From 0b308a2fa63f308c53bfba6c0ce81a3b1f2a973d Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 8 Jun 2026 23:21:31 -0700 Subject: [PATCH 1/2] crypto: versioned seed-phrase KDF (v2 = PBKDF2-600k/SHA-256) with trial-based legacy detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/commands/decryptCommand.ts | 73 ++++++++----- src/commands/orgCreation.ts | 6 +- src/commands/recoverCommand.ts | 88 ++++++++++++++- src/crypto/keyManager.ts | 58 ++++++++-- src/crypto/keyResolver.ts | 48 ++++++++- tests/commands/recoverCommand.test.ts | 6 ++ tests/commands/recoverKdf.test.ts | 148 ++++++++++++++++++++++++++ tests/crypto/kdfMigration.test.ts | 143 +++++++++++++++++++++++++ tests/run-tests.sh | 1 + 9 files changed, 531 insertions(+), 40 deletions(-) create mode 100644 tests/commands/recoverKdf.test.ts create mode 100644 tests/crypto/kdfMigration.test.ts diff --git a/src/commands/decryptCommand.ts b/src/commands/decryptCommand.ts index 988ebfd..911fb70 100644 --- a/src/commands/decryptCommand.ts +++ b/src/commands/decryptCommand.ts @@ -6,6 +6,7 @@ import { validateSeedPhrase, seedPhraseToMasterKey, deriveProjectKey, + KDF_VERSIONS, } from '../crypto/keyManager'; import { isRecoveryActive, @@ -55,8 +56,31 @@ export class DecryptCommand { process.exit(1); } - // Check for existing recovery session + // Decrypt the .env with the master key M. `decryptWith` derives the + // project key from a candidate M and decrypts; it throws "different + // project's key" if M is wrong for this project. + const decryptWith = (mkHex: string): Record => { + const projectKey = deriveProjectKey(Buffer.from(mkHex, 'hex'), projectId, orgId); + return fm.readEncryptedEnvFile(projectKey); + }; + const wrongSeedExit = (): never => { + console.error( + `\n Decryption failed. Double-check your seed phrase — a single wrong word` + + `\n produces a completely different key.` + + `\n\n If you have multiple orgs, make sure you're using the right seed` + + `\n phrase for this org.\n` + ); + process.exit(1); + }; + + // Resolve M and decrypt. A cached recovery session already holds the + // resolved M (its KDF version was determined on first use). For a freshly + // entered phrase the org's KDF version is unknown, so we trial each known + // version against the encrypted .env (the oracle) and keep the one that + // decrypts — this is how legacy (v1) and current (v2) orgs are told apart + // without any stored version marker. let masterKeyHex: string; + let decrypted: Record; if (isRecoveryActive()) { const session = readRecoverySession(); @@ -72,6 +96,12 @@ export class DecryptCommand { process.exit(1); } masterKeyHex = session.master_key; + try { + decrypted = decryptWith(masterKeyHex); + } catch (error: any) { + if (error?.message?.includes("different project's key")) wrongSeedExit(); + throw error; + } } else { // Prompt for seed phrase const inquirer = (await import('inquirer')).default; @@ -87,36 +117,25 @@ export class DecryptCommand { process.exit(1); } - const masterKey = seedPhraseToMasterKey(seedPhrase); - masterKeyHex = masterKey.toString('hex'); + let resolved: { hex: string; decrypted: Record } | null = null; + for (const version of KDF_VERSIONS) { + const mkHex = seedPhraseToMasterKey(seedPhrase, version).toString('hex'); + try { + resolved = { hex: mkHex, decrypted: decryptWith(mkHex) }; + break; + } catch (error: any) { + if (error?.message?.includes("different project's key")) continue; + throw error; + } + } + if (!resolved) return wrongSeedExit(); + + masterKeyHex = resolved.hex; + decrypted = resolved.decrypted; saveRecoverySession(masterKeyHex, orgId); console.log(' ✓ Recovery session started'); } - // Derive project key - const projectKey = deriveProjectKey( - Buffer.from(masterKeyHex, 'hex'), - projectId, - orgId, - ); - - // Decrypt .env - let decrypted: Record; - try { - decrypted = fm.readEncryptedEnvFile(projectKey); - } catch (error: any) { - if (error?.message?.includes("different project's key")) { - console.error( - `\n Decryption failed. Double-check your seed phrase — a single wrong word` + - `\n produces a completely different key.` + - `\n\n If you have multiple orgs, make sure you're using the right seed` + - `\n phrase for this org.\n` - ); - process.exit(1); - } - throw error; - } - if (Object.keys(decrypted).length === 0) { console.log('\n No encrypted secrets found in .env\n'); process.exit(0); diff --git a/src/commands/orgCreation.ts b/src/commands/orgCreation.ts index 02c5d37..2718962 100644 --- a/src/commands/orgCreation.ts +++ b/src/commands/orgCreation.ts @@ -6,6 +6,7 @@ import { Organization } from '../types/index'; import { generateSeedPhrase, seedPhraseToMasterKey, + CURRENT_KDF_VERSION, } from '../crypto/keyManager'; import { wrapAndSaveMasterKey, KeyServiceOps } from '../crypto/keyResolver'; import { displayAndConfirmRecoveryPhrase } from '../ui/recoveryPhrase'; @@ -79,7 +80,10 @@ export async function createNewOrganization( const org = await authService.createOrganization(orgName, refreshToken, userId); orgSpinner.succeed(`Organization "${org.name}" created`); - const masterKey = seedPhraseToMasterKey(seedPhrase); + // New orgs derive M under the current (strongest) KDF version. This is + // what binds the org to v2; legacy orgs created before this stay on v1 and + // are detected by trial decryption at the phrase→M boundaries. + const masterKey = seedPhraseToMasterKey(seedPhrase, CURRENT_KDF_VERSION); await wrapAndSaveMasterKey(masterKey, org.id, userId, keyServiceOpsFromClient(serviceClient)); return org; diff --git a/src/commands/recoverCommand.ts b/src/commands/recoverCommand.ts index 254d0b7..f91caca 100644 --- a/src/commands/recoverCommand.ts +++ b/src/commands/recoverCommand.ts @@ -1,12 +1,69 @@ import { AuthService } from '../auth/authService'; import { ServiceClient } from '../service/serviceClient'; import { ProjectManager } from '../core/projectManager'; -import { hasOrgKey, wrapAndSaveMasterKey } from '../crypto/keyResolver'; +import { FileManager } from '../files/fileManager'; +import { + hasOrgKey, + wrapAndSaveMasterKey, + resolveProjectKeyByTrial, +} from '../crypto/keyResolver'; import { validateSeedPhrase, seedPhraseToMasterKey, + CURRENT_KDF_VERSION, } from '../crypto/keyManager'; +/** + * Finds a piece of this org's ciphertext to use as a KDF-version oracle. + * + * The org's KDF version isn't recorded anywhere, so to recover the correct M we + * need a known ciphertext to test candidate keys against. Scans the org's + * projects for any genuinely-encrypted value and returns a verifier bound to + * that project. Returns null if the org has no stored secrets (nothing to + * verify against). + */ +async function findOrgCiphertextOracle( + serviceClient: ServiceClient, + orgId: string, + fm: FileManager, +): Promise<{ projectId: string; verify: (projectKey: string) => boolean } | null> { + let projects: Array<{ id: string; organization_id: string }>; + try { + projects = await serviceClient.listProjects(); + } catch { + return null; + } + + for (const proj of projects.filter(p => p.organization_id === orgId)) { + let envContent = ''; + try { + envContent = (await serviceClient.getDecryptData(proj.id)).env_content || ''; + } catch { + continue; + } + for (const line of envContent.split('\n')) { + const eq = line.indexOf('='); + if (eq < 0) continue; + const value = line.slice(eq + 1).trim(); + // Only genuinely-encrypted values work as oracles: capy:{id}:{payload}. + // Tombstones (capy:deleted) and plaintext decrypt as no-ops under any key. + if (!value.startsWith('capy:') || value.split(':').length < 3) continue; + return { + projectId: proj.id, + verify: (projectKey: string) => { + try { + fm.decryptValue(value, projectKey); + return true; + } catch { + return false; + } + }, + }; + } + } + return null; +} + const B = (s: string) => `\x1b[1m${s}\x1b[0m`; const Y = (s: string) => `\x1b[33m${s}\x1b[0m`; const G = (s: string) => `\x1b[32m${s}\x1b[0m`; @@ -142,7 +199,34 @@ export class RecoverCommand { process.exit(1); } - const masterKey = seedPhraseToMasterKey(phrase); + // Determine M. The KDF version that created this org isn't recorded, so we + // detect it by trial against a piece of the org's own ciphertext. This also + // validates the phrase up front — recover used to write a key.enc for a + // wrong phrase and only fail later (see class doc); now a phrase that + // matches nothing in the org is rejected before anything is written. + const fm = new FileManager(); + const oracle = await findOrgCiphertextOracle(serviceClient, orgId, fm); + + let masterKey: Buffer; + if (oracle) { + const trial = resolveProjectKeyByTrial(phrase, orgId, oracle.projectId, oracle.verify); + if (!trial) { + console.error(`\n That recovery phrase does not match any secrets in ${B(selectedOrg.name)}.`); + console.error(' Double-check the phrase, and that you selected the right organization.'); + console.error(' No changes were written.\n'); + process.exit(1); + } + masterKey = trial.masterKey; + } else { + // No stored secrets in this org — nothing to verify against. Use the + // current KDF version (what a new org would use). With no ciphertext there + // is nothing to mis-key; the first push defines the key tree. + console.log(''); + console.log(Y(' ⚠ This org has no stored secrets yet, so the recovery phrase could not')); + console.log(Y(' be verified. Writing a key under the current KDF version — run capy in')); + console.log(Y(' a project for this org to confirm it decrypts.')); + masterKey = seedPhraseToMasterKey(phrase, CURRENT_KDF_VERSION); + } const keyOps = { coDecrypt: (oid: string, ct: string) => diff --git a/src/crypto/keyManager.ts b/src/crypto/keyManager.ts index 059fad2..493f19e 100644 --- a/src/crypto/keyManager.ts +++ b/src/crypto/keyManager.ts @@ -8,11 +8,45 @@ import { } from 'crypto'; import { BIP39_WORDLIST } from './bip39Words'; +// --- Seed-phrase KDF versions ------------------------------------------- +// +// M is derived deterministically from the seed phrase with a FIXED salt: there +// is nowhere durable to keep a per-user salt that survives the recovery +// scenario (a full machine wipe, where only the 24 words remain — see +// recoverCommand). The KDF *version* selects the parameter set instead. +// +// New seed phrases derive M under CURRENT_KDF_VERSION. An existing org's M is +// bound to whatever version created it and must never change — that would +// re-key the whole org and lock everyone out — so legacy versions are detected +// at the phrase→M boundaries by trial decryption (see resolveProjectKeyByTrial), +// not migrated. +export type KdfVersion = 1 | 2; + +// v1 — BIP-39 default. LEGACY: do not change these. Existing owners' M is +// PBKDF2(phrase, 'capy-mnemonic', 2048, sha512); altering any value re-keys +// their org. 2048 is low, but the input is a 24-word mnemonic (256-bit +// entropy), so this was never brute-forceable; v2 is defense-in-depth (OWASP +// compliance, and protection for partially-leaked / non-uniform phrases). const PBKDF2_SALT = 'capy-mnemonic'; const PBKDF2_ITERATIONS = 2048; -const PBKDF2_KEY_LENGTH = 32; const PBKDF2_DIGEST = 'sha512'; +// v2 — OWASP 2023 guidance for PBKDF2-SHA256 (>=600k). Distinct salt so v2's M +// is cleanly separated from v1 for the same phrase. PBKDF2 (not Argon2id) keeps +// the standalone pkg binary free of native addons; a v3 = Argon2id is a +// one-case addition to seedPhraseToMasterKey when wanted. +const PBKDF2_V2_SALT = 'capy-mnemonic-v2'; +const PBKDF2_V2_ITERATIONS = 600_000; +const PBKDF2_V2_DIGEST = 'sha256'; + +const PBKDF2_KEY_LENGTH = 32; + +/** Strongest available KDF version. New seed phrases derive M under this. */ +export const CURRENT_KDF_VERSION: KdfVersion = 2; + +/** All known KDF versions, in trial order (newest first). */ +export const KDF_VERSIONS: readonly KdfVersion[] = [2, 1]; + // Local-only mode: the passphrase that locks M at rest is low-entropy // (human-chosen), so it gets a much higher work factor + per-keystore random // salt — distinct from the seed-phrase derivation above, which has a fixed @@ -107,15 +141,21 @@ export function validateSeedPhrase(phrase: string): boolean { /** * Derives a 256-bit master key M from a BIP-39 seed phrase using PBKDF2. + * + * `version` selects the parameter set. Defaults to CURRENT_KDF_VERSION so fresh + * derivations (new orgs / local setups) use the strongest KDF. Pass an explicit + * version to reproduce a legacy org's M (e.g. during trial resolution). */ -export function seedPhraseToMasterKey(phrase: string): Buffer { - return pbkdf2Sync( - phrase, - PBKDF2_SALT, - PBKDF2_ITERATIONS, - PBKDF2_KEY_LENGTH, - PBKDF2_DIGEST, - ); +export function seedPhraseToMasterKey( + phrase: string, + version: KdfVersion = CURRENT_KDF_VERSION, +): Buffer { + const [salt, iterations, digest] = + version === 1 ? [PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_DIGEST] : + version === 2 ? [PBKDF2_V2_SALT, PBKDF2_V2_ITERATIONS, PBKDF2_V2_DIGEST] : + [null, null, null]; + if (salt === null) throw new Error(`Unknown KDF version: ${version}`); + return pbkdf2Sync(phrase, salt, iterations, PBKDF2_KEY_LENGTH, digest); } /** diff --git a/src/crypto/keyResolver.ts b/src/crypto/keyResolver.ts index 85f215f..e5ce58f 100644 --- a/src/crypto/keyResolver.ts +++ b/src/crypto/keyResolver.ts @@ -7,6 +7,9 @@ import { encryptMasterKey, seedPhraseToMasterKey, LOCAL_KEY_ITERATIONS, + CURRENT_KDF_VERSION, + KDF_VERSIONS, + KdfVersion, } from './keyManager'; import { readMasterKey, @@ -141,16 +144,59 @@ export async function wrapAndSaveMasterKey( /** * Resolves a project key offline using a seed phrase (owner self-custody). * No server needed — the seed phrase replaces both shares. + * + * `version` selects the KDF used to derive M. Defaults to CURRENT_KDF_VERSION; + * callers that don't know the org's version should use resolveProjectKeyByTrial + * instead (it detects the version against a known ciphertext). */ export function resolveFromSeedPhrase( seedPhrase: string, orgId: string, projectId: string, + version: KdfVersion = CURRENT_KDF_VERSION, ): string { - const masterKey = seedPhraseToMasterKey(seedPhrase); + const masterKey = seedPhraseToMasterKey(seedPhrase, version); return deriveProjectKey(masterKey, projectId, orgId); } +/** Outcome of a successful trial resolution. */ +export interface TrialResolution { + projectKey: string; + masterKey: Buffer; + version: KdfVersion; +} + +/** + * Resolves a project key from a seed phrase when the org's KDF version is + * unknown. + * + * M's value is bound to the KDF version that created the org, and that version + * isn't recorded anywhere (it can't be: recovery is offline-from-phrase-only). + * So we derive M under each known version (newest first) and return the first + * whose project key satisfies `verify` — a decryption oracle over a piece of + * known ciphertext for this project. + * + * Returns null if no version verifies, which means either the phrase is wrong + * or the ciphertext belongs to a different project/org. Callers MUST treat null + * as "do not write a key" — guessing a version would corrupt the org for every + * other member. + */ +export function resolveProjectKeyByTrial( + seedPhrase: string, + orgId: string, + projectId: string, + verify: (projectKey: string) => boolean, +): TrialResolution | null { + for (const version of KDF_VERSIONS) { + const masterKey = seedPhraseToMasterKey(seedPhrase, version); + const projectKey = deriveProjectKey(masterKey, projectId, orgId); + if (verify(projectKey)) { + return { projectKey, masterKey, version }; + } + } + return null; +} + /** * Checks whether an org's master key exists on disk. */ diff --git a/tests/commands/recoverCommand.test.ts b/tests/commands/recoverCommand.test.ts index 1e08f5c..17d8a09 100644 --- a/tests/commands/recoverCommand.test.ts +++ b/tests/commands/recoverCommand.test.ts @@ -94,6 +94,12 @@ mock.module('../../src/core/projectManager', () => ({ // ── Capture wrapAndSaveMasterKey calls ───────────────────────────────────── const wrapCalls: Array<{ orgId: string; userId: string; m: Buffer }> = []; mock.module('../../src/crypto/keyResolver', () => ({ + // This fake ServiceClient has no listProjects, so findOrgCiphertextOracle + // finds no oracle and recover falls back to CURRENT_KDF_VERSION — the trial + // resolver is never reached here (its behavior is covered by + // kdfMigration.test.ts and recoverKdf.test.ts). The export must still exist + // to satisfy the static import binding in recoverCommand. + resolveProjectKeyByTrial: mock(() => null), hasOrgKey: mock((orgId: string, userId: string) => { return existsSync(join(tempHome, '.capy', 'orgs', orgId, 'users', userId, 'key.enc')); }), diff --git a/tests/commands/recoverKdf.test.ts b/tests/commands/recoverKdf.test.ts new file mode 100644 index 0000000..e6e95f1 --- /dev/null +++ b/tests/commands/recoverKdf.test.ts @@ -0,0 +1,148 @@ +/** + * recover — KDF version detection (real wiring). + * + * Unlike recoverCommand.test.ts (which mocks keyResolver), this exercises the + * REAL keyResolver + keyManager + FileManager + globalConfig so the new + * findOrgCiphertextOracle + resolveProjectKeyByTrial path actually runs. + * + * recover must reproduce the org's existing M exactly — a wrong KDF version + * would write a key.enc that corrupts the org for every other member. It learns + * the version by trial-decrypting a piece of the org's own ciphertext fetched + * from the server. These tests prove it writes the right-version M and refuses + * a phrase that matches nothing. + */ +import { mock, describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const tempHome = mkdtempSync(join(tmpdir(), 'capy-recover-kdf-')); +mock.module('os', () => { + const actual = require('os'); + return { ...actual, homedir: () => tempHome }; +}); + +let answers: Record = {}; +mock.module('inquirer', () => ({ + default: { + prompt: mock(async (q: any) => { + const arr = Array.isArray(q) ? q : [q]; + const out: Record = {}; + for (const item of arr) { + if (!(item.name in answers)) throw new Error(`unexpected prompt: ${item.name}`); + out[item.name] = answers[item.name]; + } + return out; + }), + }, +})); + +const FAKE_USER_ID = 'user_kdf'; +const ORG = 'org-kdf'; +const PROJECT = 'proj-kdf'; +const FAKE_ORGS = [{ id: ORG, workos_org_id: 'workos-kdf', name: 'KdfOrg' }]; + +mock.module('../../src/auth/authService', () => ({ + AuthService: class FakeAuthService { + constructor(_apiUrl?: string, _devMode?: boolean, _userId?: string) {} + async authenticateSilent(orgId?: string) { + return { success: true, user_id: FAKE_USER_ID, user_email: 'v@capy.sc', organizations: FAKE_ORGS, organization_id: orgId }; + } + getValidToken() { return Promise.resolve('tok'); } + }, +})); + +mock.module('../../src/core/projectManager', () => ({ + ProjectManager: class FakeProjectManager { + async detectProjectState() { return { initialized: false, userId: FAKE_USER_ID }; } + }, +})); + +// What the fake server returns from getDecryptData — set per test. +let serverEnvContent = ''; +mock.module('../../src/service/serviceClient', () => ({ + ServiceClient: class FakeServiceClient { + constructor(_apiUrl?: string) {} + setTokenProvider(_fn: any) {} + coDecrypt(_o: string, _c: string) { return Promise.resolve({ plaintext: 'unused' }); } + wrapOuterLayer(_o: string, pt: string) { return Promise.resolve({ ciphertext: 'kms:' + pt }); } + listProjects() { return Promise.resolve([{ id: PROJECT, name: 'p', organization_id: ORG }]); } + getDecryptData(_pid: string) { return Promise.resolve({ env_content: serverEnvContent }); } + }, +})); + +class ExitError extends Error { constructor(public code: number) { super(`exit:${code}`); } } +const originalExit = process.exit; +(process as any).exit = (code?: number) => { throw new ExitError(code ?? 0); }; + +let RecoverCommand: any; +let km: typeof import('../../src/crypto/keyManager'); +let gc: typeof import('../../src/config/globalConfig'); +let Encryptor: typeof import('../../src/crypto/encryptor').Encryptor; + +beforeAll(async () => { + ({ RecoverCommand } = await import('../../src/commands/recoverCommand')); + km = await import('../../src/crypto/keyManager'); + gc = await import('../../src/config/globalConfig'); + ({ Encryptor } = await import('../../src/crypto/encryptor')); +}); + +afterAll(() => { + mock.restore(); + (process as any).exit = originalExit; + rmSync(tempHome, { recursive: true, force: true }); +}); + +beforeEach(() => { + answers = {}; + serverEnvContent = ''; + rmSync(join(tempHome, '.capy'), { recursive: true, force: true }); +}); + +// A server env blob with one value encrypted under the given version's project key. +function envBlobForVersion(version: 1 | 2, phrase: string): string { + const pk = km.deriveProjectKey(km.seedPhraseToMasterKey(phrase, version), PROJECT, ORG); + return `SECRET=capy:res1:${Encryptor.encrypt('server-secret', pk)}`; +} + +// Recover the M that recover wrote to disk (strip fake KMS layer, unwrap inner). +function writtenMasterKey(): Buffer { + const outer = gc.readMasterKey(ORG, FAKE_USER_ID); + if (!outer) throw new Error('no key written'); + const inner = outer.replace(/^kms:/, ''); + return km.decryptMasterKey(inner, km.deriveWrappingKey(FAKE_USER_ID, ORG)); +} + +describe('RecoverCommand — KDF version detection', () => { + test('legacy v1 org: detects v1 from ciphertext and writes the v1 master key', async () => { + const phrase = km.generateSeedPhrase(); + serverEnvContent = envBlobForVersion(1, phrase); + answers = { orgId: ORG, seedPhrase: phrase }; + + await new RecoverCommand().execute(); + + expect(writtenMasterKey().equals(km.seedPhraseToMasterKey(phrase, 1))).toBe(true); + expect(writtenMasterKey().equals(km.seedPhraseToMasterKey(phrase, 2))).toBe(false); + }); + + test('current v2 org: detects v2 and writes the v2 master key', async () => { + const phrase = km.generateSeedPhrase(); + serverEnvContent = envBlobForVersion(2, phrase); + answers = { orgId: ORG, seedPhrase: phrase }; + + await new RecoverCommand().execute(); + + expect(writtenMasterKey().equals(km.seedPhraseToMasterKey(phrase, 2))).toBe(true); + }); + + test('phrase that matches no ciphertext is rejected and nothing is written', async () => { + const real = km.generateSeedPhrase(); + let wrong = km.generateSeedPhrase(); + while (wrong === real) wrong = km.generateSeedPhrase(); + serverEnvContent = envBlobForVersion(1, real); + answers = { orgId: ORG, seedPhrase: wrong }; + + await expect(new RecoverCommand().execute()).rejects.toBeInstanceOf(ExitError); + expect(gc.readMasterKey(ORG, FAKE_USER_ID)).toBeNull(); + }); +}); diff --git a/tests/crypto/kdfMigration.test.ts b/tests/crypto/kdfMigration.test.ts new file mode 100644 index 0000000..e0831a4 --- /dev/null +++ b/tests/crypto/kdfMigration.test.ts @@ -0,0 +1,143 @@ +/** + * KDF versioning + transparent migration. + * + * Capy upgraded the seed-phrase KDF from PBKDF2-2048/SHA-512 (v1, BIP-39 + * default) to PBKDF2-600k/SHA-256 (v2, OWASP). M's value is bound to the + * version that created the org and must never change, so legacy (v1) orgs are + * detected at the phrase→M boundaries by trial decryption rather than migrated. + * + * These tests prove: + * - v1 and v2 produce stable, distinct master keys (golden vectors guard + * against silent parameter drift that would lock owners out). + * - New derivations default to the current (v2) version. + * - An org created under v1 still decrypts under the new code (migration). + * - A new org under v2 decrypts. + * - The trial resolver picks the correct version and refuses a wrong phrase. + */ +import { + generateSeedPhrase, + seedPhraseToMasterKey, + deriveProjectKey, + CURRENT_KDF_VERSION, + KDF_VERSIONS, +} from '../../src/crypto/keyManager'; +import { + resolveProjectKeyByTrial, + resolveFromSeedPhrase, +} from '../../src/crypto/keyResolver'; +import { Encryptor } from '../../src/crypto/encryptor'; + +const orgId = 'org_migration'; +const projectId = 'proj_migration'; + +describe('KDF versioning', () => { + it('defaults to the current (strongest) version', () => { + const phrase = generateSeedPhrase(); + expect(CURRENT_KDF_VERSION).toBe(2); + expect( + seedPhraseToMasterKey(phrase).equals(seedPhraseToMasterKey(phrase, 2)), + ).toBe(true); + }); + + it('lists known versions newest-first for trial order', () => { + expect([...KDF_VERSIONS]).toEqual([2, 1]); + }); + + it('produces distinct master keys per version for the same phrase', () => { + const phrase = generateSeedPhrase(); + const v1 = seedPhraseToMasterKey(phrase, 1); + const v2 = seedPhraseToMasterKey(phrase, 2); + expect(v1.equals(v2)).toBe(false); + }); + + it('rejects an unknown version', () => { + // @ts-expect-error — exercising the runtime guard with an invalid version + expect(() => seedPhraseToMasterKey('whatever', 99)).toThrow(/Unknown KDF version/); + }); + + // Golden vectors: if PBKDF2 params drift, every existing owner's M changes and + // they are locked out. A fixed input phrase pins the exact derivation. (The + // phrase need not be checksum-valid — the KDF is just a string → bytes map.) + describe('golden vectors (immutability)', () => { + const phrase = Array(23).fill('abandon').join(' ') + ' about'; + + it('v1 (PBKDF2-2048/SHA-512) is unchanged', () => { + expect(seedPhraseToMasterKey(phrase, 1).toString('hex')).toBe( + '8d00a0732c1596800933630041bc8fc90499b7ff6894ef4b71ba590c7a8053f3', + ); + }); + + it('v2 (PBKDF2-600k/SHA-256) is unchanged', () => { + expect(seedPhraseToMasterKey(phrase, 2).toString('hex')).toBe( + '9125acc57b0d256dbfc57efd8a23b07968e24a4210d8a472e65801dd45c34911', + ); + }); + }); +}); + +describe('transparent migration via trial resolution', () => { + // Build a decryption oracle the way the live code does: a known ciphertext + // encrypted under the org's real project key. + function oracleFor(version: 1 | 2, phrase: string): { + projectKey: string; + verify: (pk: string) => boolean; + } { + const masterKey = seedPhraseToMasterKey(phrase, version); + const projectKey = deriveProjectKey(masterKey, projectId, orgId); + const ciphertext = Encryptor.encrypt('super-secret-value', projectKey); + return { projectKey, verify: (pk: string) => Encryptor.canDecrypt(ciphertext, pk) }; + } + + it('legacy v1 org: new code detects v1 and decrypts (the migration case)', () => { + const phrase = generateSeedPhrase(); + const { projectKey, verify } = oracleFor(1, phrase); + + const trial = resolveProjectKeyByTrial(phrase, orgId, projectId, verify); + expect(trial).not.toBeNull(); + expect(trial!.version).toBe(1); + expect(trial!.projectKey).toBe(projectKey); + }); + + it('new v2 org: trial detects v2 and decrypts', () => { + const phrase = generateSeedPhrase(); + const { projectKey, verify } = oracleFor(2, phrase); + + const trial = resolveProjectKeyByTrial(phrase, orgId, projectId, verify); + expect(trial).not.toBeNull(); + expect(trial!.version).toBe(2); + expect(trial!.projectKey).toBe(projectKey); + }); + + it('a wrong phrase resolves to null (recover must not write a key)', () => { + const realPhrase = generateSeedPhrase(); + let wrongPhrase = generateSeedPhrase(); + while (wrongPhrase === realPhrase) wrongPhrase = generateSeedPhrase(); + + const { verify } = oracleFor(2, realPhrase); + expect(resolveProjectKeyByTrial(wrongPhrase, orgId, projectId, verify)).toBeNull(); + }); + + it('cross-version keys do not decrypt each other (versions are isolated)', () => { + const phrase = generateSeedPhrase(); + const v1Key = deriveProjectKey(seedPhraseToMasterKey(phrase, 1), projectId, orgId); + const v2Key = deriveProjectKey(seedPhraseToMasterKey(phrase, 2), projectId, orgId); + + const v1Ciphertext = Encryptor.encrypt('value', v1Key); + const v2Ciphertext = Encryptor.encrypt('value', v2Key); + + expect(Encryptor.canDecrypt(v1Ciphertext, v2Key)).toBe(false); + expect(Encryptor.canDecrypt(v2Ciphertext, v1Key)).toBe(false); + }); + + it('resolveFromSeedPhrase reproduces the per-version key explicitly', () => { + const phrase = generateSeedPhrase(); + for (const version of KDF_VERSIONS) { + const expected = deriveProjectKey( + seedPhraseToMasterKey(phrase, version), + projectId, + orgId, + ); + expect(resolveFromSeedPhrase(phrase, orgId, projectId, version)).toBe(expected); + } + }); +}); diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 5a2575a..18fd2db 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -25,6 +25,7 @@ ISOLATED_FILES=( tests/commands/decryptCommand.test.ts tests/commands/roleAccessGuards.test.ts tests/commands/recoverCommand.test.ts + tests/commands/recoverKdf.test.ts tests/commands/cleanupOrgData.test.ts tests/config/profileConfig.test.ts tests/commands/byocCommand.test.ts From f50e705605184b91fb3d1ac0c0cd332407f0430c Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Tue, 9 Jun 2026 12:49:37 -0700 Subject: [PATCH 2/2] crypto: bind AAD on master-key AEAD wrapping (CAP-57) (#233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/commands/inviteCommand.ts | 6 +- src/commands/transportCommand.ts | 6 +- src/crypto/deployCrypto.ts | 3 + src/crypto/deployRuntime.ts | 2 + src/crypto/encryptor.ts | 5 ++ src/crypto/inviteCrypto.ts | 4 ++ src/crypto/keyManager.ts | 69 +++++++++++++++---- src/crypto/keyResolver.ts | 15 +++-- tests/commands/recoverKdf.test.ts | 7 +- tests/crypto/aeadAad.test.ts | 106 ++++++++++++++++++++++++++++++ 10 files changed, 196 insertions(+), 27 deletions(-) create mode 100644 tests/crypto/aeadAad.test.ts diff --git a/src/commands/inviteCommand.ts b/src/commands/inviteCommand.ts index 9932b97..d63956a 100644 --- a/src/commands/inviteCommand.ts +++ b/src/commands/inviteCommand.ts @@ -2,7 +2,7 @@ import inquirer from 'inquirer'; import { resolveOrgContext } from '../core/orgContext'; import { ProjectManager } from '../core/projectManager'; import { readMasterKey } from '../config/globalConfig'; -import { decryptMasterKey, deriveWrappingKey } from '../crypto/keyManager'; +import { decryptMasterKey, deriveWrappingKey, masterKeyAAD } from '../crypto/keyManager'; import { wrapAndSaveMasterKey } from '../crypto/keyResolver'; import { generateInviteToken, @@ -119,12 +119,12 @@ export class InviteCommand { const { plaintext: innerBlob } = await serviceClient.coDecrypt(orgId, encryptedM); // Strip local inner layer const wrappingKey = deriveWrappingKey(userId, orgId); - masterKey = decryptMasterKey(innerBlob, wrappingKey); + masterKey = decryptMasterKey(innerBlob, wrappingKey, masterKeyAAD(userId, orgId)); } catch (err: any) { // Fallback: try legacy single-wrapped (no KMS outer) try { const wrappingKey = deriveWrappingKey(userId, orgId); - masterKey = decryptMasterKey(encryptedM, wrappingKey); + masterKey = decryptMasterKey(encryptedM, wrappingKey, masterKeyAAD(userId, orgId)); // Migration: re-wrap with KMS outer layer const keyOps = { coDecrypt: (oid: string, ct: string) => serviceClient.coDecrypt(oid, ct).then(r => r.plaintext), diff --git a/src/commands/transportCommand.ts b/src/commands/transportCommand.ts index 4f4e521..1fbffd0 100644 --- a/src/commands/transportCommand.ts +++ b/src/commands/transportCommand.ts @@ -1,6 +1,6 @@ import { resolveOrgContext } from '../core/orgContext'; import { readMasterKey } from '../config/globalConfig'; -import { decryptMasterKey, deriveWrappingKey } from '../crypto/keyManager'; +import { decryptMasterKey, deriveWrappingKey, masterKeyAAD } from '../crypto/keyManager'; import { wrapAndSaveMasterKey } from '../crypto/keyResolver'; import { generateInviteToken, @@ -39,11 +39,11 @@ export class TransportCommand { try { const { plaintext: innerBlob } = await serviceClient.coDecrypt(orgId, encryptedM); const wrappingKey = deriveWrappingKey(userId, orgId); - masterKey = decryptMasterKey(innerBlob, wrappingKey); + masterKey = decryptMasterKey(innerBlob, wrappingKey, masterKeyAAD(userId, orgId)); } catch { try { const wrappingKey = deriveWrappingKey(userId, orgId); - masterKey = decryptMasterKey(encryptedM, wrappingKey); + masterKey = decryptMasterKey(encryptedM, wrappingKey, masterKeyAAD(userId, orgId)); const keyOps = { coDecrypt: (oid: string, ct: string) => serviceClient.coDecrypt(oid, ct).then(r => r.plaintext), wrapOuterLayer: (oid: string, pt: string) => serviceClient.wrapOuterLayer(oid, pt).then(r => r.ciphertext), diff --git a/src/crypto/deployCrypto.ts b/src/crypto/deployCrypto.ts index f38bd28..6d7fce4 100644 --- a/src/crypto/deployCrypto.ts +++ b/src/crypto/deployCrypto.ts @@ -111,6 +111,9 @@ export function encryptEnvBlob( const plaintext = Buffer.from(envBlob, 'utf-8'); const iv = randomBytes(IV_LENGTH); + // No setAAD: decryptKey = HKDF(projectKey||serviceKey, salt=deployId, + // info="capy:deploy:decrypt") already binds the deploy/project context, so the + // blob can't be replayed under a different deploy. AAD would be redundant. const cipher = createCipheriv('aes-256-gcm', decryptKey, iv, { authTagLength: AUTH_TAG_LENGTH, }); diff --git a/src/crypto/deployRuntime.ts b/src/crypto/deployRuntime.ts index e2580eb..6f87233 100644 --- a/src/crypto/deployRuntime.ts +++ b/src/crypto/deployRuntime.ts @@ -118,6 +118,8 @@ export function decryptSecretsBlob( const authTag = encryptedVars.subarray(encryptedVars.length - BLOB_AUTH_TAG_LENGTH); const ciphertext = encryptedVars.subarray(BLOB_IV_LENGTH, encryptedVars.length - BLOB_AUTH_TAG_LENGTH); + // No setAAD — mirrors deployCrypto.encryptSecretsBlob: decryptKey already + // binds the deploy context via HKDF(salt=deployId), so AAD would be redundant. const decipher = createDecipheriv('aes-256-gcm', decryptKey, iv, { authTagLength: BLOB_AUTH_TAG_LENGTH, }); diff --git a/src/crypto/encryptor.ts b/src/crypto/encryptor.ts index 371a367..7e68bc8 100644 --- a/src/crypto/encryptor.ts +++ b/src/crypto/encryptor.ts @@ -15,6 +15,11 @@ export class Encryptor { const iv = randomBytes(this.ivLength); const derivedKey = this.deriveKey(key); + // No setAAD: the key is already fully context-bound — `key` is the project + // key = HKDF(M, projectId, orgId), so a value can't be moved across + // projects/orgs (decryption fails). Binding per-value AAD (e.g. the + // resource_id) would require re-encrypting every stored secret for marginal + // gain; deferred (CAP-57). const cipher = createCipheriv(this.algorithm, derivedKey, iv, { authTagLength: this.authTagLength, }); diff --git a/src/crypto/inviteCrypto.ts b/src/crypto/inviteCrypto.ts index aca0b13..fe5bb05 100644 --- a/src/crypto/inviteCrypto.ts +++ b/src/crypto/inviteCrypto.ts @@ -31,6 +31,10 @@ export function deriveInnerKey(token: Buffer, salt: string, info: string): Buffe */ export function aesEncrypt(plaintext: Buffer, key: Buffer): string { const iv = randomBytes(IV_LENGTH); + // No setAAD: callers derive `key` via deriveInnerKey(token, salt, info), where + // salt/info already bind the context (e.g. "orgId:email" + "capy:invite", or + // deployId + "capy:deploy:decrypt"). The context lives in the key, so AAD here + // would be redundant. const cipher = createCipheriv(AES_ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH, }); diff --git a/src/crypto/keyManager.ts b/src/crypto/keyManager.ts index 493f19e..920c21a 100644 --- a/src/crypto/keyManager.ts +++ b/src/crypto/keyManager.ts @@ -177,21 +177,42 @@ export function deriveProjectKey( return Buffer.from(derived).toString('hex'); } +/** + * AAD binding the org master-key wrapping to its (user, org) context, so a + * wrapped blob cannot be verified/substituted under a different user or org. + * Mirrors the context already in deriveWrappingKey; the version tag leaves room + * to rotate the binding later. + */ +export function masterKeyAAD(userId: string, orgId: string): Buffer { + return Buffer.from(`capy:masterkey:v1:${userId}:${orgId}`, 'utf8'); +} + +/** + * AAD for the passphrase-wrapped local-mode keystore. A fixed domain tag (the + * local keystore has no user/org) that separates local blobs from org-wrapped + * ones: an org blob won't verify under the local AAD, and vice versa. + */ +export const LOCAL_MASTER_KEY_AAD = Buffer.from('capy:local-masterkey:v1', 'utf8'); + /** * Encrypts the master key M for storage on disk using AES-256-GCM. - * The wrapping key is derived from the auth token (stepping stone until - * service co-sign is implemented). + * + * `aad` binds the ciphertext to its operational context (see masterKeyAAD / + * LOCAL_MASTER_KEY_AAD). It is optional so older call sites keep compiling, but + * every new write should pass it. * * Returns a base64 string: base64(iv || ciphertext || authTag) */ export function encryptMasterKey( masterKey: Buffer, wrappingKey: Buffer, + aad?: Buffer, ): string { const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(AES_ALGORITHM, wrappingKey, iv, { authTagLength: AUTH_TAG_LENGTH, }); + if (aad) cipher.setAAD(aad); const encrypted = Buffer.concat([ cipher.update(masterKey), cipher.final(), @@ -200,18 +221,7 @@ export function encryptMasterKey( return Buffer.concat([iv, encrypted, authTag]).toString('base64'); } -/** - * Decrypts the master key M from its on-disk wrapped form. - */ -export function decryptMasterKey( - encryptedBlob: string, - wrappingKey: Buffer, -): Buffer { - const combined = Buffer.from(encryptedBlob, 'base64'); - if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH) { - throw new Error('Encrypted master key blob too short'); - } - +function gcmDecryptMasterKey(combined: Buffer, wrappingKey: Buffer, aad?: Buffer): Buffer { const iv = combined.subarray(0, IV_LENGTH); const authTag = combined.subarray(combined.length - AUTH_TAG_LENGTH); const encrypted = combined.subarray(IV_LENGTH, combined.length - AUTH_TAG_LENGTH); @@ -219,6 +229,7 @@ export function decryptMasterKey( const decipher = createDecipheriv(AES_ALGORITHM, wrappingKey, iv, { authTagLength: AUTH_TAG_LENGTH, }); + if (aad) decipher.setAAD(aad); decipher.setAuthTag(authTag); return Buffer.concat([ decipher.update(encrypted), @@ -226,6 +237,36 @@ export function decryptMasterKey( ]); } +/** + * Decrypts the master key M from its on-disk wrapped form. + * + * When `aad` is supplied, we verify against it first, then fall back to a + * no-AAD decrypt for blobs written before AAD binding existed — a transparent + * grandfather (those legacy blobs just aren't context-bound). A blob written + * WITH one AAD never verifies under a different AAD: the wrong-AAD attempt fails + * the GCM tag and the no-AAD fallback fails too, so cross-context substitution + * is rejected. Only genuinely AAD-less blobs reach the fallback. + */ +export function decryptMasterKey( + encryptedBlob: string, + wrappingKey: Buffer, + aad?: Buffer, +): Buffer { + const combined = Buffer.from(encryptedBlob, 'base64'); + if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH) { + throw new Error('Encrypted master key blob too short'); + } + + if (aad) { + try { + return gcmDecryptMasterKey(combined, wrappingKey, aad); + } catch { + // Legacy blob written without AAD — grandfather it (not context-bound). + } + } + return gcmDecryptMasterKey(combined, wrappingKey, undefined); +} + /** * Derives the inner wrapping key for the master key M. * This is one half of the double-wrap: AES-GCM(M, innerKey). diff --git a/src/crypto/keyResolver.ts b/src/crypto/keyResolver.ts index e5ce58f..57621e1 100644 --- a/src/crypto/keyResolver.ts +++ b/src/crypto/keyResolver.ts @@ -10,6 +10,8 @@ import { CURRENT_KDF_VERSION, KDF_VERSIONS, KdfVersion, + masterKeyAAD, + LOCAL_MASTER_KEY_AAD, } from './keyManager'; import { readMasterKey, @@ -77,11 +79,12 @@ export async function resolveProjectKey( } const innerKey = deriveWrappingKey(userId, orgId); + const innerAAD = masterKeyAAD(userId, orgId); // Try double-wrapped path: co-decrypt strips KMS outer, then inner unwrap try { const innerBlob = await service.coDecrypt(orgId, encryptedBlob); - const masterKey = decryptMasterKey(innerBlob, innerKey); + const masterKey = decryptMasterKey(innerBlob, innerKey, innerAAD); return deriveProjectKey(masterKey, projectId, orgId); } catch (err) { // 403 = membership revoked — do NOT fall through to legacy path. @@ -98,7 +101,7 @@ export async function resolveProjectKey( // Migration: try legacy single-wrapped (no KMS outer layer) let masterKey: Buffer; try { - masterKey = decryptMasterKey(encryptedBlob, innerKey); + masterKey = decryptMasterKey(encryptedBlob, innerKey, innerAAD); } catch { throw new CapyError( 'You do not have access to this project\'s secrets.\n\n' + @@ -111,7 +114,7 @@ export async function resolveProjectKey( // Legacy blob unwrapped — re-wrap with KMS outer layer for future runs try { - const innerWrapped = encryptMasterKey(masterKey, innerKey); + const innerWrapped = encryptMasterKey(masterKey, innerKey, innerAAD); // innerWrapped is already base64 — pass directly to wrapOuterLayer const outerWrapped = await service.wrapOuterLayer(orgId, innerWrapped); saveMasterKey(orgId, outerWrapped, userId); @@ -135,7 +138,7 @@ export async function wrapAndSaveMasterKey( service: KeyServiceOps, ): Promise { const innerKey = deriveWrappingKey(userId, orgId); - const innerWrapped = encryptMasterKey(masterKey, innerKey); + const innerWrapped = encryptMasterKey(masterKey, innerKey, masterKeyAAD(userId, orgId)); // innerWrapped is already base64 — pass directly to wrapOuterLayer const outerWrapped = await service.wrapOuterLayer(orgId, innerWrapped); saveMasterKey(orgId, outerWrapped, userId); @@ -227,7 +230,7 @@ export function resolveFromLocalKey(masterKeyHex: string, projectId: string): st export function saveLocalKey(masterKey: Buffer, passphrase: string): void { const salt = randomBytes(16); const wrappingKey = deriveLocalWrappingKey(passphrase, salt); - const encrypted = encryptMasterKey(masterKey, wrappingKey); + const encrypted = encryptMasterKey(masterKey, wrappingKey, LOCAL_MASTER_KEY_AAD); saveLocalKeyRecord({ version: '1.0', wrapping_method: 'passphrase', @@ -254,7 +257,7 @@ export function decryptLocalMasterKeyHex(passphrase: string): string { const salt = Buffer.from(record.salt, 'base64'); const wrappingKey = deriveLocalWrappingKey(passphrase, salt); try { - const masterKey = decryptMasterKey(record.encrypted_master_key, wrappingKey); + const masterKey = decryptMasterKey(record.encrypted_master_key, wrappingKey, LOCAL_MASTER_KEY_AAD); return masterKey.toString('hex'); } catch { throw new CapyError('Incorrect passphrase.', ERROR_CODES.PERMISSION_DENIED); diff --git a/tests/commands/recoverKdf.test.ts b/tests/commands/recoverKdf.test.ts index e6e95f1..71a87d2 100644 --- a/tests/commands/recoverKdf.test.ts +++ b/tests/commands/recoverKdf.test.ts @@ -106,11 +106,16 @@ function envBlobForVersion(version: 1 | 2, phrase: string): string { } // Recover the M that recover wrote to disk (strip fake KMS layer, unwrap inner). +// The inner blob is now AAD-bound (CAP-57), so pass the matching context AAD. function writtenMasterKey(): Buffer { const outer = gc.readMasterKey(ORG, FAKE_USER_ID); if (!outer) throw new Error('no key written'); const inner = outer.replace(/^kms:/, ''); - return km.decryptMasterKey(inner, km.deriveWrappingKey(FAKE_USER_ID, ORG)); + return km.decryptMasterKey( + inner, + km.deriveWrappingKey(FAKE_USER_ID, ORG), + km.masterKeyAAD(FAKE_USER_ID, ORG), + ); } describe('RecoverCommand — KDF version detection', () => { diff --git a/tests/crypto/aeadAad.test.ts b/tests/crypto/aeadAad.test.ts new file mode 100644 index 0000000..d9cea3a --- /dev/null +++ b/tests/crypto/aeadAad.test.ts @@ -0,0 +1,106 @@ +/** + * AAD binding on the master-key AEAD wrapping (CAP-57). + * + * encryptMasterKey/decryptMasterKey now bind Additional Authenticated Data so a + * wrapped blob can't be verified under a different (user, org) or moved between + * the org and local-only keystores. Existing blobs written before AAD existed + * must keep working (transparent grandfather). + * + * Proves: round-trip under AAD; altered AAD fails; cross-context substitution + * fails; org⇄local domain separation; legacy no-AAD blobs still decrypt; and a + * new AAD-bound blob does NOT decrypt when the reader forgets the AAD (the + * contract that obliges every reader to pass it). + */ +import { + generateSeedPhrase, + seedPhraseToMasterKey, + encryptMasterKey, + decryptMasterKey, + deriveWrappingKey, + masterKeyAAD, + LOCAL_MASTER_KEY_AAD, +} from '../../src/crypto/keyManager'; + +const orgId = 'org_aad'; +const userId = 'user_aad'; + +function freshMasterKey(): Buffer { + return seedPhraseToMasterKey(generateSeedPhrase()); +} + +describe('masterKeyAAD', () => { + it('is deterministic and distinct per (user, org)', () => { + expect(masterKeyAAD(userId, orgId).equals(masterKeyAAD(userId, orgId))).toBe(true); + expect(masterKeyAAD('a', orgId).equals(masterKeyAAD('b', orgId))).toBe(false); + expect(masterKeyAAD(userId, 'x').equals(masterKeyAAD(userId, 'y'))).toBe(false); + }); +}); + +describe('encryptMasterKey / decryptMasterKey — AAD binding', () => { + it('round-trips when the same AAD is supplied', () => { + const m = freshMasterKey(); + const key = deriveWrappingKey(userId, orgId); + const aad = masterKeyAAD(userId, orgId); + + const blob = encryptMasterKey(m, key, aad); + expect(decryptMasterKey(blob, key, aad).equals(m)).toBe(true); + }); + + it('fails when the AAD is altered between encrypt and decrypt', () => { + const m = freshMasterKey(); + const key = deriveWrappingKey(userId, orgId); + + const blob = encryptMasterKey(m, key, masterKeyAAD(userId, orgId)); + // Same key, different context AAD → tag mismatch, no legacy fallback succeeds. + expect(() => decryptMasterKey(blob, key, masterKeyAAD('attacker', orgId))).toThrow(); + }); + + it('rejects cross-context substitution (different user in same org)', () => { + const m = freshMasterKey(); + const keyA = deriveWrappingKey('userA', orgId); + const blob = encryptMasterKey(m, keyA, masterKeyAAD('userA', orgId)); + + const keyB = deriveWrappingKey('userB', orgId); + expect(() => decryptMasterKey(blob, keyB, masterKeyAAD('userB', orgId))).toThrow(); + }); + + it('separates org and local keystores by domain (same key, different AAD)', () => { + const m = freshMasterKey(); + const key = deriveWrappingKey(userId, orgId); + + const orgBlob = encryptMasterKey(m, key, masterKeyAAD(userId, orgId)); + expect(() => decryptMasterKey(orgBlob, key, LOCAL_MASTER_KEY_AAD)).toThrow(); + + const localBlob = encryptMasterKey(m, key, LOCAL_MASTER_KEY_AAD); + expect(() => decryptMasterKey(localBlob, key, masterKeyAAD(userId, orgId))).toThrow(); + }); + + it('grandfathers legacy blobs written without AAD', () => { + const m = freshMasterKey(); + const key = deriveWrappingKey(userId, orgId); + + // Legacy write: no AAD (how every existing key.enc was produced). + const legacyBlob = encryptMasterKey(m, key); + + // New reader passes AAD; the with-AAD attempt fails, the no-AAD fallback wins. + expect(decryptMasterKey(legacyBlob, key, masterKeyAAD(userId, orgId)).equals(m)).toBe(true); + // And a reader that passes no AAD still works too. + expect(decryptMasterKey(legacyBlob, key).equals(m)).toBe(true); + }); + + it('an AAD-bound blob does NOT decrypt when the reader omits the AAD', () => { + const m = freshMasterKey(); + const key = deriveWrappingKey(userId, orgId); + + const blob = encryptMasterKey(m, key, masterKeyAAD(userId, orgId)); + // No fallback can rescue this — the contract is that readers must pass AAD. + expect(() => decryptMasterKey(blob, key)).toThrow(); + }); + + it('still fails on a wrong wrapping key regardless of AAD', () => { + const m = freshMasterKey(); + const blob = encryptMasterKey(m, deriveWrappingKey(userId, orgId), masterKeyAAD(userId, orgId)); + const wrongKey = deriveWrappingKey('someone-else', orgId); + expect(() => decryptMasterKey(blob, wrongKey, masterKeyAAD(userId, orgId))).toThrow(); + }); +});