From 670f87a609a421b1bdc9d08af39b4123901a20ab Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 01:13:07 +0200 Subject: [PATCH] docs: record what phase 3 turned out to require Phase 3 is implemented in the fork on claude/jvm-target-actuals (ce49657). The security analysis in the plan held up; three practical constraints around it did not appear until the code was written. **A passphrase-derived KEK is not a drop-in.** The plan treated the choice between a passphrase, an OS keychain and a key file as the whole decision. But keyStoreEncryption(keyName, plainText) takes no context and no secret -- on android the OS holds the key, so none is needed -- which means any passphrase scheme needs an out-of-band unlock the expect cannot express. That is a change to application startup, not just to the actual, so it is now called out against phase 5: the desktop entry point has to prompt and unlock before the wallet starts. **The iv must be 16 bytes.** EncryptedSeed.V2.serialize in commonMain throws on anything else, which rules out a conventional 96-bit GCM nonce -- worth knowing before designing around one. It turns out to help: with randomly generated nonces the risk is a repeat under one key, and 128 bits makes that vanishingly unlikely where 96 merely makes it unlikely. **Argon2id costs a dependency.** The jdk has PBKDF2 and no memory-hard KDF at all, so it means bouncycastle. Recorded with the reason to pay it: if the build is dev-only because it lacks hardware backing, weakening the KDF too gets the trade backwards. Also recorded: wrap a per-name data key under the KEK rather than encrypting the seed with it directly, so a passphrase change rewraps 32 bytes; throw java.security.KeyStoreException when locked, since the graceful* wrappers already map it to DecryptSeedResult.Failure.KeyStoreFailure; and a verification section naming the properties that fail quietly, plus the two limits worth writing down rather than fixing -- the first unlock on a new store accepts any passphrase, and zeroing the key is best effort. Co-Authored-By: Claude Opus 5 --- docs/jvm-target.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/jvm-target.md b/docs/jvm-target.md index f4ad4bdf..a9314379 100644 --- a/docs/jvm-target.md +++ b/docs/jvm-target.md @@ -362,10 +362,55 @@ desktop build clearly as unsuitable for real funds, and treat OS-keychain integration as its own piece of work with its own review. That unblocks Phases 4 and 5 without pretending the security question is answered. +### Three things that only surface once you build it + +**The expect signature has nowhere to put a passphrase.** `keyStoreEncryption(keyName, +plainText)` takes no context and no secret, because on android the OS holds the key +and no secret is needed. A passphrase-derived KEK is therefore *not* a drop-in: it +needs an out-of-band unlock, so the jvm actual grows a `JvmKeyStore.unlock(passphrase, +storeDir)` that the application calls before any seed is touched — the same way the +android actual grows a `KeystoreHelper` beside it. **This lands in Phase 5**, which +must unlock before the wallet starts, so budget for a passphrase prompt in the +desktop entry point rather than discovering it there. + +**The iv must be exactly 16 bytes**, which rules out a conventional GCM nonce. +`EncryptedSeed.V2.serialize` in commonMain throws on anything else and `deserialize` +reads exactly 16. GCM permits it, and for randomly generated nonces 128 bits is +actually the better choice — the whole risk with a random nonce is a repeat under one +key, and 128 bits makes that vanishingly unlikely where 96 merely makes it unlikely. +A constraint inherited from android's CBC format happens to help. + +**The jdk has no memory-hard KDF.** `SecretKeyFactory` offers PBKDF2 and nothing else, +so Argon2id means a new dependency (`org.bouncycastle:bcprov-jdk18on`). Worth it: if +the build is dev-only *because* it has no hardware backing, weakening the KDF as well +to save a dependency gets the trade backwards. + +Two smaller notes. Wrap a per-key-name data key under the KEK rather than encrypting +the seed with the KEK directly — a passphrase change then rewraps a 32-byte key +instead of re-encrypting and re-serialising the seed. And throw +`java.security.KeyStoreException` when locked: that is what android raises when it +cannot serve a key, and the `graceful*` wrappers already map it to +`DecryptSeedResult.Failure.KeyStoreFailure`, so a caller that forgets to unlock gets a +handled failure rather than a crash. + **This phase is the only one in the plan with no honest day estimate**, because the estimate is a function of which row of that table gets chosen and how much review it attracts. +**Verification:** `JvmKeyStoreTest`. The properties worth pinning are the ones that +fail quietly — a 16-byte iv (or `EncryptedSeed` refuses to serialise), tamper +detection (the reason for GCM over android's unauthenticated CBC), key separation +between the two names, a per-install salt, and that neither seed nor passphrase +lands in the store file. Add the damaged-store refusal too: a store with key +material but no salt must not be given a fresh one, since that turns a file a +backup could rescue into one whose data keys are gone. + +Two limits to write down rather than fix. The first `unlock` on a new store accepts +any passphrase, because there is nothing yet to check it against — a wrong one only +surfaces when a data key fails to unwrap. And zeroing the derived key is best +effort; the jvm may have copied it during a gc, and nothing in process can reach +those copies. + --- ## Phase 4 — mantra's own actuals