crypto: versioned seed-phrase KDF (v2 = PBKDF2-600k) with trial-based legacy detection#232
Merged
Conversation
…al-based legacy detection Upgrade the seed-phrase KDF from PBKDF2-2048/SHA-512 (v1, the BIP-39 default) to PBKDF2-600k/SHA-256 (v2, OWASP) for defense-in-depth. The 24-word mnemonic already carries 256-bit entropy, so v1 was never brute-forceable; v2 closes the gap for partially-leaked / non-uniform phrases and satisfies OWASP guidance. M is derived deterministically from the phrase with no stored salt (recovery must work offline from the 24 words alone after a machine wipe), so the KDF version cannot be persisted. New orgs/local setups derive M under the current version; existing orgs stay on their original version forever and are detected at the phrase->M boundaries by trial decryption against a known ciphertext — no data migration, no server change, no stored version marker. - keyManager: KdfVersion + CURRENT_KDF_VERSION/KDF_VERSIONS registry; seedPhraseToMasterKey(phrase, version?) defaults to current. - keyResolver: resolveProjectKeyByTrial() picks the version whose key decrypts a given ciphertext; resolveFromSeedPhrase takes a version. - decrypt: trials versions against the encrypted .env oracle. - recover: trials against a fetched ciphertext oracle, which also fixes the long-standing gap where a wrong phrase wrote a bad key.enc silently — it is now rejected before anything is written. - orgCreation: new orgs pinned to CURRENT_KDF_VERSION explicitly. - Tests: golden vectors pin v1+v2 params; migration tests prove a v1 org decrypts under the new code and the trial resolver picks the right version / refuses a wrong phrase (crypto + real-wiring recover).
encryptMasterKey/decryptMasterKey now bind Additional Authenticated Data so a wrapped master-key blob can't be verified under a different (user, org) or moved between the org and local-only keystores: - org wrapping (key.enc): AAD = masterKeyAAD(userId, orgId) - local-only keystore (key.local): AAD = LOCAL_MASTER_KEY_AAD (domain tag) decryptMasterKey verifies against the supplied AAD first, then falls back to a no-AAD decrypt for blobs written before AAD binding existed — a transparent grandfather. A blob written WITH an AAD never verifies under a different one (wrong-AAD attempt fails the GCM tag and the no-AAD fallback also fails), so cross-context substitution is rejected; only genuinely AAD-less legacy blobs reach the fallback. Every reader of a master-key blob (keyResolver, invite, transport, local unlock) now passes the matching AAD. The other AES-GCM call sites in packages/cli/src/crypto/ — value encryption (encryptor), invite/deploy wrapping (inviteCrypto, deployCrypto/deployRuntime) — keep no AAD by design: each derives its key via HKDF with the context already in the salt/info (projectId+orgId, orgId:email, deployId), so AAD would be redundant, and binding it on stored values would require re-encrypting everything. Each site is now commented to that effect. Service-side kms.ts already binds AAD via contextAAD / EncryptionContext (unchanged). Tests (tests/crypto/aeadAad.test.ts): round-trip under AAD; altered AAD fails; cross-context substitution fails; org/local domain separation; legacy no-AAD blobs still decrypt; AAD-bound blob refuses a reader that omits the AAD. Full suite green (413), typecheck + build clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Upgrades the seed-phrase KDF and makes it versioned, so we can strengthen
M's derivation without re-keying or locking out existing owners.PBKDF2(phrase, "capy-mnemonic", 2048, SHA-512)— the BIP-39 default.PBKDF2(phrase, "capy-mnemonic-v2", 600_000, SHA-256)— OWASP 2023 guidance.New orgs and local-mode setups derive
Munder v2. Existing orgs keep deriving under v1.Why this shape (the honest fork, not a "transparent migration")
Mis the root of the whole key tree (deriveProjectKey = HKDF(M, …)), and it's derived deterministically from the phrase with a fixed salt — there's nowhere durable to store a per-user salt or a version marker, becausecapy recovermust rebuildMfrom the 24 words alone, offline, after a full machine wipe.That makes a true "upgrade everyone's KDF in place" impossible without either re-encrypting all data or storing a key blob server-side (a zero-trust posture change). So instead:
Mvalue is bound to v1 and must never change.Mboundaries (decrypt,recover, create) we detect the version by trial decryption against a piece of the org's own ciphertext.No data migration, no server change, no stored marker.
Changes
keyManager—KdfVersiontype +CURRENT_KDF_VERSION/KDF_VERSIONSregistry;seedPhraseToMasterKey(phrase, version?)defaults to current. (Adding a futurev3 = Argon2idis a one-case change; PBKDF2 chosen to keep the standalonepkgbinary free of native addons.)keyResolver—resolveProjectKeyByTrial()returns the version whose project key decrypts a given ciphertext;resolveFromSeedPhrasegains an optionalversion.decrypt— trials versions against the encrypted.envoracle; caches the resolvedMin the recovery session.recover— trials against a ciphertext fetched from the server. This also fixes a long-standing gap: recover used to write akey.encfor a wrong phrase silently and only fail later. A phrase that matches nothing in the org is now rejected before anything is written. (If the org has no stored secrets, there's no oracle — it warns and writes under the current version.)orgCreation— new orgs pinned toCURRENT_KDF_VERSIONexplicitly.Tests
tests/crypto/kdfMigration.test.ts): a v1 org's ciphertext is correctly resolved under the new code; a v2 org resolves to v2; cross-version keys are isolated; a wrong phrase resolves tonull.tests/commands/recoverKdf.test.ts): drivesRecoverCommandend-to-end and asserts it writes the correct-versionM(v1 and v2) and refuses a non-matching phrase.