feat(security): paranoid mode with LUKS2-style keyslots and hidden volumes#73
feat(security): paranoid mode with LUKS2-style keyslots and hidden volumes#73
Conversation
…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>
JVM Load Benchmark (Desktop)Synthetic in-memory benchmark measuring load performance for the desktop (JVM) app.
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 |
Android Load BenchmarkInstrumented benchmark on an API 30 x86_64 emulator — 500-page synthetic graph. Comparing Graph Load
Interactive Write Latency (during Phase 3)
SAF I/O Overhead (ContentProvider vs direct File read)Measures Binder IPC cost added by ContentResolver per readFile() call.
|
There was a problem hiding this comment.
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) andGraphWriter(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.
| import kotlinx.coroutines.withContext | ||
| import javax.crypto.Mac | ||
| import javax.crypto.spec.SecretKeySpec | ||
|
|
There was a problem hiding this comment.
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.
| } catch (e: javax.crypto.AEADBadTagException) { | ||
| throw VaultAuthException("Authentication tag verification failed: ${e.message}") |
There was a problem hiding this comment.
Fixed in commit b334000 — swapped the catch order so AEADBadTagException is caught before its superclass BadPaddingException, making both branches reachable.
| * 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()) |
There was a problem hiding this comment.
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.
| // 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| @@ -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" | |||
| } | |||
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| 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 |
There was a problem hiding this comment.
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.
| private fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean = | ||
| java.security.MessageDigest.isEqual(a, b) | ||
|
|
There was a problem hiding this comment.
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.
| // 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") | ||
| } |
There was a problem hiding this comment.
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.
| 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}") | ||
| } |
There was a problem hiding this comment.
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.
- 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>
…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>
Summary
Architecture
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
SecureRandomnonces.code.toByte()silently truncates non-ASCII codepoints — fixedPROVIDER_UNUSEDslots in unlockNew dependency
org.bouncycastle:bcprov-jdk18on:1.80— jvmMain only (Argon2id + HKDF;javax.cryptocovers ChaCha20-Poly1305 natively on Java 11+).Tests
71 new vault tests, 0 failures:
CryptoEngineTestVaultManagerTestCryptoLayerTestVaultHeaderSerializerTestVaultRoundTripTestAdversarialTestKeyslotIntegrityTestNoncePropertyTestVaultPerformanceTestAll tests use
Argon2Params(memory=4096, iterations=1)so CI stays fast. Full JVM suite: 0 failures.Out of scope (v2)
CryptoEngine(libsodium.js interop — prototyping needed first per RISK-1)Requirements, research, plan, and validation docs are in
project_plans/paranoid-mode/.Test plan
.stele-vaultappears,.md.stekfiles written./gradlew jvmTest→ BUILD SUCCESSFUL, 0 failures🤖 Generated with Claude Code