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
73 changes: 46 additions & 27 deletions src/commands/decryptCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
validateSeedPhrase,
seedPhraseToMasterKey,
deriveProjectKey,
KDF_VERSIONS,
} from '../crypto/keyManager';
import {
isRecoveryActive,
Expand Down Expand Up @@ -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<string, string> => {
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<string, string>;

if (isRecoveryActive()) {
const session = readRecoverySession();
Expand All @@ -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;
Expand All @@ -87,36 +117,25 @@ export class DecryptCommand {
process.exit(1);
}

const masterKey = seedPhraseToMasterKey(seedPhrase);
masterKeyHex = masterKey.toString('hex');
let resolved: { hex: string; decrypted: Record<string, string> } | 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<string, string>;
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);
Expand Down
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: 5 additions & 1 deletion src/commands/orgCreation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
88 changes: 86 additions & 2 deletions src/commands/recoverCommand.ts
Original file line number Diff line number Diff line change
@@ -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`;
Expand Down Expand Up @@ -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) =>
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
Loading
Loading