Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/commands/inviteCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 3 additions & 3 deletions src/commands/transportCommand.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions src/crypto/deployCrypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
2 changes: 2 additions & 0 deletions src/crypto/deployRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
5 changes: 5 additions & 0 deletions src/crypto/encryptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
4 changes: 4 additions & 0 deletions src/crypto/inviteCrypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
69 changes: 55 additions & 14 deletions src/crypto/keyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -200,32 +221,52 @@ 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);

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),
decipher.final(),
]);
}

/**
* 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).
Expand Down
15 changes: 9 additions & 6 deletions src/crypto/keyResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
CURRENT_KDF_VERSION,
KDF_VERSIONS,
KdfVersion,
masterKeyAAD,
LOCAL_MASTER_KEY_AAD,
} from './keyManager';
import {
readMasterKey,
Expand Down Expand Up @@ -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.
Expand All @@ -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' +
Expand All @@ -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);
Expand All @@ -135,7 +138,7 @@ export async function wrapAndSaveMasterKey(
service: KeyServiceOps,
): Promise<void> {
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);
Expand Down Expand Up @@ -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',
Expand All @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion tests/commands/recoverKdf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
106 changes: 106 additions & 0 deletions tests/crypto/aeadAad.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});