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(); + }); +});