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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 01:13:07 +02:00
parent 506dfea802
commit 670f87a609

View File

@@ -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