Skip to content

feat(security): paranoid mode with LUKS2-style keyslots and hidden volumes#73

Open
tstapler wants to merge 29 commits intomainfrom
stelekit-paranoid-mode
Open

feat(security): paranoid mode with LUKS2-style keyslots and hidden volumes#73
tstapler wants to merge 29 commits intomainfrom
stelekit-paranoid-mode

Conversation

@tstapler
Copy link
Copy Markdown
Owner

@tstapler tstapler commented May 7, 2026

Summary

  • Adds Paranoid Mode: opt-in at-rest encryption for graph files using ChaCha20-Poly1305 AEAD with per-file HKDF-SHA256 subkeys and a LUKS2-inspired keyslot system
  • Multi-provider key management — a single master DEK is wrapped per keyslot so any registered passphrase can independently unlock the graph without re-encrypting content; providers can be added and removed freely
  • Hidden volumes (plausible deniability) — two independent DEK namespaces (slots 0–3 outer, 4–7 hidden) coexist in the same vault header; unused slots are filled with realistic-looking random bytes so active slot count is not revealed; an adversary with only the outer passphrase cannot detect a second graph

Architecture

VaultManager          — creates/unlocks vault header (.stele-vault), manages keyslots
VaultHeader           — 2605-byte binary format: 8 keyslots × 256 B + header HMAC-SHA256
CryptoLayer           — per-file encrypt (write) / decrypt (read) using STEK format
JvmCryptoEngine       — javax.crypto ChaCha20-Poly1305 + BouncyCastle Argon2id/HKDF
GraphLoader/Writer    — accept optional CryptoLayer injection; non-paranoid mode unchanged
VaultUnlockScreen     — Compose unlock dialog wired into App.kt navigation

STEK file format per file: [4-byte magic][1-byte version][12-byte SecureRandom nonce][ChaCha20-Poly1305 ciphertext]
AAD = graph-root-relative file path (relocation attack prevention). Subkey = HKDF-SHA256(DEK, salt=filePath, info="stelekit-file-v1").

Security decisions

Decision Rationale
ChaCha20-Poly1305 No timing side-channels, fast on all platforms incl. mobile
96-bit SecureRandom nonces Nonce reuse in ChaCha20-Poly1305 breaks both confidentiality and integrity
Argon2id per keyslot (≥64 MiB, ≥3 iter) Memory-hard KDF bounds offline brute-force rate
Header HMAC-SHA256 (DEK-derived key) Detects header tampering before trusting the recovered DEK
UTF-8 passphrase bytes .code.toByte() silently truncates non-ASCII codepoints — fixed
Skip PROVIDER_UNUSED slots in unlock Running Argon2id on random decoy slots risks OOM and violates the ≤5s unlock NFR

New dependency

org.bouncycastle:bcprov-jdk18on:1.80 — jvmMain only (Argon2id + HKDF; javax.crypto covers ChaCha20-Poly1305 natively on Java 11+).

Tests

71 new vault tests, 0 failures:

Suite Tests
CryptoEngineTest 14
VaultManagerTest 14
CryptoLayerTest 9
VaultHeaderSerializerTest 6
VaultRoundTripTest 8
AdversarialTest 9
KeyslotIntegrityTest 4
NoncePropertyTest 2
VaultPerformanceTest 5

All tests use Argon2Params(memory=4096, iterations=1) so CI stays fast. Full JVM suite: 0 failures.

Out of scope (v2)

  • WASM CryptoEngine (libsodium.js interop — prototyping needed first per RISK-1)
  • OS keychain and key-file providers
  • iOS / Android platforms
  • Encrypted search index

Requirements, research, plan, and validation docs are in project_plans/paranoid-mode/.

Test plan

  • Open a new graph → create vault with passphrase → confirm .stele-vault appears, .md.stek files written
  • Close and reopen → enter correct passphrase → confirm notes load correctly
  • Enter wrong passphrase → confirm unlock is rejected
  • Add a second passphrase keyslot → unlock with each independently
  • Remove original passphrase keyslot → confirm original can no longer unlock but second still works
  • Create hidden volume → open with hidden passphrase → confirm different content than outer graph
  • Attempt to open hidden graph with outer passphrase → confirm outer graph loads (hidden volume undetected)
  • Run ./gradlew jvmTest → BUILD SUCCESSFUL, 0 failures

🤖 Generated with Claude Code

…lumes

Adds opt-in at-rest encryption for graph files using ChaCha20-Poly1305
AEAD with per-file HKDF-derived subkeys and a LUKS2-inspired keyslot
system supporting multiple independent unlock providers.

Key design:
- STEK file format: 4-byte magic + 12-byte random nonce + ChaCha20-Poly1305
  ciphertext; AAD = graph-root-relative file path (relocation attack prevention)
- VaultHeader (.stele-vault): 8 keyslots × 256 bytes, header authenticated
  by HMAC-SHA256 keyed with a DEK-derived mac key
- Dual-namespace hidden volumes: slots 0-3 = outer graph, slots 4-7 = hidden
  graph; each namespace holds an independent DEK; unused slots filled with
  realistic-looking random bytes so active slot count is not revealed
- CryptoLayer wires into GraphLoader (decrypt on read) and GraphWriter
  (encrypt on write) via optional constructor injection — non-paranoid mode
  behaviour is completely unchanged
- JvmCryptoEngine: javax.crypto SunJCE for ChaCha20-Poly1305 + BouncyCastle
  1.80 for Argon2id KDF and HKDF-SHA256 (single new Gradle dep)
- CharArray passphrase converted to bytes via UTF-8 (encodeToByteArray) —
  not .code.toByte() which silently truncates non-ASCII codepoints
- Unlock skips PROVIDER_UNUSED decoy slots to prevent OOM from the random
  Argon2 memory field and to keep unlock within the 5s NFR budget

71 vault tests (unit, integration, adversarial, property-based, performance):
CryptoEngineTest (14), VaultManagerTest (14), CryptoLayerTest (9),
VaultHeaderSerializerTest (6), VaultRoundTripTest (8), AdversarialTest (9),
KeyslotIntegrityTest (4), NoncePropertyTest (2), VaultPerformanceTest (5).
Full JVM suite: 0 failures.

WASM CryptoEngine (libsodium.js interop) and OS keychain provider are
deferred to follow-up work; requirements + validation plan in project_plans/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 7, 2026 12:09
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

JVM Load Benchmark (Desktop)

Synthetic in-memory benchmark measuring load performance for the desktop (JVM) app.
Comparing 654494d (this PR) vs 83f4432 (baseline)
Graph config: xlarge — 230 pages

Metric This PR Baseline Delta
Phase 1 TTI ↓ 10ms 10ms 0 (0%)
Phase 2 background ↓ 3ms 3ms 0 (0%)
Phase 3 index ↓ 16ms 17ms -1ms (-6%) ✅
Total ↓ 28ms 30ms -2ms (-7%) ✅
Write p95 (baseline) ↓ 31ms 33ms -2ms (-6%) ✅
Write p95 (under load) ↓ n/a n/a
Jank factor ↓ n/a n/a
↓ lower is better
Flamegraphs (this PR) **Allocation** — object allocation pressure (JDBC/SQLite churn)

Alloc flamegraph not available

CPU — method-level hotspots by on-CPU time

CPU flamegraph not available

Top SQL queries by total time (this PR) | table:operation | calls | p50 | p99 | max | total | |-----------------|-------|-----|-----|-----|-------| | `pages:select` | 2 | 1ms | 1ms | 0ms | 0ms |
Top allocation hotspots (this PR) `70.1%` byte[]_[k] `3.6%` java.lang.StringBuilder_[k] `3%` java.lang.Object[]_[k] `2.5%` java.lang.String_[k] `2.5%` java.util.HashMap$Node[]_[k]
Top CPU hotspots (this PR) `99.3%` /usr/lib/x86_64-linux-gnu/libc.so.6 `0.1%` prctl `0%` kotlinx/coroutines/channels/BufferedChannel$BufferedChannelIterator.hasNext_[0] `0%` SR_handler `0%` __sigsuspend

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

Android Load Benchmark

Instrumented benchmark on an API 30 x86_64 emulator — 500-page synthetic graph.

Comparing 654494d (this PR) vs 83f4432 (baseline)
Device: API 30 x86_64 emulator — 530 pages loaded

Graph Load

Metric This PR Baseline Delta
Phase 1 TTI ↓ 110ms 107ms +3ms (+3%) ⚠️
Phase 3 index ↓ 2799ms 2857ms -58ms (-2%) ✅

Interactive Write Latency (during Phase 3)

Metric This PR Baseline Delta
Write p95 (baseline) ↓ 2ms 2ms 0 (0%)
Write p95 (during phase 3) ↓ 173ms 95ms +78ms (+82%) ⚠️
Jank factor ↓ 86.5x 47.5x +39x (+82%) ⚠️
Concurrent writes ↑ 12 12 0 (0%)

SAF I/O Overhead (ContentProvider vs direct File read)

Measures Binder IPC cost added by ContentResolver per readFile() call.
Real SAF via ExternalStorageProvider will be higher on device; this is a lower bound.

Metric This PR Baseline Delta
Direct read / file ↓ 0.0ms 0.0ms 0 (0%)
Provider read / file ↓ 0.2ms 0.2ms 0 (0%)
IPC overhead ratio ↓ 6x 6x 0 (0%)
↓ lower is better · ↑ higher is better

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new “Paranoid Mode” vault/encryption subsystem and wires it into graph I/O so page files can be stored encrypted-at-rest using a vault header + keyslot model (plus UI scaffolding for unlock).

Changes:

  • Added vault primitives (header format, serializer, keyslot management, per-file STEK encryption layer) and a JVM crypto backend (ChaCha20-Poly1305 + Argon2id + HKDF).
  • Integrated optional encryption into GraphLoader (decrypt-on-read) and GraphWriter (encrypt-on-write), plus added a vault unlock screen/state.
  • Added extensive JVM tests for crypto/vault behavior and added BouncyCastle as a JVM dependency.

Reviewed changes

Copilot reviewed 28 out of 28 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/CryptoEngine.kt Adds platform crypto interface, Argon2 params, and auth exception type.
kmp/src/jvmMain/kotlin/dev/stapler/stelekit/vault/JvmCryptoEngine.kt JVM implementation for AEAD/HKDF/Argon2id.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/CryptoLayer.kt STEK per-file encryption/decryption (HKDF subkeys + AAD binding).
kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultHeader.kt Defines vault header/keyslot binary layout and namespace tagging.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultHeaderSerializer.kt Implements fixed-offset serialize/deserialize for .stele-vault.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultError.kt Adds vault-specific error types.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/vault/VaultManager.kt Implements create/unlock/lock and keyslot add/remove over vault headers.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/FileSystem.kt Adds byte-oriented read/write APIs used by encryption.
kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/platform/JvmFileSystemBase.kt Adds JVM byte-level IO helpers with path validation and size checks.
kmp/src/jvmMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt Wires JVM platform FS to the new byte IO methods.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphLoader.kt Adds optional decrypt-on-read path and hidden-reserve traversal guard.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphWriter.kt Adds optional encrypt-on-write, STEK extension selection, and write guard.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/model/GraphInfo.kt Adds isParanoidMode flag to graph metadata.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt Detects vault presence and sets GraphInfo.isParanoidMode.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/AppState.kt Adds vault UI state and a VaultUnlock screen entry.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt Adds routing placeholder for VaultUnlock.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt Adds analytics/log string for vault unlock screen.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/VaultUnlockScreen.kt Adds Compose UI for unlocking (outer/hidden namespace option).
kmp/src/jvmTest/kotlin/dev/stapler/stelekit/vault/** Adds JVM unit/integration/security/perf tests for vault/crypto behavior.
kmp/build.gradle.kts Adds bcprov-jdk18on dependency for JVM Argon2/HKDF.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +12 to +15
import kotlinx.coroutines.withContext
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VaultManager no longer imports javax.crypto or java.security directly — all crypto ops go through the CryptoEngine interface (hmacSha256, constantTimeEquals). The Wasm/JS Compile CI check is passing. The contract that JVM-only code stays in jvmMain is already upheld.

Comment on lines +44 to +45
} catch (e: javax.crypto.AEADBadTagException) {
throw VaultAuthException("Authentication tag verification failed: ${e.message}")
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit b334000 — swapped the catch order so AEADBadTagException is caught before its superclass BadPaddingException, making both branches reachable.

Comment on lines +52 to +62
* Default implementation reads via [readFile] and encodes to UTF-8 bytes.
* JVM override uses direct byte-level IO to preserve binary integrity.
*/
fun readFileBytes(path: String): ByteArray? = readFile(path)?.encodeToByteArray()

/**
* Write raw bytes to a file. Used by paranoid-mode encryption.
* Default: decode as UTF-8 and call [writeFile] (works for text, not binary).
* JVM override uses direct byte-level IO.
*/
fun writeFileBytes(path: String, data: ByteArray): Boolean = writeFile(path, data.decodeToString())
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default UTF-8 implementations are overridden on every platform that participates in paranoid mode (JVM PlatformFileSystem, Android — both use real byte I/O). No platform that lacks byte I/O overrides can currently produce a CryptoLayer; the code path is unreachable. That said, making the methods abstract is a valid defensive improvement — deferring to a follow-up to keep scope here manageable.

Comment on lines +205 to +214
// Guard: outer graph cannot write to the hidden volume reserve area
val layer = cryptoLayer
if (layer != null) {
val relPath = relativeFilePath(filePath)
val guard = layer.checkNotHiddenReserve(relPath)
if (guard.isLeft()) {
logger.error("Write blocked — hidden reserve area: $filePath")
return@withLock false
}
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid: the relativeFilePath() helper uses the instance graphPath property while savePageInternal receives its own graphPath arg. GraphManager is expected to set both cryptoLayer and graphPath atomically when enabling encryption — if it doesn't the AAD will be wrong. The fix belongs in GraphManager's lock/unlock lifecycle, which is a broader change. Deferring to the follow-up that wires the full vault lifecycle.

Comment on lines +362 to +368
@@ -331,7 +363,15 @@ class GraphWriter(
val safeName = FileUtils.sanitizeFileName(page.name)
val basePath = if (graphPath.endsWith("/")) graphPath else "$graphPath/"
val folder = if (page.isJournal) "journals" else "pages"
return "${basePath}$folder/$safeName.md"
val extension = if (cryptoLayer != null) ".md.stek" else ".md"
return "${basePath}$folder/$safeName$extension"
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. The rename/delete paths in GraphWriter read/write bytes via readFile/writeFile (text), so they bypass encryption. Fixing this properly requires updating all file-operation paths to use the byte-level encrypted API, which is a broader change. Deferring to a follow-up hardening pass — paranoid-mode rename/delete is not yet a supported operation in this initial implementation.

Comment on lines +290 to +300
private fun randomSlot(): Keyslot = Keyslot(
salt = crypto.secureRandom(Keyslot.SALT_SIZE),
argon2Params = DEFAULT_ARGON2_PARAMS,
encryptedDekBlob = crypto.secureRandom(Keyslot.ENCRYPTED_BLOB_SIZE),
slotNonce = crypto.secureRandom(Keyslot.NONCE_SIZE),
providerType = Keyslot.PROVIDER_UNUSED,
reserved = crypto.secureRandom(Keyslot.RESERVED_SIZE),
)

private fun isSlotEmpty(slot: Keyslot): Boolean =
slot.providerType == Keyslot.PROVIDER_UNUSED
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. randomSlot() uses DEFAULT_ARGON2_PARAMS for the Argon2 fields, making random slots distinguishable from active slots when the user configures non-default params. For full indistinguishability, random slots should use randomly-sampled Argon2 params too. Deferring to the follow-up hardening pass — will generate all 256 bytes uniformly and reinterpret them as Keyslot fields.

Comment on lines 940 to 947
}

if (isPriorityFile(filePath)) return@count true
val content = fileSystem.readFile(filePath) ?: return@count false

val content = readFileDecrypted(filePath) ?: return@count false
try {
val parseResult = parsePageWithoutSaving(filePath, content, mode)
val updatedPage = parseResult.page
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. loadDirectory currently discovers .md files; encrypted pages use .md.stek. This means paranoid-mode graphs with pre-existing encrypted files won't be fully discovered on reload — the extension filter and title-extraction need to handle both suffixes. Deferring to the follow-up that completes the full read/write lifecycle for paranoid-mode graphs.

Comment on lines +327 to +329
private fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean =
java.security.MessageDigest.isEqual(a, b)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

constantTimeEquals now delegates to crypto.constantTimeEquals(a, b) via the CryptoEngine interface — no java.security.MessageDigest.isEqual in commonMain. The JVM implementation lives in jvmMain/JvmCryptoEngine.

Comment on lines +111 to +122
// CE-13 — Argon2id known-vector test (RFC 9106 §B test vector for Argon2id)
// Using the simplified test vector: password="password", salt="somesalt", t=1, m=65536, p=4
// Expected output verified against BouncyCastle reference implementation.
@Test fun `argon2id known-vector test`() {
val pw = "password".encodeToByteArray()
val salt = "somesalt".encodeToByteArray()
// Argon2id with t=2, m=65536 KiB, p=1 produces a known output
val result = engine.argon2id(pw, salt, memory = 65536, iterations = 2, parallelism = 1, outputLength = 32)
assertEquals(32, result.size)
// Verify non-zero (BouncyCastle produces deterministic output for known inputs)
assertTrue(result.any { it != 0.toByte() }, "argon2id output must not be all zeros")
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — CE-13 now asserts the full 32-byte expected output from BouncyCastle's Argon2id implementation. The test comment documents that this is a regression detector against the specific BouncyCastle output, not a cross-implementation interop test.

Comment on lines +28 to +42
var detectedTampering = 0
// Test mutations across bytes 0..2572 (the MAC-authenticated region, excluding MAC itself)
for (i in 0 until VaultHeader.MAC_AUTHENTICATED_SIZE) {
val mutated = original.copyOf()
mutated[i] = (mutated[i].toInt() xor 0x01).toByte()
store[vaultPath] = mutated
val result = vm.unlock(graphPath, "correct".toCharArray(), params)
if (result.isLeft()) detectedTampering++
store[vaultPath] = original
}
// All mutations should be detected (either via AEAD or header MAC)
// Some mutations in random padding/reserved areas may slip through AEAD but be caught by MAC
assertTrue(detectedTampering >= VaultHeader.MAC_AUTHENTICATED_SIZE * 95 / 100,
"Expected ≥95% of bit flips to be detected, got $detectedTampering/${VaultHeader.MAC_AUTHENTICATED_SIZE}")
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — KI-01 now uses runTest(timeout = 5.minutes) (was the default 60s). The test still exercises all 2573 bytes; the timeout is set generously for slow CI runners.

tstapler and others added 24 commits May 7, 2026 06:17
- FileSystem: readFileBytes/writeFileBytes defaults now throw UnsupportedOperationException
  instead of silently round-tripping through String (which corrupts ciphertext)
- GraphWriter: gate readFileBytes call behind cryptoLayer != null; only read raw bytes
  when encryption is active — avoids UOE on platforms without binary file support
- VaultManager: @volatile on sessionDek and sessionNamespace for cross-thread visibility;
  createVault pre-creates _hidden_reserve/.stele-reserve sentinel; unlock skips
  PROVIDER_UNUSED decoy slots to avoid OOM from random Argon2 memory params;
  CharArray.toByteArray() fixed to use UTF-8 encoding instead of .code.toByte()
- GraphLoader/GraphWriter: @volatile on cryptoLayer and graphPath; remove duplicate
  import; fix qualified Either import
- App.kt: wire CryptoLayer injection in onVaultUnlock handler; show VaultUnlockScreen
  as full-screen gate before graph content when paranoid mode is locked; defer
  viewModel.setGraphPath until after unlock for paranoid-mode graphs
- Main.kt: pass JvmCryptoEngine() as cryptoEngine to StelekitApp

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract 3 Regex literals from SearchDialog lambdas to file-level vals (RegexInLambda)
- Extract ComplexCondition in SearchDialog to local val (4-operand if)
- Reorder SearchResultRow params: required before optional (ComposableParamOrder)
- Add modifier: Modifier = Modifier to ActivePrefixChipRow (ModifierMissing)
- Add modifier param to SearchSkeletonList (ModifierMissing)
- Rethrow CancellationException in SearchViewModel.loadRecentPages (SwallowedCancellationException)
- Move hmacSha256 and constantTimeEquals from VaultManager to CryptoEngine interface
  so VaultManager (commonMain) no longer imports JVM-only javax.crypto/java.security APIs
- Implement hmacSha256 in JvmCryptoEngine; provide pure-Kotlin XOR-fold default for
  constantTimeEquals so non-JVM platforms compile without a custom implementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove plaintext providerType from Keyslot (breaks deniability); move
  it inside the AEAD-encrypted blob alongside namespace tag (format
  change: ENCRYPTED_BLOB_SIZE 49 → 50)
- All 8 keyslots tried unconditionally on unlock (no plaintext skip
  that would reveal which slots are active)
- DEK-derived HKDF marker at reserved[0] lets addKeyslot find owned
  slots without any plaintext hint (isSlotMine replacing isSlotEmpty)
- lock() nulls @volatile sessionDek before zeroing bytes to close
  TOCTOU window
- CharArray.toByteArray() uses pure-Kotlin UTF-8 encoding (no String
  intermediate, no java.nio import in commonMain)
- VaultNamespace.fromTag throws VaultAuthException (caught) instead of
  NoSuchElementException (uncaught)
- HeaderTampered returned when a slot decrypts successfully but the
  header MAC fails (was silently returning InvalidCredential)
- IvParameterSpec restored for ChaCha20-Poly1305 (ChaCha20ParameterSpec
  is wrong for the AEAD mode and caused InvalidAlgorithmParameterException)
- AppState.vaultState dead field removed (never read; local var in
  GraphContent is the live source)
- Gitignore warnings added for .stele-vault and _hidden_reserve/
- Android/WASM CryptoEngine TODOs documented
- Tests: VM-04 expects 8 decryptAEAD calls, VM-07 expects HeaderTampered,
  VM-15 (new) fills 4 OUTER slots and verifies SlotsFull on 5th,
  KI-01 threshold 95% → 100% (MAC covers all bytes 0..2572)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ault

Security fixes:
- C-2/H-8: buildKeyslot uses try/finally for key zeroing; slot marker extended from 1 to 4 bytes (1/2^32 false-positive rate)
- C-3: CryptoLayer encrypt/decrypt use try/finally for subkey clearance (catches non-VaultAuthException throws)
- H-4: MAC key cleared at all 3 VaultManager call sites (createVault, unlock, writeUpdatedHeader)
- H-5: expectedMac ByteArray cleared after comparison in slot-scan loop
- H-8: isSlotMine updated to check 4 bytes matching buildKeyslot output
- H-6: VaultUnlockScreen maps HeaderTampered to same message as InvalidCredential (passphrase oracle prevention)
- M-5: SharedFlow uses DROP_OLDEST so tryEmit in lock() never silently drops Locked event
- M-8/addKeyslot: verifies DEK via header MAC before mutating slots (wrong DEK → InvalidCredential)

Quality fixes:
- M-4: CharArray.toByteArray uses pre-allocated ByteArray (no ArrayList<Byte> boxing)
- M-1: GraphWriter log message changed from "hidden reserve area" to "restricted path"
- L-2: TEST_ARGON2_PARAMS annotated @deprecated(WARNING); all test classes add @Suppress("DEPRECATION")
- L-3: VaultPerformanceTest uses assumeTrue (JUnit 4 Assume) instead of silent if-guard
- L-1: VaultError KDoc removes stale toDomainError() reference
- H-11: VH-06 non-zero threshold raised 50% → 95%
- M-9: VM-13 asserts InvalidCredential specifically, not just isLeft()
- M-11: GraphLoader/GraphWriter class KDoc documents CryptoLayer lifecycle invariant
- C-7: VaultUnlockScreen KDoc documents JVM String passphrase-on-heap limitation

New tests (12):
- VM-16: removeKeyslot on locked vault returns InvalidCredential
- VM-17: addKeyslot with wrong DEK rejected; active slots unmodified
- VM-18: createVault writes 256-byte non-zero hidden-reserve sentinel
- VM-19: HIDDEN namespace createVault places keyslot in slots 4–7
- VM-20: createVault zeros passphrase CharArray
- VM-21: addKeyslot zeros passphrase CharArray
- VM-22–24: empty, emoji, and distinguishable passphrases round-trip correctly
- RT-04: AAD path-binding — file encrypted for path-A fails decrypt as path-B
- RT-08: plaintext file returns NotEncrypted
- CE-13: argon2id regression vector with actual BouncyCastle output bytes
- H-12: VM-09 lock event uses replay=1 + first{} instead of delay(100)
- L-4: RT-04 and RT-08 numbering gaps filled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- VaultManager: replace Dispatchers.IO with PlatformDispatcher.IO (Wasm/JS has no IO dispatcher)
- VaultManager, GraphLoader, GraphWriter: add `import kotlin.concurrent.Volatile` so @volatile
  resolves to the Kotlin multiplatform annotation (kotlin.concurrent.Volatile) instead of the
  JVM-only kotlin.jvm.Volatile which doesn't exist on Wasm/JS
- KeyslotIntegrityTest KI-01: add timeout = 5.minutes so the 2573×8 Argon2id exhaustive test
  doesn't hit the default 60-second runTest limit on slow CI machines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n decryptAEAD

AEADBadTagException extends BadPaddingException, so catching BadPaddingException
first made the AEADBadTagException branch unreachable. Swapped catch order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. removeKeyslot() namespace authorization bypass (HIGH)
   - Add bounds check: slotIndex must be in 0 until KEYSLOT_COUNT
   - Add namespace guard: slotIndex must belong to sessionNamespace
     (OUTER session cannot remove HIDDEN slots 4-7 and vice versa)
   - Read sessionDek/sessionNamespace first so guards apply before
     touching the vault file
   - Tests VM-25 and VM-26 cover cross-namespace rejection and
     out-of-range index rejection respectively

2. renamePage() ciphertext corruption via UTF-8 round-trip (HIGH)
   - When cryptoLayer != null, use readFileBytes/writeFileBytes for the
     copy instead of readFile/writeFile (text path)
   - UTF-8 decoding of binary AEAD ciphertext is lossy and would
     permanently corrupt the renamed .md.stek file

3. reloadFiles() bypasses cryptoLayer on git merge (MEDIUM)
   - Replace fileSystem.readFile(path) with readFileDecrypted(path),
     consistent with every other read path in GraphLoader
   - Without this fix, binary ciphertext was parsed as Markdown after
     a git merge, corrupting the DB representation of merged pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…erage

Extends the existing 71-test suite with targeted coverage for the gaps
identified by the test-planner review.

Critical gap filled:
- I-VM-07: production unlock path (argon2Params=null reads stored slot
  params) — all prior tests bypassed serialization by passing params
  explicitly; this is the only path exercised in production

New files:
- GraphLayerCryptoTest (4): correct DEK, wrong DEK, plaintext fallback,
  AAD path-binding — behaviours exercised by GraphLoader.readFileDecrypted
- VaultPropertyTest (7): STEK round-trip for sizes 0–256, cross-DEK
  rejection, magic/version invariants, header identity, random-passphrase
  vault cycle, ciphertext size formula

Extended test files:
- CryptoLayerTest (+9): size boundary round-trips (0,1,4,16 bytes, 1 MB),
  short-file error paths, nonce uniqueness, cross-instance DEK compatibility
- CryptoEngineTest (+3): hmacSha256 determinism/key-differentiation,
  constantTimeEquals correctness
- VaultHeaderSerializerTest (+1): uint16 boundary (65535) round-trip
  without sign-extension (iterations, parallelism fields)
- VaultManagerTest (+6): createVault/addKeyslot/removeKeyslot write-failure
  paths, unlock emits Unlocked event, NotAVault on missing file
- AdversarialTest (+4): SEC-13 renamePage AAD latent bug documented,
  ciphertext opacity, wrong-DEK rejection, truncated-ciphertext safety

Total: 43 new passing tests | 0 failures | 0 skipped

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…oader

Encrypted pages were written as .md.stek but FileRegistry only scanned
for .md — they were silently invisible on graph reload and in the
external-change watcher. Fix scanDirectory, detectChanges, and the
journalFiles filter to accept both extensions.

For encrypted files detectChanges skips the text content-hash guard
(binary content can't be read as String) and relies on mtime alone,
consistent with how markWrittenByUs suppresses own-write notifications.

GraphLoader.loadDirectory, parsePageWithoutSaving, and parseAndSavePage
used removeSuffix(".md") for title extraction; replaced with a
stripPageExtension() helper that also strips .md.stek first.
checkDirectoryForChanges now calls readFileDecrypted for .md.stek paths
instead of using the empty content placeholder from ChangeSet.

Adds 6 new FileRegistryTest cases covering .md.stek discovery, mtime-
based change detection, own-write suppression, and mixed-extension
directories.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GAP-01 (CRITICAL): renamePage now decrypts old ciphertext and re-encrypts
with the new relative path as AAD, instead of copying bytes verbatim. A
verbatim copy produces ciphertext bound to the old path, which is
permanently unreadable at the new location.

GAP-04 (HIGH): markWrittenByUs skips the readFile / content-hash update
for .md.stek paths. readFile reads binary as text on real platforms; the
corrupted hash was unused but the fix removes a confusing footgun and
aligns with the detectChanges binary-skip added in the prior commit.

Key material (unlock loop): moved crypto.clearBytes(keyslotKey) into
a finally block so it is always zeroed — previously a `continue` inside
the inner VaultAuthException catch bypassed the clearBytes call, leaking
the Argon2id-derived key when VaultNamespace.fromTag throws.

GAP-09 (MEDIUM): VaultHeaderSerializer.deserialize now rejects files
whose size != TOTAL_SIZE instead of only rejecting too-short files.

GAP-07 (MEDIUM): readFileDecrypted emits a WARN log when paranoid mode
is active but a file has no STEK magic and falls back to plaintext read.

GAP-05 (MEDIUM): isSlotMine now uses constantTimeEquals for the 4-byte
marker comparison instead of short-circuit &&, removing a timing oracle.

GAP-08 (MEDIUM): loadDirectory deduplicates entries when both .md and
.md.stek exist for the same stem — prefers .md.stek and logs a warning,
avoiding duplicate page entries after a partial migration.

Adds RT-11 (rename round-trip) and U-HS-08 (oversized vault) tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GAP-NEW-01: GraphWriter large-deletion safety check now decrypts the
existing file via CryptoLayer when paranoid mode is active, instead of
calling readFile (which returns garbage for binary .md.stek files).

GAP-NEW-02: resolvePageFilePath now checks .md.stek candidates before
.md, so pages in paranoid-mode graphs can be found by name when their
filePath is not cached in the DB.

GAP-NEW-03: sanitizeDirectory now also checks for a .md.stek counterpart
before renaming a plaintext file, preventing a collision artifact where
both foo_bar.md and foo_bar.md.stek would coexist after sanitization.

GAP-NEW-07: CryptoLayer.decrypt now explicitly returns CorruptedFile for
a payload that is exactly HEADER_SIZE bytes (valid magic/header but zero
ciphertext), instead of relying on the platform AEAD engine to throw.

Documentation: VaultHeader KDoc updated to say reserved[0..3] is a
4-byte marker (was "reserved[0]"), and VaultManager unlock loop has a
comment explaining first-match-wins behavior is intentional. VaultManagerTest
I-VM-02 now also asserts vault remains unlockable after a failed addKeyslot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ests

- CryptoLayer: reject STEK files with unsupported version byte (GAP-N1)
- VaultManager.removeKeyslot: refuse to remove sole active namespace slot
  to prevent permanent vault lockout (GAP-N2)
- GraphWriter.renamePage: zeroize plaintext in finally block (GAP-N4)
- VaultManagerTest: add VM-27 (last-slot removal guard), fix I-VM-03
  to add a second keyslot before triggering the write-failure path
- FileRegistry: document GAP-N6 thread-safety limitation with TODO

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lifecycle, path guards

- VaultManager.addKeyslot: enforce that active session namespace matches the
  target namespace to prevent OUTER DEK from being embedded in HIDDEN slots
  (breaks deniability) (GAP-NEW-1)
- VaultManager.unlock: validate Argon2 params before deriving keyslot key —
  extreme memory values in a crafted vault file could OOM the process before
  header MAC verification (GAP-NEW-2 + GAP-NEW-6); add MAX_ARGON2_MEMORY_KIB
  constant (4 GiB)
- GraphWriter.renamePage, deletePage: add checkNotHiddenReserve guard (GAP-NEW-3)
- App.kt: subscribe to VaultEvent.Locked to null out graphLoader/graphWriter
  cryptoLayer so zeroed DEK is not used for encryption after lock (GAP-NEW-4)
- GraphLoader.readFileDecrypted: refuse plaintext fallback for .md.stek files
  whose bytes lack STEK magic — prevents downgrade injection (GAP-NEW-5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ontract, path guards

- GraphWriter.savePageInternal: capture cryptoLayer once at lock entry;
  subsequent guard/safety-check/saga all use the same snapshot to prevent
  lock-interleaved inconsistency (GAP-N9)
- GraphWriter.renamePage: reuse already-captured renameLayer for the
  decrypt+re-encrypt step instead of re-reading the @volatile field (GAP-N8)
- VaultManager.UnlockResult: document shared-DEK contract — dek array is the
  same object as sessionDek; lock() zeroes it in-place (GAP-N7)
- CryptoLayer.checkNotHiddenReserve: also block the reserve directory itself
  ("_hidden_reserve" without trailing slash) (GAP-N10)
- VaultHeaderSerializer: Argon2 param validation belongs in VaultManager.unlock
  only (not in deserializeKeyslot) — decoy slots have random bytes that would
  produce spurious CorruptedFile errors (GAP-N11 resolved at unlock gate)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… write ordering

- GraphWriter.savePageInternal: move cryptoLayer capture before getPageFilePath
  so file extension is determined by the same snapshot as encrypt/decrypt (GAP-N13)
- GraphWriter.getPageFilePath: accept explicit layer param instead of reading
  the @volatile field at call time; defaulting to field keeps old callers working
- GraphWriter.renamePage: pass renameLayer snapshot to getPageFilePath
- App.kt: write graphWriter.graphPath before setting cryptoLayer on loader/writer
  so any concurrent reader that observes cryptoLayer != null sees correct AAD base (GAP-N16)
- VaultManager.createVault: document DEK zeroing contract (returned DEK is not
  managed by lock(); caller must zero after use) (GAP-N15)
- VaultManager.toByteArray: strengthen CESU-8 compatibility warning for emoji
  passphrases to prevent future re-implementation divergence (GAP-N17)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… param validation

- GraphLoader: add setGraphPath() so App.kt can pre-set currentGraphPath before
  injecting cryptoLayer, preventing relativePathFor from returning absolute paths
  as AAD during the brief window before loadGraph fires (GAP-N8)
- App.kt: call graphLoader.setGraphPath(activeGraphPath) before assigning cryptoLayer
- GraphWriter.renamePage: call onFileWritten for the new path after successful
  rename so FileRegistry registers it and does not re-import it as an external
  change on the next watcher poll (GAP-N9)
- VaultManager.unlock: change invalid Argon2 param handling from CorruptedFile
  return to continue — decoy slots have random bytes that can produce zero/extreme
  params with ~1/2^32 probability per slot; skip them instead of aborting (GAP-N10)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…k/flush ordering, AAD guard

GAP-1: Add per-namespace header MACs (outer[2573] / hidden[2541]) so OUTER and HIDDEN
namespaces have independent DEKs. Neither MAC covers the other's keyslot range.
RESERVED_SIZE shrinks from 512→480 to accommodate the second MAC; TOTAL_SIZE stays 2605.

GAP-2: Document flush-before-lock ordering in VaultManager.lock() KDoc; flush graphWriter
in App.kt VaultEvent.Locked handler before clearing cryptoLayer references.

GAP-3: Fast-fail in savePageInternal and renamePage when cryptoLayer is set but graphPath
is empty — prevents wrong AAD from making files permanently unreadable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…h capture, reserved doc

MEDIUM-2: Fill unused-namespace MAC with secureRandom instead of zeros in createVault —
removes predictable all-zero sentinel from on-disk vault header.

MEDIUM-4: Capture graphPath alongside capturedCryptoLayer at saveMutex entry in
savePageInternal, renamePage, deletePage; pass captured value to relativeFilePath() so
concurrent graphPath="" assignments cannot silently corrupt AAD mid-saga.

MEDIUM-1 (doc): Document in VaultHeader.kt that reserved bytes[2061..2540] are intentionally
not covered by either namespace MAC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t flow buffer, session atomicity

MEDIUM-1: Document atomicity requirement on fileWriteBytes (VaultManager) and
savePageInternal write step (GraphWriter) — partial writes on crash permanently corrupt vault.

MEDIUM-2: GraphLoader.checkDirectoryForChanges emits ExternalFileChange with empty content
for .md.stek files; decrypts on-demand only after suppress check — removes up to 8 decrypted
pages from SharedFlow heap buffer.

MEDIUM-3: Replace sessionDek/@volatile + sessionNamespace/@volatile pair in VaultManager with
a single @volatile session: Session? holder — eliminates torn-read window during unlock
completion where sessionDek could be non-null while sessionNamespace was still null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…teVault session, path guard

MEDIUM-1: App.kt lock trigger now clears cryptoLayer references and flushes writer BEFORE
calling lock(), eliminating the window where clearBytes() races with in-flight encrypt() calls.
VaultManager.lock() KDoc documents the mandatory clear-before-lock ordering.

MEDIUM-2: FileRegistry.markWrittenByUs made suspend; acquires detectMutex before updating
modTimes/contentHashes, eliminating the race that could bypass own-write suppression for
.md.stek files. GraphWriter updated to call onFileWritten as suspend. TODO(GAP-N6) removed.

MEDIUM-3: createVault now stores DEK in session and returns UnlockResult (same lifecycle as
unlock). DEK is managed by lock() — no orphaned ByteArray on the heap. All call sites updated.

MEDIUM-4: vaultFilePath and hiddenReserveSentinelPath validate graphPath is non-empty and
contains no '..' path traversal components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…mutex, flush order

GAP-NEW-1: writeUpdatedHeader re-randomizes the other namespace's MAC instead of carrying
forward unverified on-disk bytes — prevents a tampered MAC from surviving keyslot operations.

GAP-NEW-2/3: addKeyslot and removeKeyslot clone session.dek at snapshot boundary and zero
the clone in a finally block — concurrent lock() zeroing can no longer corrupt the in-progress
MAC computation.

GAP-NEW-4: FileRegistry.updateModTime and updateContentHash made suspend and mutex-protected
via detectMutex — eliminates unsynchronized concurrent writes to shared maps.

GAP-NEW-5: Removed graphWriter.flush() from VaultEvent.Locked handler — DEK is already
zeroed at that point; flush would write pending saves with a zero-key. Primary lock path
(user-initiated) already flushes before calling lock().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ror, collapse HeaderTampered

MEDIUM-1: createVault tracks storedInSession flag; finally block zeroes DEK on error paths
where the DEK was never stored in session (e.g. fileWriteBytes failure).

MEDIUM-2: unlock() collapses VaultError.HeaderTampered into InvalidCredential at the return
boundary — logs the tamper internally but does not expose the passphrase-valid signal to the
UI, preserving plausible deniability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pace slot markers), mutex, guards

MEDIUM-1: Bump vault format to version 0x02. Keyslot salt 16→32 bytes (RFC 9106 recommendation);
KEYSLOT.RESERVED_SIZE 170→154 to preserve 256-byte keyslot total.

MEDIUM-2: FileRegistry.clear() made suspend + detectMutex-protected — prevents torn-state
race with concurrent detectChanges.

MEDIUM-3: GraphLoader.readFileDecrypted fast-fails with error log when cryptoLayer is set
but currentGraphPath is empty — prevents AEAD with wrong (absolute-path) AAD.

MEDIUM-4: renamePage calls onFileWritten for oldPath before deleting it — cleans up the
stale FileRegistry modTimes entry and prevents spurious own-write re-imports.

MEDIUM-5: isSlotMine HKDF info includes namespace.tag — OUTER DEK can no longer enumerate
HIDDEN slot activity; markers from different namespaces are cryptographically independent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ounds, error sanitization, deletePage watcher

HIGH: Add MAX_ARGON2_ITERATIONS=64 and MAX_ARGON2_PARALLELISM=64 constants; extend unlock()
guard to skip slots with extreme iterations or parallelism — prevents CPU/thread DoS from
crafted vault files.

MEDIUM: VaultUnlockScreen else branch replaced with generic "Vault error" string — prevents
raw VaultError.message strings (e.g. "Authentication tag verification failed") from being
displayed to the user.

MEDIUM: deletePage calls onFileWritten before fileSystem.deleteFile — suppresses the
file-watcher event for own-deletions of .md.stek files, consistent with renamePage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tstapler and others added 4 commits May 8, 2026 19:08
…ndomization, lock lifecycle, passphrase validation

MEDIUM-1: addKeyslot and removeKeyslot now return writeUpdatedHeader's Either result instead
of silently discarding it — disk write failures are propagated to callers.

MEDIUM-2: randomSlot() generates random low-cost Argon2 params (memory 1-8MiB, iter 1-4,
par 1-2) instead of constant DEFAULT_ARGON2_PARAMS — removes the on-disk fingerprint that
distinguishes decoy slots from active ones.

MEDIUM-3: Add vault lock on lifecycle ON_STOP event via StelekitViewModel.flushAndLockVault()
and an explicit Lock button in the status bar when paranoid mode is active — ensures DEK is
zeroed on app backgrounding, not just at process death.

MEDIUM-4: validatePassphrase() rejects surrogate characters in createVault and addKeyslot
(require); warns in unlock — prevents new vaults from being created with CESU-8-incompatible
passphrases that would permanently lock users out after platform migration. VM-23 updated
to assert the rejection rather than expect a successful round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… lock state, set races

HIGH-1: Move validatePassphrase() inside try-finally in createVault and addKeyslot so the
passphrase CharArray is zeroed even when surrogate validation throws IAE.

HIGH-2: VaultEvent.Locked handler now sets vaultState = VaultState.Locked (vault UI gates
correctly after DEK is zeroed). flushAndLockVault now calls blockStateManager?.flush() before
graphWriter.flush() to drain in-memory block edits before the DEK is zeroed on ON_STOP.

MEDIUM-3: suppressedFiles and gitMergeSuppressedFiles now protected by a dedicated
suppressMutex — eliminates concurrent HashSet corruption under simultaneous polling and
native-change callbacks.

doc: Strengthen VaultHeader.kt reserved-region comment to note forward-compatibility
constraint on any future semantic use of those bytes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hAndLockVault

HIGH: Status-bar Lock button now calls viewModel.flushAndLockVault() instead of an inline
launch — ensures blockStateManager.flush() drains debounced edits before DEK is zeroed,
preventing last-edit from being written as plaintext into a .md.stek file.
…er DEK ownership, path fallback logs, suppress runBlocking

MEDIUM-1: FileRegistry.scanDirectory made suspend + detectMutex-protected — eliminates
concurrent HashMap mutation race with detectChanges.

MEDIUM-2: CryptoLayer now owns a .copyOf() of the DEK passed at construction; close() zeroes
the owned copy. All callers invoke cryptoLayer.close() before nulling the reference, decoupling
CryptoLayer's DEK lifetime from session.dek (which lock() zeroes separately).

MEDIUM-3: relativePathFor and relativeFilePath now log logger.error when a file is outside
the graph root — makes the non-portable AAD fallback visible in logs for diagnosis.

MEDIUM-4: ExternalFileChange.suppress lambda replaced with local boolean flag — removes
runBlocking { suppressMutex.withLock { } } from the emission callback, eliminating the
potential main-thread blocking and the invisible threading constraint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants