diff --git a/src/commands/capyCommand.ts b/src/commands/capyCommand.ts index 3ce9ace..3269899 100644 --- a/src/commands/capyCommand.ts +++ b/src/commands/capyCommand.ts @@ -68,6 +68,8 @@ export class CapyCommand { return { coDecrypt: (orgId, ciphertext) => this.serviceClient.coDecrypt(orgId, ciphertext).then(r => r.plaintext), wrapOuterLayer: (orgId, plaintext) => this.serviceClient.wrapOuterLayer(orgId, plaintext).then(r => r.ciphertext), + getEpoch: (orgId) => this.serviceClient.getEpoch(orgId).then(r => r.epoch), + getEpochEscrows: (orgId) => this.serviceClient.getEpochEscrows(orgId).then(r => r.escrows), }; } diff --git a/src/commands/checkoutCommand.ts b/src/commands/checkoutCommand.ts index 2a8244a..47e8b67 100644 --- a/src/commands/checkoutCommand.ts +++ b/src/commands/checkoutCommand.ts @@ -77,6 +77,8 @@ export class CheckoutCommand { const keyOps: KeyServiceOps = { coDecrypt: (oid, ct) => this.serviceClient.coDecrypt(oid, ct).then(r => r.plaintext), wrapOuterLayer: (oid, pt) => this.serviceClient.wrapOuterLayer(oid, pt).then(r => r.ciphertext), + getEpoch: (oid) => this.serviceClient.getEpoch(oid).then(r => r.epoch), + getEpochEscrows: (oid) => this.serviceClient.getEpochEscrows(oid).then(r => r.escrows), }; const encryptionKey = await resolveProjectKey(orgId, projectId, authResult.user_id!, keyOps); diff --git a/src/commands/editCommand.ts b/src/commands/editCommand.ts index 2960d83..9b0870c 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -104,6 +104,8 @@ export class EditCommand { 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), + getEpoch: (oid: string) => serviceClient!.getEpoch(oid).then((r) => r.epoch), + getEpochEscrows: (oid: string) => serviceClient!.getEpochEscrows(oid).then((r) => r.escrows), }; projectKey = await resolveProjectKey( orgId, diff --git a/src/commands/exportCommand.ts b/src/commands/exportCommand.ts index f2a2e95..b539008 100644 --- a/src/commands/exportCommand.ts +++ b/src/commands/exportCommand.ts @@ -63,6 +63,8 @@ async function resolveEnv(devMode: boolean): Promise { const keyServiceOps = { coDecrypt: (o: string, c: string) => svc.coDecrypt(o, c).then(r => r.plaintext), wrapOuterLayer: (o: string, p: string) => svc.wrapOuterLayer(o, p).then(r => r.ciphertext), + getEpoch: (o: string) => svc.getEpoch(o).then(r => r.epoch), + getEpochEscrows: (o: string) => svc.getEpochEscrows(o).then(r => r.escrows), }; const projectKeyHex = await resolveProjectKey(orgId, projectId, result.user_id, keyServiceOps); diff --git a/src/commands/inviteCommand.ts b/src/commands/inviteCommand.ts index bbc0cb2..7833cce 100644 --- a/src/commands/inviteCommand.ts +++ b/src/commands/inviteCommand.ts @@ -2,8 +2,7 @@ import inquirer from 'inquirer'; import { resolveOrgContext } from '../core/orgContext'; import { ProjectManager } from '../core/projectManager'; import { readMasterKey } from '../config/globalConfig'; -import { decryptMasterKey, deriveWrappingKey, masterKeyAAD } from '../crypto/keyManager'; -import { wrapAndSaveMasterKey } from '../crypto/keyResolver'; +import { unwrapMasterKey } from '../crypto/keyResolver'; import { generateInviteToken, innerWrap, @@ -113,28 +112,20 @@ export class InviteCommand { process.exit(1); } + // Recover M via the shared resolver: co-decrypt strips the KMS outer + // layer, the inner layer is unwrapped under K_local (legacy SHA256 + // fallback + transparent migration). The invite payload re-wraps M under + // the invite token, so K_local never enters it. let masterKey: Buffer; try { - // Strip KMS outer layer via server co-decrypt - const { plaintext: innerBlob } = await serviceClient.coDecrypt(orgId, encryptedM); - // Strip local inner layer - const wrappingKey = deriveWrappingKey(userId, orgId); - 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, 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), - wrapOuterLayer: (oid: string, pt: string) => serviceClient.wrapOuterLayer(oid, pt).then(r => r.ciphertext), - }; - await wrapAndSaveMasterKey(masterKey, orgId, userId, keyOps); - } catch { - console.error('Failed to unwrap master key. Re-authenticate and try again.'); - process.exit(1); - } + 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), + }; + masterKey = await unwrapMasterKey(orgId, userId, keyOps); + } catch { + console.error('Failed to unwrap master key. Re-authenticate and try again.'); + process.exit(1); } // If this email already belongs to an org member, reuse their role and diff --git a/src/commands/kickCommand.ts b/src/commands/kickCommand.ts index e64df53..b7a7f4b 100644 --- a/src/commands/kickCommand.ts +++ b/src/commands/kickCommand.ts @@ -50,6 +50,14 @@ export class KickCommand { process.exit(1); } + // NOTE (CAP-58): the cryptographic epoch BUMP on kick is implemented in + // crypto/epochManager.ts (bumpEpoch) and the service stage/commit + // endpoints, but is NOT wired here yet. Activating it requires per-snapshot + // epoch tagging so that data pushed under an older epoch stays readable + // after a bump (cross-epoch reads). Until that lands, kick remains a + // policy-layer revocation (WorkOS removal + the kicked user's co-decrypt is + // refused). See docs/epoch-key-design.md. + console.log(''); console.log(` \x1b[33m${email}\x1b[0m has been removed from the organization.`); console.log(` \x1b[90mMembership ${membershipId} deleted.\x1b[0m`); diff --git a/src/commands/pushCommand.ts b/src/commands/pushCommand.ts index bafb2e9..8a87358 100644 --- a/src/commands/pushCommand.ts +++ b/src/commands/pushCommand.ts @@ -116,6 +116,8 @@ export class PushCommand { const keyOps: KeyServiceOps = { coDecrypt: (oid, ct) => this.serviceClient.coDecrypt(oid, ct).then(r => r.plaintext), wrapOuterLayer: (oid, pt) => this.serviceClient.wrapOuterLayer(oid, pt).then(r => r.ciphertext), + getEpoch: (oid) => this.serviceClient.getEpoch(oid).then(r => r.epoch), + getEpochEscrows: (oid) => this.serviceClient.getEpochEscrows(oid).then(r => r.escrows), }; encryptionKey = await resolveProjectKey( projectState.organizationId!, diff --git a/src/commands/redeemCommand.ts b/src/commands/redeemCommand.ts index dcb8e50..9e17494 100644 --- a/src/commands/redeemCommand.ts +++ b/src/commands/redeemCommand.ts @@ -145,13 +145,29 @@ export class RedeemCommand { process.exit(1); } - // 8. Double-wrap M (inner local key + outer KMS) and store locally + // 8. Double-wrap M (inner K_local + outer KMS) and store locally 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), }; await wrapAndSaveMasterKey(masterKey, orgId, userId, keyOps); + // 8b. Mint + register this machine's device keypair (CAP-58). Used by + // kick-time re-key to HPKE-seal new epoch keys to this device. The + // private key is double-wrapped under K_local; the public key is + // registered with the service. Best-effort — a registration hiccup + // self-heals on the next run and never blocks redeem. + try { + const { ensureDeviceKey } = await import('../crypto/deviceManager'); + await ensureDeviceKey(orgId, userId, { + 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), + registerDevice: (oid: string, pk: string) => serviceClient.registerDevice(oid, pk), + }); + } catch { + // Device registration deferred to next run. + } + console.log(''); console.log(' \x1b[32mInvite redeemed successfully!\x1b[0m'); console.log(''); diff --git a/src/commands/runCommand.ts b/src/commands/runCommand.ts index 60128fe..a732741 100644 --- a/src/commands/runCommand.ts +++ b/src/commands/runCommand.ts @@ -191,6 +191,8 @@ export async function runCommand(args: string[], devMode: boolean = false): Prom const keyServiceOps = { coDecrypt: (o: string, c: string) => svc.coDecrypt(o, c).then(r => r.plaintext), wrapOuterLayer: (o: string, p: string) => svc.wrapOuterLayer(o, p).then(r => r.ciphertext), + getEpoch: (o: string) => svc.getEpoch(o).then(r => r.epoch), + getEpochEscrows: (o: string) => svc.getEpochEscrows(o).then(r => r.escrows), }; projectKeyHex = await resolveProjectKey(orgId, projectId, result.user_id, keyServiceOps); diff --git a/src/commands/statusCommand.ts b/src/commands/statusCommand.ts index 8df18ba..4f85434 100644 --- a/src/commands/statusCommand.ts +++ b/src/commands/statusCommand.ts @@ -181,6 +181,8 @@ export class StatusCommand { const keyOps = { coDecrypt: (oid: string, ct: string) => this.serviceClient.coDecrypt(oid, ct).then(r => r.plaintext), wrapOuterLayer: (oid: string, pt: string) => this.serviceClient.wrapOuterLayer(oid, pt).then(r => r.ciphertext), + getEpoch: (oid: string) => this.serviceClient.getEpoch(oid).then(r => r.epoch), + getEpochEscrows: (oid: string) => this.serviceClient.getEpochEscrows(oid).then(r => r.escrows), }; encryptionKey = await resolveProjectKey( projectState.organizationId!, diff --git a/src/commands/transportCommand.ts b/src/commands/transportCommand.ts index 1fbffd0..03e035d 100644 --- a/src/commands/transportCommand.ts +++ b/src/commands/transportCommand.ts @@ -1,7 +1,6 @@ import { resolveOrgContext } from '../core/orgContext'; import { readMasterKey } from '../config/globalConfig'; -import { decryptMasterKey, deriveWrappingKey, masterKeyAAD } from '../crypto/keyManager'; -import { wrapAndSaveMasterKey } from '../crypto/keyResolver'; +import { unwrapMasterKey } from '../crypto/keyResolver'; import { generateInviteToken, innerWrap, @@ -35,24 +34,20 @@ export class TransportCommand { process.exit(1); } + // Recover M via the shared resolver: co-decrypt strips the KMS outer + // layer, the inner layer is unwrapped under K_local (legacy SHA256 + // fallback + transparent migration). K_local never enters the transport + // payload — only M is transported, re-wrapped under the transport token. let masterKey: Buffer; try { - const { plaintext: innerBlob } = await serviceClient.coDecrypt(orgId, encryptedM); - const wrappingKey = deriveWrappingKey(userId, orgId); - masterKey = decryptMasterKey(innerBlob, 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), + }; + masterKey = await unwrapMasterKey(orgId, userId, keyOps); } catch { - try { - const wrappingKey = deriveWrappingKey(userId, orgId); - 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), - }; - await wrapAndSaveMasterKey(masterKey, orgId, userId, keyOps); - } catch { - console.error('Failed to unwrap master key. Re-authenticate and try again.'); - process.exit(1); - } + console.error('Failed to unwrap master key. Re-authenticate and try again.'); + process.exit(1); } // Bind the inner key to the user's own email so only the same WorkOS diff --git a/src/config/globalConfig.ts b/src/config/globalConfig.ts index 806b71d..f2f8600 100644 --- a/src/config/globalConfig.ts +++ b/src/config/globalConfig.ts @@ -28,6 +28,112 @@ export function getProjectKeyCachePath(orgId: string, projectId: string): string return join(getGlobalCapyDir(), 'orgs', orgId, 'projects', projectId, 'key.cache'); } +// --- K_local + device key (CAP-58) --- +// +// Both live beside key.enc under ~/.capy/orgs//users//, the +// recovery-equivalent area `capy logout` never wipes. K_local is the +// device-local inner-wrap root (never transmitted); the device blob holds the +// double-wrapped X25519 private key. + +export function getLocalRootPath(orgId: string, userId?: string): string { + const base = userId + ? join(getGlobalCapyDir(), 'orgs', orgId, 'users', userId) + : join(getGlobalCapyDir(), 'orgs', orgId); + return join(base, 'local.key'); +} + +export function getDeviceKeyPath(orgId: string, userId?: string): string { + const base = userId + ? join(getGlobalCapyDir(), 'orgs', orgId, 'users', userId) + : join(getGlobalCapyDir(), 'orgs', orgId); + return join(base, 'device.enc'); +} + +/** Persists K_local (raw 32 bytes, base64) 0600. */ +export function saveLocalRoot(orgId: string, kLocal: Buffer, userId?: string): void { + writeSecureFile(getLocalRootPath(orgId, userId), kLocal.toString('base64')); +} + +/** Reads K_local, or null if this machine has never minted one for this org. */ +export function readLocalRoot(orgId: string, userId?: string): Buffer | null { + const content = readFileOrNull(getLocalRootPath(orgId, userId)); + return content ? Buffer.from(content.trim(), 'base64') : null; +} + +export function hasLocalRoot(orgId: string, userId?: string): boolean { + return existsSync(getLocalRootPath(orgId, userId)); +} + +export interface DeviceKeyRecord { + version: string; + /** opaque device id assigned by the service at registration */ + device_id?: string; + /** base64 raw X25519 public key (registered with the service) */ + public_key: string; + /** double-wrapped (KMS outer + K_local inner) PKCS#8 private key, base64 */ + encrypted_private_key: string; + created_at: string; +} + +export function saveDeviceKeyRecord(orgId: string, record: DeviceKeyRecord, userId?: string): void { + writeSecureFile(getDeviceKeyPath(orgId, userId), JSON.stringify(record, null, 2)); +} + +export function readDeviceKeyRecord(orgId: string, userId?: string): DeviceKeyRecord | null { + const content = readFileOrNull(getDeviceKeyPath(orgId, userId)); + if (!content) return null; + try { + return JSON.parse(content) as DeviceKeyRecord; + } catch { + return null; + } +} + +export function hasDeviceKey(orgId: string, userId?: string): boolean { + return existsSync(getDeviceKeyPath(orgId, userId)); +} + +// --- Current epoch key (CAP-58) --- +// +// The org's current epoch key E_e, double-wrapped (KMS outer + K_local inner), +// stored beside key.enc. Absent means epoch 0 — the legacy M-derived scheme, +// where key.enc (wrapped M) IS the epoch-0 key. Written when the org bumps past +// epoch 0 (first kick) and refreshed transparently on each run. + +export function getEpochKeyPath(orgId: string, userId?: string): string { + const base = userId + ? join(getGlobalCapyDir(), 'orgs', orgId, 'users', userId) + : join(getGlobalCapyDir(), 'orgs', orgId); + return join(base, 'epoch.enc'); +} + +export interface EpochKeyRecord { + version: string; + epoch: number; + /** double-wrapped (KMS outer + K_local inner) current epoch key, base64 */ + encrypted_epoch_key: string; + updated_at: string; +} + +export function saveEpochKeyRecord(orgId: string, record: EpochKeyRecord, userId?: string): void { + writeSecureFile(getEpochKeyPath(orgId, userId), JSON.stringify(record, null, 2)); +} + +export function readEpochKeyRecord(orgId: string, userId?: string): EpochKeyRecord | null { + const content = readFileOrNull(getEpochKeyPath(orgId, userId)); + if (!content) return null; + try { + return JSON.parse(content) as EpochKeyRecord; + } catch { + return null; + } +} + +/** Local epoch number (0 if no epoch.enc — i.e. legacy/epoch-0 state). */ +export function readLocalEpoch(orgId: string, userId?: string): number { + return readEpochKeyRecord(orgId, userId)?.epoch ?? 0; +} + export function getGlobalConfigPath(): string { return join(getGlobalCapyDir(), 'config.json'); } diff --git a/src/crypto/deviceKey.ts b/src/crypto/deviceKey.ts new file mode 100644 index 0000000..7228c04 --- /dev/null +++ b/src/crypto/deviceKey.ts @@ -0,0 +1,124 @@ +import { + generateKeyPairSync, + diffieHellman, + createPublicKey, + createPrivateKey, + hkdfSync, + randomBytes, + KeyObject, +} from 'crypto'; +import { aesEncrypt, aesDecrypt } from './inviteCrypto'; + +/** + * Per-user X25519 device keypair (CAP-58 / docs/epoch-key-design.md §4). + * + * Purpose: a service-blind transport channel for new epoch keys. At kick time + * the kicker seals the new epoch key to every remaining member's device public + * key; members unseal it on their next run. The service stores the sealed + * blobs but cannot open them — only the device private key can. + * + * Construction is a box-style ECIES seal built from Node's native primitives + * (X25519 + HKDF-SHA256 + AES-256-GCM), so the CLI ships with no native crypto + * addons (pkg binary constraint). Each seal uses a fresh ephemeral keypair; the + * shared secret is mixed with both public keys via HKDF before keying AES-GCM. + * + * NOTE: the device PRIVATE key is itself stored double-wrapped (KMS outer + + * local inner keyed by HKDF(K_local, "capy:inner:device")) — see localKeyRoot. + * This module deals only with the keypair and the seal/open transform. + */ + +const RAW_KEY_LENGTH = 32; + +export interface DeviceKeyPair { + /** base64 of the raw 32-byte X25519 public key (what the service stores). */ + publicKeyB64: string; + /** PKCS#8 DER of the private key, base64 — the bytes that get double-wrapped. */ + privateKeyPkcs8B64: string; +} + +/** Extracts the raw 32-byte X25519 public key from a KeyObject. */ +function rawPublicKey(pub: KeyObject): Buffer { + const jwk = pub.export({ format: 'jwk' }) as { x?: string }; + if (!jwk.x) throw new Error('Not an X25519 public key'); + const raw = Buffer.from(jwk.x, 'base64url'); + if (raw.length !== RAW_KEY_LENGTH) throw new Error('Unexpected X25519 public key length'); + return raw; +} + +/** Reconstructs a public-key KeyObject from raw 32 bytes (SPKI wrapping). */ +function publicKeyFromRaw(raw: Buffer): KeyObject { + if (raw.length !== RAW_KEY_LENGTH) throw new Error('X25519 public key must be 32 bytes'); + const jwk = { kty: 'OKP', crv: 'X25519', x: raw.toString('base64url') }; + return createPublicKey({ key: jwk, format: 'jwk' }); +} + +/** Generates a fresh device keypair. */ +export function generateDeviceKeyPair(): DeviceKeyPair { + const { publicKey, privateKey } = generateKeyPairSync('x25519'); + return { + publicKeyB64: rawPublicKey(publicKey).toString('base64'), + privateKeyPkcs8B64: privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64'), + }; +} + +/** Loads a private-key KeyObject from the stored PKCS#8 bytes. */ +function loadPrivateKey(privateKeyPkcs8B64: string): KeyObject { + return createPrivateKey({ + key: Buffer.from(privateKeyPkcs8B64, 'base64'), + type: 'pkcs8', + format: 'der', + }); +} + +/** + * Derives the symmetric seal key from a DH shared secret, binding both public + * keys so the transcript can't be repurposed across recipients. + */ +function deriveSealKey(shared: Buffer, ephemeralPubRaw: Buffer, recipientPubRaw: Buffer): Buffer { + const salt = Buffer.concat([ephemeralPubRaw, recipientPubRaw]); + return Buffer.from(hkdfSync('sha256', shared, salt, 'capy:device:seal:v1', 32)); +} + +/** + * Seals `plaintext` (e.g. a 32-byte epoch key) to a recipient device public + * key. Output: base64( ephemeralPub(32) || aesEncrypt(plaintext) ). + */ +export function sealToDevice(recipientPublicKeyB64: string, plaintext: Buffer): string { + const recipientRaw = Buffer.from(recipientPublicKeyB64, 'base64'); + const recipientPub = publicKeyFromRaw(recipientRaw); + + const { publicKey: ephPub, privateKey: ephPriv } = generateKeyPairSync('x25519'); + const ephemeralRaw = rawPublicKey(ephPub); + const shared = diffieHellman({ privateKey: ephPriv, publicKey: recipientPub }); + const sealKey = deriveSealKey(shared, ephemeralRaw, recipientRaw); + + const inner = Buffer.from(aesEncrypt(plaintext, sealKey), 'base64'); + return Buffer.concat([ephemeralRaw, inner]).toString('base64'); +} + +/** + * Opens a sealed blob with the device private key. Tamper / wrong key fails the + * AEAD auth. Returns the plaintext. + */ +export function openSealed(privateKeyPkcs8B64: string, sealedB64: string): Buffer { + const combined = Buffer.from(sealedB64, 'base64'); + if (combined.length <= RAW_KEY_LENGTH) throw new Error('Sealed blob too short'); + const ephemeralRaw = combined.subarray(0, RAW_KEY_LENGTH); + const inner = combined.subarray(RAW_KEY_LENGTH); + + const priv = loadPrivateKey(privateKeyPkcs8B64); + const ephemeralPub = publicKeyFromRaw(ephemeralRaw); + const shared = diffieHellman({ privateKey: priv, publicKey: ephemeralPub }); + + // Recipient's own raw public key, recomputed from the private key, completes + // the HKDF salt transcript used at seal time. + const recipientRaw = rawPublicKey(createPublicKey(priv)); + const sealKey = deriveSealKey(shared, ephemeralRaw, recipientRaw); + + return aesDecrypt(inner.toString('base64'), sealKey); +} + +/** Generates a random opaque device id used to namespace local key files. */ +export function randomDeviceLabel(): string { + return randomBytes(8).toString('hex'); +} diff --git a/src/crypto/deviceManager.ts b/src/crypto/deviceManager.ts new file mode 100644 index 0000000..7439109 --- /dev/null +++ b/src/crypto/deviceManager.ts @@ -0,0 +1,118 @@ +import { + readLocalRoot, + saveLocalRoot, + readDeviceKeyRecord, + saveDeviceKeyRecord, + DeviceKeyRecord, +} from '../config/globalConfig'; +import { generateLocalRoot, deriveDeviceInnerKey } from './localKeyRoot'; +import { encryptMasterKey, decryptMasterKey } from './keyManager'; +import { generateDeviceKeyPair } from './deviceKey'; + +/** + * Device keypair lifecycle (CAP-58 / docs/epoch-key-design.md §4). + * + * Each machine has one X25519 device keypair per org. The private key is stored + * double-wrapped exactly like key.enc — KMS outer (stripped via co-decrypt) + + * local inner keyed by HKDF(K_local, "capy:inner:device"). The public key is + * registered with the service (append-only) so kick-time re-key can HPKE-seal + * new epoch keys to it. + * + * The device private key never leaves the machine in plaintext, and K_local + * never leaves at all — so the service is a blind mailbox for sealed blobs. + */ + +const DEVICE_AAD = Buffer.from('capy:devicekey:v1', 'utf8'); + +/** Operations deviceManager needs from the service (subset of ServiceClient). */ +export interface DeviceServiceOps { + coDecrypt(orgId: string, ciphertext: string): Promise; + wrapOuterLayer(orgId: string, plaintext: string): Promise; + registerDevice(orgId: string, publicKey: string): Promise<{ device_id: string }>; +} + +/** Reads K_local, minting + persisting one if this machine has none. */ +function ensureLocalRoot(orgId: string, userId: string): Buffer { + let kLocal = readLocalRoot(orgId, userId); + if (!kLocal) { + kLocal = generateLocalRoot(); + saveLocalRoot(orgId, kLocal, userId); + } + return kLocal; +} + +/** + * Ensures this machine has a registered device keypair for the org. Idempotent: + * if device.enc already exists, returns its device_id (re-registering the same + * public key is a service-side no-op). Otherwise mints a keypair, double-wraps + * the private key under K_local, registers the public key, and persists. + * + * Best-effort by design: callers wrap this so a registration failure (e.g. + * server briefly unavailable) never blocks the surrounding flow — the device is + * registered on the next run. + */ +export async function ensureDeviceKey( + orgId: string, + userId: string, + service: DeviceServiceOps, +): Promise { + const existing = readDeviceKeyRecord(orgId, userId); + if (existing) { + // Re-assert registration (append-only / idempotent) so a device minted + // while the server was down still lands. Cheap and self-healing. + try { + const { device_id } = await service.registerDevice(orgId, existing.public_key); + if (device_id && device_id !== existing.device_id) { + saveDeviceKeyRecord(orgId, { ...existing, device_id }, userId); + } + return device_id || existing.device_id || ''; + } catch { + return existing.device_id || ''; + } + } + + const kLocal = ensureLocalRoot(orgId, userId); + const keyPair = generateDeviceKeyPair(); + + // Double-wrap the private key: inner = HKDF(K_local), outer = KMS. + const privBuf = Buffer.from(keyPair.privateKeyPkcs8B64, 'base64'); + const innerWrapped = encryptMasterKey(privBuf, deriveDeviceInnerKey(kLocal), DEVICE_AAD); + const encryptedPrivateKey = await service.wrapOuterLayer(orgId, innerWrapped); + + const { device_id } = await service.registerDevice(orgId, keyPair.publicKeyB64); + + const record: DeviceKeyRecord = { + version: '1.0', + device_id, + public_key: keyPair.publicKeyB64, + encrypted_private_key: encryptedPrivateKey, + created_at: new Date().toISOString(), + }; + saveDeviceKeyRecord(orgId, record, userId); + return device_id; +} + +/** + * Recovers this machine's device private key (PKCS#8 base64): strip KMS outer + * via co-decrypt, then unwrap the inner layer with HKDF(K_local). Returns null + * if no device keypair exists locally. + */ +export async function loadDevicePrivateKey( + orgId: string, + userId: string, + service: DeviceServiceOps, +): Promise { + const record = readDeviceKeyRecord(orgId, userId); + if (!record) return null; + const kLocal = readLocalRoot(orgId, userId); + if (!kLocal) return null; + + const innerBlob = await service.coDecrypt(orgId, record.encrypted_private_key); + const priv = decryptMasterKey(innerBlob, deriveDeviceInnerKey(kLocal), DEVICE_AAD); + return priv.toString('base64'); +} + +/** The locally-stored device public key, or null. */ +export function getDevicePublicKey(orgId: string, userId: string): string | null { + return readDeviceKeyRecord(orgId, userId)?.public_key ?? null; +} diff --git a/src/crypto/epochCrypto.ts b/src/crypto/epochCrypto.ts new file mode 100644 index 0000000..5e1033e --- /dev/null +++ b/src/crypto/epochCrypto.ts @@ -0,0 +1,148 @@ +import { randomBytes, hkdfSync } from 'crypto'; +import { aesEncrypt, aesDecrypt } from './inviteCrypto'; + +/** + * Epoch key model (CAP-58 / docs/epoch-key-design.md). + * + * Data is encrypted under per-epoch keys, never under a key derived from the + * org master key M. Each kick mints a fresh random epoch key with no + * mathematical relationship to any previous key (forward secrecy against + * exfiltration). The current epoch key unlocks all past epoch keys via an + * encrypted backward chain (full-history access for invitees). + * + * This module is pure crypto — no I/O, no service calls. Service transport and + * command flows live elsewhere. + */ + +const EPOCH_KEY_LENGTH = 32; + +/** Mints a fresh epoch key: 32 bytes of CSPRNG output, derived from nothing. */ +export function generateEpochKey(): Buffer { + return randomBytes(EPOCH_KEY_LENGTH); +} + +/** + * The epoch-0 key. Per CAP-58's migration decision (epoch 0 = the legacy + * M-derived scheme — NOT HKDF(M)), E_0 IS M itself, so existing ciphertext + * encrypted under deriveProjectKey(M, …) reads unchanged as epoch 0 with no + * re-encryption. Every E_e for e >= 1 is fresh randomness (generateEpochKey). + * + * Returns a copy so callers can't mutate M through the returned buffer. + * + * NOTE: because E_0 = M, walking the ORG-WIDE history chain back to epoch 0 + * yields M — so that chain is owner/admin-only. Project-scoped members use the + * PER-PROJECT chain, which bottoms out at deriveProjectKey(M, p) and never + * exposes M (see design doc §7). + */ +export function deriveEpoch0(masterKey: Buffer): Buffer { + return Buffer.from(masterKey); +} + +/** + * Derives the project-scoped data-encryption key from an epoch key. + * Mirrors the legacy deriveProjectKey(M, ...) shape (salt=orgId, info per + * project) so that, at epoch 0, deriveProjectKey(deriveEpoch0(M), p, org) + * equals the legacy deriveProjectKey(M, p, org) IFF E_0 == M. It does not — + * E_0 = HKDF(M) — so epoch-0 ciphertext is re-derived under E_0, which the + * migration path accounts for. Returns a 32-byte key as hex (Encryptor input). + */ +export function deriveProjectKey( + epochKey: Buffer, + projectId: string, + orgId: string, +): string { + const derived = hkdfSync( + 'sha256', + epochKey, + orgId, + `capy:project:${projectId}`, + 32, + ); + return Buffer.from(derived).toString('hex'); +} + +/** + * AAD binding a snapshot's ciphertext to {orgId, projectId, epoch}. Extends + * capy-cli #233's masterKeyAAD scheme down to the data layer: re-tagging a + * snapshot with a different epoch / project / org fails the AEAD check, so a + * blob can't be spliced across epochs or projects. + */ +export function snapshotAAD(orgId: string, projectId: string, epoch: number): Buffer { + return Buffer.from(`capy:snapshot:v1:${orgId}:${projectId}:${epoch}`, 'utf8'); +} + +// --------------------------------------------------------------------------- +// History chain (backward) — org-wide and per-project +// --------------------------------------------------------------------------- + +/** + * Writes the history blob for the transition e-1 -> e: + * AES-GCM( prevKey, key = HKDF(newKey, "history") ) + * + * Holding the NEW (current) key, you derive the wrapping key and recover the + * PREVIOUS key — and recurse to the start. You cannot walk forward: newKey is + * fresh randomness, unreachable from prevKey. + */ +export function wrapHistoryBlob(prevKey: Buffer, newKey: Buffer): string { + const wrappingKey = Buffer.from(hkdfSync('sha256', newKey, 'history', 'capy:epoch:history', 32)); + return aesEncrypt(prevKey, wrappingKey); +} + +/** Recovers the previous epoch key from a history blob, given the new key. */ +export function unwrapHistoryBlob(blob: string, newKey: Buffer): Buffer { + const wrappingKey = Buffer.from(hkdfSync('sha256', newKey, 'history', 'capy:epoch:history', 32)); + return aesDecrypt(blob, wrappingKey); +} + +/** + * Per-project history blob. The chain is over DERIVED keys so a project-scoped + * member's walk stays confined to their project and never exposes the org-wide + * epoch key or another project's keys: + * AES-GCM( deriveProjectKey(E_{e-1}, p), key = HKDF(deriveProjectKey(E_e, p), "history") ) + */ +export function wrapProjectHistoryBlob( + prevKey: Buffer, + newKey: Buffer, + projectId: string, + orgId: string, +): string { + const prevDerived = Buffer.from(deriveProjectKey(prevKey, projectId, orgId), 'hex'); + const newDerived = Buffer.from(deriveProjectKey(newKey, projectId, orgId), 'hex'); + const wrappingKey = Buffer.from(hkdfSync('sha256', newDerived, 'history', 'capy:epoch:history', 32)); + return aesEncrypt(prevDerived, wrappingKey); +} + +/** + * Recovers the previous epoch's DERIVED project key from a per-project history + * blob, given the new epoch's derived project key. Returns the derived key as + * hex (same shape as deriveProjectKey). + */ +export function unwrapProjectHistoryBlob( + blob: string, + newDerivedKeyHex: string, +): string { + const newDerived = Buffer.from(newDerivedKeyHex, 'hex'); + const wrappingKey = Buffer.from(hkdfSync('sha256', newDerived, 'history', 'capy:epoch:history', 32)); + return aesDecrypt(blob, wrappingKey).toString('hex'); +} + +// --------------------------------------------------------------------------- +// Escrow (owner break-glass) +// --------------------------------------------------------------------------- + +/** + * Escrow blob for epoch e: AES-GCM( E_e, key = HKDF(M, "escrow", e) ). + * Owner-only: seed phrase -> M -> open every escrow -> every epoch key, fully + * offline (ADR-6 break-glass preserved). The epoch number is bound into the + * HKDF info so blobs are not interchangeable across epochs. + */ +export function wrapEscrowBlob(masterKey: Buffer, epoch: number, epochKey: Buffer): string { + const wrappingKey = Buffer.from(hkdfSync('sha256', masterKey, 'escrow', `capy:epoch:escrow:${epoch}`, 32)); + return aesEncrypt(epochKey, wrappingKey); +} + +/** Recovers an epoch key from its escrow blob using M. Wrong M fails GCM auth. */ +export function unwrapEscrowBlob(blob: string, masterKey: Buffer, epoch: number): Buffer { + const wrappingKey = Buffer.from(hkdfSync('sha256', masterKey, 'escrow', `capy:epoch:escrow:${epoch}`, 32)); + return aesDecrypt(blob, wrappingKey); +} diff --git a/src/crypto/epochManager.ts b/src/crypto/epochManager.ts new file mode 100644 index 0000000..625afea --- /dev/null +++ b/src/crypto/epochManager.ts @@ -0,0 +1,247 @@ +import { + readEpochKeyRecord, + saveEpochKeyRecord, + readLocalEpoch, + readLocalRoot, + EpochKeyRecord, +} from '../config/globalConfig'; +import { deriveEpochInnerKey } from './localKeyRoot'; +import { encryptMasterKey, decryptMasterKey, masterKeyAAD } from './keyManager'; +import { unwrapMasterKey } from './keyResolver'; +import { + generateEpochKey, + wrapHistoryBlob, + wrapProjectHistoryBlob, + wrapEscrowBlob, + unwrapEscrowBlob, +} from './epochCrypto'; +import { openSealed, sealToDevice } from './deviceKey'; +import { loadDevicePrivateKey } from './deviceManager'; +import { isMembershipRevokedError } from '../errors/membershipRevoked'; + +/** + * Epoch key lifecycle orchestration (CAP-58 / docs/epoch-key-design.md). + * + * Epoch 0 is the legacy M-derived scheme: key.enc (wrapped M) IS the epoch-0 + * key, so getCurrentEpochKey returns M and resolveProjectKey derives from it, + * exactly as before. From epoch 1 onward the current epoch key E_e is fresh + * randomness, stored double-wrapped in epoch.enc and refreshed transparently on + * each run from the service's per-device sealed blobs. + */ + +const EPOCH_AAD = Buffer.from('capy:epochkey:v1', 'utf8'); + +export interface EpochServiceOps { + coDecrypt(orgId: string, ciphertext: string): Promise; + wrapOuterLayer(orgId: string, plaintext: string): Promise; + /** Current org epoch (optional — absent means treat as epoch 0). */ + getEpoch?(orgId: string): Promise; + /** Escrow blobs epoch→blob (optional). */ + getEpochEscrows?(orgId: string): Promise>; +} + +/** + * Catches this machine up to the org's current epoch by recovering the current + * epoch key from M + the escrow blob (the owner break-glass channel, available + * to any M-holder — which all members are during migration). Best-effort: an + * old service without epoch endpoints, a missing escrow (owner-backfill + * pending), or any error leaves the local epoch unchanged. + * + * This is the transitional recovery path. The device-sealed-blob channel + * (refreshEpoch) is the end-state path for members who no longer hold M. + */ +export async function ensureCurrentEpoch( + orgId: string, + userId: string, + service: EpochServiceOps, + masterKey: Buffer, +): Promise { + if (!service.getEpoch || !service.getEpochEscrows) return; + try { + const currentEpoch = await service.getEpoch(orgId); + if (currentEpoch <= readLocalEpoch(orgId, userId)) return; + + const escrows = await service.getEpochEscrows(orgId); + const blob = escrows[String(currentEpoch)]; + if (!blob) return; // escrow not yet written (admin kick awaiting owner backfill) + + const epochKey = unwrapEscrowBlob(blob, masterKey, currentEpoch); + await storeEpochKey(orgId, userId, currentEpoch, epochKey, service); + } catch (err) { + // The membership gate already ran (unwrapMasterKey in getCurrentEpochKey), + // so a kick is detected there with a clean MEMBERSHIP_REVOKED. Anything + // that surfaces here (old service without epoch endpoints, missing escrow, + // transient blip) is benign — stay on the local epoch. A late-propagating + // revocation is still re-thrown so cleanup can run. + if (isMembershipRevokedError(err)) throw err; + } +} + +export interface FullEpochServiceOps extends EpochServiceOps { + getEpoch(orgId: string): Promise; + registerDevice(orgId: string, publicKey: string): Promise<{ device_id: string }>; + getSealedBlobs(orgId: string): Promise<{ epoch: number; sealed_blobs: Array<{ device_id: string; blob: string }> }>; + listMembers(orgId: string): Promise<{ members: any[]; device_keys?: Record> }>; + stageEpoch(orgId: string, payload: any): Promise<{ staged: boolean; epoch: number; uncovered_members: string[] }>; + commitEpoch(orgId: string, epoch: number): Promise<{ epoch: number }>; +} + +/** Unwraps the current epoch key E_e from epoch.enc (KMS outer + K_local inner). */ +async function unwrapEpochKey(orgId: string, userId: string, record: EpochKeyRecord, service: EpochServiceOps): Promise { + const kLocal = readLocalRoot(orgId, userId); + if (!kLocal) throw new Error('Missing K_local — cannot unwrap epoch key'); + const innerBlob = await service.coDecrypt(orgId, record.encrypted_epoch_key); + return decryptMasterKey(innerBlob, deriveEpochInnerKey(kLocal), EPOCH_AAD); +} + +/** Persists E_e double-wrapped (KMS outer + K_local inner) at the given epoch. */ +async function storeEpochKey(orgId: string, userId: string, epoch: number, epochKey: Buffer, service: EpochServiceOps): Promise { + const kLocal = readLocalRoot(orgId, userId); + if (!kLocal) throw new Error('Missing K_local — cannot store epoch key'); + const innerWrapped = encryptMasterKey(epochKey, deriveEpochInnerKey(kLocal), EPOCH_AAD); + const outerWrapped = await service.wrapOuterLayer(orgId, innerWrapped); + const record: EpochKeyRecord = { + version: '1.0', + epoch, + encrypted_epoch_key: outerWrapped, + updated_at: new Date().toISOString(), + }; + saveEpochKeyRecord(orgId, record, userId); +} + +/** + * Returns the org's current epoch key as known to this machine: M at epoch 0 + * (legacy), or the stored E_e at epoch ≥1. This is the key resolveProjectKey + * derives the per-project data key from. + */ +export async function getCurrentEpochKey( + orgId: string, + userId: string, + service: EpochServiceOps, +): Promise<{ epoch: number; key: Buffer }> { + // Membership gate FIRST: unwrapMasterKey co-decrypts key.enc, so a kicked + // user gets a clean MEMBERSHIP_REVOKED here (before any epoch call), and the + // caller's normal cleanup runs. M also seeds the escrow-based catch-up. + // (Transitional: every member still holds M. In the end state this gate + // moves to the epoch.enc co-decrypt.) + const masterKey = await unwrapMasterKey(orgId, userId, service); + + // Catch up to the org's current epoch (no-op at epoch 0 / old service). + await ensureCurrentEpoch(orgId, userId, service, masterKey); + + const record = readEpochKeyRecord(orgId, userId); + if (!record || record.epoch === 0) { + // Epoch 0: the epoch key IS M (legacy M-derived scheme). + return { epoch: 0, key: masterKey }; + } + return { epoch: record.epoch, key: await unwrapEpochKey(orgId, userId, record, service) }; +} + +/** + * Transparent re-key (per-run). Given the service's current epoch and the + * caller's pending sealed blobs (from the co-decrypt response or a dedicated + * fetch), unseal the new epoch key with this machine's device private key and + * store it. No-op when already current. Best-effort — a failure leaves the + * member on their old epoch; the next run retries. + */ +export async function refreshEpoch( + orgId: string, + userId: string, + currentEpoch: number, + sealedBlobs: Array<{ device_id: string; blob: string }>, + service: FullEpochServiceOps, +): Promise { + if (currentEpoch <= readLocalEpoch(orgId, userId)) return false; + if (!sealedBlobs.length) return false; + + const privKey = await loadDevicePrivateKey(orgId, userId, service); + if (!privKey) return false; + + for (const sealed of sealedBlobs) { + try { + const epochKey = openSealed(privKey, sealed.blob); + await storeEpochKey(orgId, userId, currentEpoch, epochKey, service); + return true; + } catch { + // Try the next sealed blob (e.g. sealed to a different device of ours). + } + } + return false; +} + +/** + * Kick-time epoch bump (owner/admin). Mints E_{e+1}, writes the history and + * (owner-only) escrow blobs, HPKE-seals E_{e+1} to every device of every + * remaining member, stages, then commits — the two-phase transaction the + * service guards with an optimistic lock. Updates the kicker's own local epoch. + * + * `masterKey` is required to write the escrow blob and, at epoch 0, as the + * previous epoch key (E_0 = M). An admin kicker without M passes null: escrow + * is skipped (the owner backfills) — but at epoch 0 the admin still needs M to + * derive the previous key, so admin-kick-at-epoch-0 is owner-only for now. + * + * `remainingUserId` is the kicker (and any other remaining members are read + * from the members list). Returns the new epoch and any uncovered members. + */ +export async function bumpEpoch( + orgId: string, + userId: string, + service: FullEpochServiceOps, + opts: { + projectIds: string[]; + masterKey: Buffer | null; + excludeUserId?: string; // the kicked user — never seal to them + }, +): Promise<{ epoch: number; uncoveredMembers: string[] }> { + const currentEpoch = await service.getEpoch(orgId); + const nextEpoch = currentEpoch + 1; + + // Previous epoch key: M at epoch 0, else this machine's stored E_cur. + let prevKey: Buffer; + if (currentEpoch === 0) { + if (!opts.masterKey) throw new Error('Owner master key required to bump from epoch 0'); + prevKey = opts.masterKey; + } else { + prevKey = (await getCurrentEpochKey(orgId, userId, service)).key; + } + + const newKey = generateEpochKey(); + + // Org-wide history blob (owner/admin full-access walk). + const historyBlob = wrapHistoryBlob(prevKey, newKey); + + // Per-project history blobs (project-scoped members walk these — they bottom + // out at deriveProjectKey(prev, p), never exposing M). + const projectHistoryBlobs: Record = {}; + for (const pid of opts.projectIds) { + projectHistoryBlobs[pid] = wrapProjectHistoryBlob(prevKey, newKey, pid, orgId); + } + + // Escrow blob (owner only — needs M). + const escrowBlob = opts.masterKey ? wrapEscrowBlob(opts.masterKey, nextEpoch, newKey) : undefined; + + // Seal E_{e+1} to every device of every remaining member. + const { device_keys } = await service.listMembers(orgId); + const sealedBlobs: Array<{ user_id: string; device_id: string; blob: string }> = []; + for (const [memberId, devices] of Object.entries(device_keys ?? {})) { + if (memberId === opts.excludeUserId) continue; // never seal to the kicked user + for (const d of devices) { + sealedBlobs.push({ user_id: memberId, device_id: d.device_id, blob: sealToDevice(d.public_key, newKey) }); + } + } + + // Stage → commit (two-phase, service-guarded). + const stageRes = await service.stageEpoch(orgId, { + epoch: nextEpoch, + history_blob: historyBlob, + project_history_blobs: projectHistoryBlobs, + ...(escrowBlob ? { escrow_blob: escrowBlob } : {}), + sealed_blobs: sealedBlobs, + }); + await service.commitEpoch(orgId, nextEpoch); + + // Advance the kicker's own local epoch immediately. + await storeEpochKey(orgId, userId, nextEpoch, newKey, service); + + return { epoch: nextEpoch, uncoveredMembers: stageRes.uncovered_members }; +} diff --git a/src/crypto/keyResolver.ts b/src/crypto/keyResolver.ts index 57621e1..67370e6 100644 --- a/src/crypto/keyResolver.ts +++ b/src/crypto/keyResolver.ts @@ -21,8 +21,11 @@ import { hasOrgKey as globalHasOrgKey, saveLocalKeyRecord, readLocalKeyRecord, + readLocalRoot, + saveLocalRoot, LOCAL_ORG_ID, } from '../config/globalConfig'; +import { generateLocalRoot, deriveEpochInnerKey } from './localKeyRoot'; import { CapyError, ERROR_CODES } from '../types/index'; /** Check whether an error is a server 403 (membership revoked). */ @@ -46,28 +49,88 @@ export interface KeyServiceOps { coDecrypt(orgId: string, ciphertext: string): Promise; /** Add the KMS outer layer via POST /orgs/:orgId/wrap */ wrapOuterLayer(orgId: string, plaintext: string): Promise; + // --- Epoch awareness (CAP-58, optional) --- + // When present, resolveProjectKey transparently catches this machine up to the + // org's current epoch before deriving the data key (recovering the current + // epoch key from M + escrow). Absent → epoch 0 / legacy behavior. Old service + // builds that lack the endpoints simply make these reject, which is swallowed. + /** Current org epoch via GET /orgs/:orgId/epoch */ + getEpoch?(orgId: string): Promise; + /** All escrow blobs (epoch → blob) via GET /orgs/:orgId/epoch/escrow */ + getEpochEscrows?(orgId: string): Promise>; } /** - * Resolves the encryption key for a project. + * Unwraps the inner blob (KMS already stripped) to recover M, trying the + * current K_local inner key first and falling back to the legacy + * SHA256(userId:orgId) key. Returns whether the legacy key was used, so the + * caller can trigger a transparent re-wrap under K_local. * - * M is stored double-wrapped on disk: KMS_ENCRYPT(AES-GCM(M, innerKey)). - * To unwrap: - * 1. Send the blob to the server's co-decrypt endpoint (strips KMS outer layer) - * 2. Decrypt the inner layer locally with SHA256(userId:orgId) - * 3. Derive the project key via HKDF(M, projectId, orgId) + * Trying K_local-then-legacy (rather than assuming one) also self-heals a + * split-brain: if K_local was minted but key.enc wasn't yet re-wrapped (crash + * mid-migration), the legacy key still opens it and we re-wrap. + */ +function unwrapInner( + innerBlob: string, + orgId: string, + userId: string, + innerAAD: Buffer, +): { masterKey: Buffer; usedLegacy: boolean } { + const kLocal = readLocalRoot(orgId, userId); + if (kLocal) { + try { + return { masterKey: decryptMasterKey(innerBlob, deriveEpochInnerKey(kLocal), innerAAD), usedLegacy: false }; + } catch { + // K_local didn't open it — fall through to the legacy key (split-brain + // or a blob from before this machine minted K_local). + } + } + // Legacy path: the publicly-computable SHA256(userId:orgId). Throws if this + // key doesn't open the blob either — the caller maps that to PERMISSION_DENIED. + const masterKey = decryptMasterKey(innerBlob, deriveWrappingKey(userId, orgId), innerAAD); + return { masterKey, usedLegacy: true }; +} + +/** + * Re-wraps M under K_local (minting one if this machine has none) and persists + * the double-wrapped blob. This is the inner-wrap migration: it moves a blob + * off the legacy SHA256(userId:orgId) key — which the service can recompute — + * onto a device-local secret the service never sees (CAP-58 / K_local). * - * Without the server, the blob on disk is KMS-encrypted garbage — no M, no decrypt. + * Best-effort: a failure here (e.g. server unavailable) leaves the existing + * blob in place; the next run retries. + */ +async function rewrapUnderLocalRoot( + masterKey: Buffer, + orgId: string, + userId: string, + service: KeyServiceOps, +): Promise { + let kLocal = readLocalRoot(orgId, userId); + if (!kLocal) { + kLocal = generateLocalRoot(); + saveLocalRoot(orgId, kLocal, userId); + } + const innerWrapped = encryptMasterKey(masterKey, deriveEpochInnerKey(kLocal), masterKeyAAD(userId, orgId)); + const outerWrapped = await service.wrapOuterLayer(orgId, innerWrapped); + saveMasterKey(orgId, outerWrapped, userId); +} + +/** + * Recovers M from the on-disk double-wrapped blob via the service co-decrypt + * round trip, transparently migrating the inner wrap onto K_local on first + * sight of a legacy blob. Shared by resolveProjectKey and transport. * - * Migration: if the blob is not KMS-wrapped (legacy single-wrap), unwrap with - * the local key, re-wrap with KMS outer, and save. Future runs require the server. + * M is stored double-wrapped: KMS_ENCRYPT(AES-GCM(M, HKDF(K_local))). + * 1. co-decrypt strips the KMS outer layer + * 2. unwrap the inner layer (K_local, falling back to legacy SHA256) + * 3. if legacy was used, re-wrap under K_local for future runs */ -export async function resolveProjectKey( +export async function unwrapMasterKey( orgId: string, - projectId: string, userId: string, service: KeyServiceOps, -): Promise { +): Promise { const encryptedBlob = readMasterKey(orgId, userId); if (!encryptedBlob) { throw new CapyError( @@ -78,30 +141,31 @@ 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 double-wrapped path: co-decrypt strips KMS outer, then inner unwrap. try { const innerBlob = await service.coDecrypt(orgId, encryptedBlob); - const masterKey = decryptMasterKey(innerBlob, innerKey, innerAAD); - return deriveProjectKey(masterKey, projectId, orgId); + const { masterKey, usedLegacy } = unwrapInner(innerBlob, orgId, userId, innerAAD); + if (usedLegacy) { + // Transparent inner-wrap migration onto K_local. Never block resolution + // on it succeeding. + await rewrapUnderLocalRoot(masterKey, orgId, userId, service).catch(() => {}); + } + return masterKey; } catch (err) { // 403 = membership revoked — do NOT fall through to legacy path. - // Re-throw so the caller can clean up appropriately. if (isPermissionDenied(err)) throw err; - - // Network / connectivity failure — re-throw so a transient outage - // doesn't get misclassified as PERMISSION_DENIED and nuke local keys. + // Network / connectivity failure — re-throw so a transient outage isn't + // misclassified as PERMISSION_DENIED and used to nuke local keys. if (isNetworkError(err)) throw err; - - // Other errors (e.g. blob isn't KMS-wrapped) → fall through to legacy + // Other errors (e.g. blob isn't KMS-wrapped) → fall through to legacy. } - // Migration: try legacy single-wrapped (no KMS outer layer) + // Migration: legacy single-wrapped (no KMS outer layer). let masterKey: Buffer; try { - masterKey = decryptMasterKey(encryptedBlob, innerKey, innerAAD); + ({ masterKey } = unwrapInner(encryptedBlob, orgId, userId, innerAAD)); } catch { throw new CapyError( 'You do not have access to this project\'s secrets.\n\n' + @@ -111,25 +175,39 @@ export async function resolveProjectKey( { orgId }, ); } + // Legacy blob unwrapped — re-wrap (KMS outer + K_local inner) for future runs. + await rewrapUnderLocalRoot(masterKey, orgId, userId, service).catch(() => {}); + return masterKey; +} - // Legacy blob unwrapped — re-wrap with KMS outer layer for future runs - try { - 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); - } catch { - // Re-wrap failed (server unavailable?) — proceed with the unwrapped M this time. - // Next run will retry migration. - } - - return deriveProjectKey(masterKey, projectId, orgId); +/** + * Resolves the data-encryption key for a project at the org's CURRENT epoch + * (CAP-58). At epoch 0 the epoch key is M, so this is identical to the legacy + * deriveProjectKey(M, …); at epoch ≥1 it derives from the stored epoch key E_e. + * All sync/encrypt/decrypt sites route through here, so they transparently + * follow epoch bumps. + * + * Reading a snapshot pinned to an OLDER epoch (cross-epoch pinned checkout) + * needs the history walk — see resolveProjectKeyForEpoch (follow-up). + */ +export async function resolveProjectKey( + orgId: string, + projectId: string, + userId: string, + service: KeyServiceOps, +): Promise { + // Lazy import avoids a cycle (epochManager imports keyResolver.unwrapMasterKey). + const { getCurrentEpochKey } = await import('./epochManager'); + const { key } = await getCurrentEpochKey(orgId, userId, service); + return deriveProjectKey(key, projectId, orgId); } /** * Double-wraps M for local storage. - * Inner layer: AES-GCM with SHA256(userId:orgId) - * Outer layer: KMS via service wrap endpoint + * Inner layer: AES-GCM with HKDF(K_local, "capy:inner:epoch") — NOT the legacy + * SHA256(userId:orgId), which the service could recompute. A fresh K_local is + * minted for this machine if none exists. + * Outer layer: KMS via service wrap endpoint. */ export async function wrapAndSaveMasterKey( masterKey: Buffer, @@ -137,11 +215,7 @@ export async function wrapAndSaveMasterKey( userId: string, service: KeyServiceOps, ): Promise { - const innerKey = deriveWrappingKey(userId, orgId); - 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); + await rewrapUnderLocalRoot(masterKey, orgId, userId, service); } /** diff --git a/src/crypto/localKeyRoot.ts b/src/crypto/localKeyRoot.ts new file mode 100644 index 0000000..8553e2b --- /dev/null +++ b/src/crypto/localKeyRoot.ts @@ -0,0 +1,37 @@ +import { randomBytes, hkdfSync } from 'crypto'; + +/** + * K_local — the device-local inner-wrap root (CAP-58 / docs/epoch-key-design.md + * §4, ADR-12 amended 2026-06-09). + * + * Background: the legacy inner-wrap key was SHA256(userId:orgId) — both inputs + * are public identifiers the service knows, so during co-decrypt (where the + * service strips the KMS outer layer) the service could recompute the inner key + * and recover M. The inner layer provided no confidentiality against the + * service. Re-keying it with the device private key would be circular, since + * that key is itself wrapped the same way. + * + * Fix: K_local is 32 bytes of CSPRNG output, generated per machine, stored + * 0600 alongside key.enc, NEVER transmitted and NEVER derivable from any + * identifier. Both local inner wraps are HKDF'd from it. The service can strip + * KMS all day and recover nothing — the inner key never leaves the machine. + * + * This module is pure derivation; storage lives in config/globalConfig.ts. + */ + +const K_LOCAL_LENGTH = 32; + +/** Mints a fresh K_local. */ +export function generateLocalRoot(): Buffer { + return randomBytes(K_LOCAL_LENGTH); +} + +/** Inner wrapping key for key.enc (the epoch-key blob). */ +export function deriveEpochInnerKey(kLocal: Buffer): Buffer { + return Buffer.from(hkdfSync('sha256', kLocal, 'capy:inner', 'capy:inner:epoch', 32)); +} + +/** Inner wrapping key for the device private-key blob. */ +export function deriveDeviceInnerKey(kLocal: Buffer): Buffer { + return Buffer.from(hkdfSync('sha256', kLocal, 'capy:inner', 'capy:inner:device', 32)); +} diff --git a/src/service/serviceClient.ts b/src/service/serviceClient.ts index 90211aa..c6968a4 100644 --- a/src/service/serviceClient.ts +++ b/src/service/serviceClient.ts @@ -479,7 +479,7 @@ export class ServiceClient { } - async listMembers(orgId: string): Promise<{ members: any[] }> { + async listMembers(orgId: string): Promise<{ members: any[]; device_keys?: Record> }> { return this.request('GET', `/orgs/${orgId}/members`); } @@ -487,6 +487,62 @@ export class ServiceClient { return this.request('GET', `/orgs/${orgId}/members/details`); } + // --- Epoch key model (CAP-58) --- + + /** Current org epoch. */ + async getEpoch(orgId: string): Promise<{ epoch: number }> { + return this.request('GET', `/orgs/${orgId}/epoch`); + } + + /** Register this machine's device public key (append-only, idempotent). */ + async registerDevice(orgId: string, publicKey: string): Promise<{ device_id: string }> { + return this.request('POST', `/orgs/${orgId}/devices`, { public_key: publicKey }); + } + + /** This caller's registered devices. */ + async listDevices(orgId: string): Promise<{ devices: Array<{ device_id: string; public_key: string; created_at: string }> }> { + return this.request('GET', `/orgs/${orgId}/devices`); + } + + /** Stage epoch e+1 blobs (kick transaction step 1). */ + async stageEpoch( + orgId: string, + payload: { + epoch: number; + history_blob: string; + project_history_blobs?: Record; + escrow_blob?: string; + sealed_blobs: Array<{ user_id: string; device_id: string; blob: string }>; + }, + ): Promise<{ staged: boolean; epoch: number; uncovered_members: string[] }> { + return this.request('POST', `/orgs/${orgId}/epoch/stage`, payload); + } + + /** Commit a staged epoch bump (kick transaction step 2). */ + async commitEpoch(orgId: string, epoch: number): Promise<{ epoch: number }> { + return this.request('POST', `/orgs/${orgId}/epoch/commit`, { epoch }); + } + + /** History blobs for epoch e (org-wide + per-project). */ + async getEpochHistory(orgId: string, epoch: number): Promise<{ epoch: number; history_blob: string; project_history_blobs: Record }> { + return this.request('GET', `/orgs/${orgId}/epoch/history/${epoch}`); + } + + /** All escrow blobs (epoch → blob); owner-openable. */ + async getEpochEscrows(orgId: string): Promise<{ epoch: number; escrows: Record }> { + return this.request('GET', `/orgs/${orgId}/epoch/escrow`); + } + + /** Owner backfill of missing escrow blobs (fill-only). */ + async backfillEscrows(orgId: string, escrows: Record): Promise<{ written: number[] }> { + return this.request('POST', `/orgs/${orgId}/epoch/escrow`, { escrows }); + } + + /** This caller's pending sealed blobs at the current epoch. */ + async getSealedBlobs(orgId: string): Promise<{ epoch: number; sealed_blobs: Array<{ device_id: string; blob: string }> }> { + return this.request('GET', `/orgs/${orgId}/epoch/sealed`); + } + async kickMember(orgId: string, membershipId: string): Promise { await this.request('DELETE', `/orgs/${orgId}/members/${encodeURIComponent(membershipId)}`); } diff --git a/tests/commands/recoverKdf.test.ts b/tests/commands/recoverKdf.test.ts index 71a87d2..bb3043a 100644 --- a/tests/commands/recoverKdf.test.ts +++ b/tests/commands/recoverKdf.test.ts @@ -15,6 +15,7 @@ import { mock, describe, test, expect, beforeAll, beforeEach, afterAll } from 'b import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; +import { deriveEpochInnerKey } from '../../src/crypto/localKeyRoot'; const tempHome = mkdtempSync(join(tmpdir(), 'capy-recover-kdf-')); mock.module('os', () => { @@ -106,14 +107,18 @@ 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. +// The inner blob is AAD-bound (CAP-57) AND, as of CAP-58, keyed by +// HKDF(K_local) rather than the legacy SHA256(userId:orgId) — recover mints +// K_local and stores it alongside key.enc, so we read it back to unwrap. function writtenMasterKey(): Buffer { const outer = gc.readMasterKey(ORG, FAKE_USER_ID); if (!outer) throw new Error('no key written'); const inner = outer.replace(/^kms:/, ''); + const kLocal = gc.readLocalRoot(ORG, FAKE_USER_ID); + if (!kLocal) throw new Error('no K_local written'); return km.decryptMasterKey( inner, - km.deriveWrappingKey(FAKE_USER_ID, ORG), + deriveEpochInnerKey(kLocal), km.masterKeyAAD(FAKE_USER_ID, ORG), ); } diff --git a/tests/crypto/deviceKey.test.ts b/tests/crypto/deviceKey.test.ts new file mode 100644 index 0000000..0801bb8 --- /dev/null +++ b/tests/crypto/deviceKey.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'bun:test'; +import { + generateDeviceKeyPair, + sealToDevice, + openSealed, +} from '../../src/crypto/deviceKey'; +import { generateEpochKey } from '../../src/crypto/epochCrypto'; +import { randomBytes } from 'crypto'; + +describe('device keypair', () => { + it('generates a 32-byte raw public key (44-char base64) and a private blob', () => { + const kp = generateDeviceKeyPair(); + expect(Buffer.from(kp.publicKeyB64, 'base64')).toHaveLength(32); + expect(kp.publicKeyB64).toMatch(/^[A-Za-z0-9+/]{43}=$/); + expect(kp.privateKeyPkcs8B64.length).toBeGreaterThan(0); + }); + + it('generates distinct keypairs', () => { + expect(generateDeviceKeyPair().publicKeyB64).not.toBe(generateDeviceKeyPair().publicKeyB64); + }); +}); + +describe('seal / open', () => { + it('round-trips an epoch key to the holder of the device private key', () => { + const kp = generateDeviceKeyPair(); + const epochKey = generateEpochKey(); + const sealed = sealToDevice(kp.publicKeyB64, epochKey); + const opened = openSealed(kp.privateKeyPkcs8B64, sealed); + expect(opened.equals(epochKey)).toBe(true); + }); + + it('a different device cannot open the sealed blob', () => { + const alice = generateDeviceKeyPair(); + const bob = generateDeviceKeyPair(); + const sealed = sealToDevice(alice.publicKeyB64, generateEpochKey()); + expect(() => openSealed(bob.privateKeyPkcs8B64, sealed)).toThrow(); + }); + + it('tampering with the sealed blob fails AEAD auth', () => { + const kp = generateDeviceKeyPair(); + const sealed = sealToDevice(kp.publicKeyB64, generateEpochKey()); + const buf = Buffer.from(sealed, 'base64'); + buf[buf.length - 1] ^= 0xff; // flip a tag bit + const tampered = buf.toString('base64'); + expect(() => openSealed(kp.privateKeyPkcs8B64, tampered)).toThrow(); + }); + + it('each seal uses a fresh ephemeral key (ciphertexts differ)', () => { + const kp = generateDeviceKeyPair(); + const key = generateEpochKey(); + expect(sealToDevice(kp.publicKeyB64, key)).not.toBe(sealToDevice(kp.publicKeyB64, key)); + }); + + it('seals arbitrary payloads, not just 32-byte keys', () => { + const kp = generateDeviceKeyPair(); + const payload = randomBytes(100); + expect(openSealed(kp.privateKeyPkcs8B64, sealToDevice(kp.publicKeyB64, payload)).equals(payload)).toBe(true); + }); +}); diff --git a/tests/crypto/deviceManager.test.ts b/tests/crypto/deviceManager.test.ts new file mode 100644 index 0000000..91e3421 --- /dev/null +++ b/tests/crypto/deviceManager.test.ts @@ -0,0 +1,93 @@ +import { mock, describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; + +const tempHome = mkdtempSync(join(require('os').tmpdir(), 'capy-devmgr-')); +mock.module('os', () => { + const actual = require('os'); + return { ...actual, homedir: () => tempHome }; +}); + +afterAll(() => { mock.restore(); rmSync(tempHome, { recursive: true, force: true }); }); + +let dm: typeof import('../../src/crypto/deviceManager'); +let openSealed: typeof import('../../src/crypto/deviceKey').openSealed; +let sealToDevice: typeof import('../../src/crypto/deviceKey').sealToDevice; +let gc: typeof import('../../src/config/globalConfig'); + +beforeAll(async () => { + dm = await import('../../src/crypto/deviceManager'); + const dk = await import('../../src/crypto/deviceKey'); + openSealed = dk.openSealed; + sealToDevice = dk.sealToDevice; + gc = await import('../../src/config/globalConfig'); +}); + +const ORG = 'org_dev'; +const USER = 'user_dev'; + +/** + * Mock service: KMS outer layer is passthrough (wrapOuterLayer returns plaintext, + * coDecrypt returns it back). Device registration returns a stable id. + */ +function mockService() { + let counter = 0; + const registered: string[] = []; + return { + coDecrypt: async (_o: string, ct: string) => ct, + wrapOuterLayer: async (_o: string, pt: string) => pt, + registerDevice: async (_o: string, pk: string) => { + registered.push(pk); + return { device_id: `dev_${++counter}` }; + }, + registered, + }; +} + +beforeEach(() => { + rmSync(join(tempHome, '.capy'), { recursive: true, force: true }); +}); + +describe('deviceManager', () => { + it('mints, double-wraps, and registers a device keypair', async () => { + const svc = mockService(); + const deviceId = await dm.ensureDeviceKey(ORG, USER, svc); + expect(deviceId).toBe('dev_1'); + expect(svc.registered.length).toBe(1); + + const record = gc.readDeviceKeyRecord(ORG, USER); + expect(record).not.toBeNull(); + expect(record!.public_key).toBe(svc.registered[0]); + // Private key is stored wrapped, never in plaintext. + expect(record!.encrypted_private_key).not.toContain(record!.public_key); + // K_local was minted alongside. + expect(gc.hasLocalRoot(ORG, USER)).toBe(true); + }); + + it('is idempotent — second call does not mint a new keypair', async () => { + const svc = mockService(); + await dm.ensureDeviceKey(ORG, USER, svc); + const firstPub = gc.readDeviceKeyRecord(ORG, USER)!.public_key; + await dm.ensureDeviceKey(ORG, USER, svc); + expect(gc.readDeviceKeyRecord(ORG, USER)!.public_key).toBe(firstPub); + }); + + it('loadDevicePrivateKey recovers a key that opens blobs sealed to the public key', async () => { + const svc = mockService(); + await dm.ensureDeviceKey(ORG, USER, svc); + const pub = gc.readDeviceKeyRecord(ORG, USER)!.public_key; + + const priv = await dm.loadDevicePrivateKey(ORG, USER, svc); + expect(priv).not.toBeNull(); + + // A blob sealed to the registered public key opens with the recovered key. + const secret = Buffer.from('an-epoch-key-32-bytes-padding...'); + const sealed = sealToDevice(pub, secret); + expect(openSealed(priv!, sealed).equals(secret)).toBe(true); + }); + + it('loadDevicePrivateKey returns null when no device exists', async () => { + const svc = mockService(); + expect(await dm.loadDevicePrivateKey(ORG, USER, svc)).toBeNull(); + }); +}); diff --git a/tests/crypto/epochCrypto.test.ts b/tests/crypto/epochCrypto.test.ts new file mode 100644 index 0000000..eb5e01e --- /dev/null +++ b/tests/crypto/epochCrypto.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'bun:test'; +import { + generateEpochKey, + deriveEpoch0, + deriveProjectKey, + snapshotAAD, + wrapHistoryBlob, + unwrapHistoryBlob, + wrapProjectHistoryBlob, + unwrapProjectHistoryBlob, + wrapEscrowBlob, + unwrapEscrowBlob, +} from '../../src/crypto/epochCrypto'; +import { randomBytes } from 'crypto'; + +const ORG = 'org_test'; +const PROJ_A = 'proj_a'; +const PROJ_B = 'proj_b'; + +describe('epoch key generation', () => { + it('generates 32-byte keys', () => { + expect(generateEpochKey()).toHaveLength(32); + }); + + it('generates independent keys (no derivation relationship)', () => { + const a = generateEpochKey(); + const b = generateEpochKey(); + expect(a.equals(b)).toBe(false); + }); + + it('deriveEpoch0 IS M (epoch 0 = legacy M path, CAP-58 migration decision)', () => { + const M = randomBytes(32); + // E_0 == M so legacy ciphertext under deriveProjectKey(M, …) reads as + // epoch 0 with no re-encryption. + expect(deriveEpoch0(M).equals(M)).toBe(true); + expect(deriveEpoch0(M)).toHaveLength(32); + // Returns a copy — mutating it must not corrupt M. + const e0 = deriveEpoch0(M); + e0[0] ^= 0xff; + expect(deriveEpoch0(M).equals(M)).toBe(true); + }); +}); + +describe('deriveProjectKey', () => { + it('is deterministic and project-scoped', () => { + const E = generateEpochKey(); + expect(deriveProjectKey(E, PROJ_A, ORG)).toBe(deriveProjectKey(E, PROJ_A, ORG)); + expect(deriveProjectKey(E, PROJ_A, ORG)).not.toBe(deriveProjectKey(E, PROJ_B, ORG)); + }); + + it('different epoch keys yield different project keys', () => { + const e1 = generateEpochKey(); + const e2 = generateEpochKey(); + expect(deriveProjectKey(e1, PROJ_A, ORG)).not.toBe(deriveProjectKey(e2, PROJ_A, ORG)); + }); +}); + +describe('history chain (org-wide)', () => { + it('round-trips E_3 -> E_2 -> E_1 by walking backward', () => { + const e1 = generateEpochKey(); + const e2 = generateEpochKey(); + const e3 = generateEpochKey(); + const blob2 = wrapHistoryBlob(e1, e2); // transition 1->2 + const blob3 = wrapHistoryBlob(e2, e3); // transition 2->3 + + // Holding e3, walk to e2 then e1. + const recovered2 = unwrapHistoryBlob(blob3, e3); + expect(recovered2.equals(e2)).toBe(true); + const recovered1 = unwrapHistoryBlob(blob2, recovered2); + expect(recovered1.equals(e1)).toBe(true); + }); + + it('forward walk is impossible by construction (no api yields E_{e+1} from E_e)', () => { + // The only history primitive recovers the PREVIOUS key from the NEW key. + // Given e2 and the 2->3 blob, you cannot get e3 (you ARE expected to hold + // e3 to open it). Confirm the blob does not open with the old key. + const e2 = generateEpochKey(); + const e3 = generateEpochKey(); + const blob3 = wrapHistoryBlob(e2, e3); + expect(() => unwrapHistoryBlob(blob3, e2)).toThrow(); + }); +}); + +describe('history chain (per-project confinement)', () => { + it('project A chain never exposes the org-wide E or project B keys', () => { + const e1 = generateEpochKey(); + const e2 = generateEpochKey(); + const blobA = wrapProjectHistoryBlob(e1, e2, PROJ_A, ORG); + + const newDerivedA = deriveProjectKey(e2, PROJ_A, ORG); + const recoveredA = unwrapProjectHistoryBlob(blobA, newDerivedA); + // Recovers project A's PREVIOUS derived key — equals deriveProjectKey(e1, A). + expect(recoveredA).toBe(deriveProjectKey(e1, PROJ_A, ORG)); + + // The recovered value is a project-A derived key, NOT the org-wide e1 and + // NOT project B's key. + expect(recoveredA).not.toBe(e1.toString('hex')); + expect(recoveredA).not.toBe(deriveProjectKey(e1, PROJ_B, ORG)); + + // Project A's blob cannot be opened with project B's derived key. + const newDerivedB = deriveProjectKey(e2, PROJ_B, ORG); + expect(() => unwrapProjectHistoryBlob(blobA, newDerivedB)).toThrow(); + }); +}); + +describe('escrow round-trip', () => { + it('M opens every epoch escrow; wrong M fails GCM auth', () => { + const M = randomBytes(32); + const wrongM = randomBytes(32); + const e1 = generateEpochKey(); + const e2 = generateEpochKey(); + const blob1 = wrapEscrowBlob(M, 1, e1); + const blob2 = wrapEscrowBlob(M, 2, e2); + + expect(unwrapEscrowBlob(blob1, M, 1).equals(e1)).toBe(true); + expect(unwrapEscrowBlob(blob2, M, 2).equals(e2)).toBe(true); + expect(() => unwrapEscrowBlob(blob1, wrongM, 1)).toThrow(); + }); + + it('escrow blob for one epoch does not open under a different epoch number', () => { + const M = randomBytes(32); + const e1 = generateEpochKey(); + const blob1 = wrapEscrowBlob(M, 1, e1); + // The epoch is bound into the HKDF info, so the key differs. + expect(() => unwrapEscrowBlob(blob1, M, 2)).toThrow(); + }); +}); + +describe('snapshot AAD binding', () => { + it('differs across epoch, project, and org', () => { + const base = snapshotAAD(ORG, PROJ_A, 1).toString(); + expect(snapshotAAD(ORG, PROJ_A, 2).toString()).not.toBe(base); + expect(snapshotAAD(ORG, PROJ_B, 1).toString()).not.toBe(base); + expect(snapshotAAD('other', PROJ_A, 1).toString()).not.toBe(base); + }); +}); diff --git a/tests/crypto/epochLifecycle.test.ts b/tests/crypto/epochLifecycle.test.ts new file mode 100644 index 0000000..dddc2ea --- /dev/null +++ b/tests/crypto/epochLifecycle.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'bun:test'; +import { randomBytes } from 'crypto'; +import { + generateEpochKey, + deriveEpoch0, + deriveProjectKey, + wrapHistoryBlob, + unwrapHistoryBlob, + wrapEscrowBlob, + unwrapEscrowBlob, +} from '../../src/crypto/epochCrypto'; +import { generateDeviceKeyPair, sealToDevice, openSealed } from '../../src/crypto/deviceKey'; +import { Encryptor } from '../../src/crypto/encryptor'; + +/** + * Crypto-layer proxy for e2e scenarios 2 (kick-blocks-future) and 5 (owner + * break-glass). Composes the modules into the full kick lifecycle without any + * service or I/O, so the cryptographic guarantee is asserted directly. + */ +describe('epoch lifecycle: migration -> kick -> revocation -> break-glass', () => { + const ORG = 'org_life'; + const PROJ = 'proj_life'; + + it('remaining member decrypts across the kick; exfiltrated old key cannot', () => { + const M = randomBytes(32); + + // --- Epoch 0 (migration): data encrypted under E_0 = HKDF(M) --- + const e0 = deriveEpoch0(M); + const k0 = deriveProjectKey(e0, PROJ, ORG); + const v1 = JSON.stringify({ API_KEY: 'v1-secret' }); + const blobV1 = Encryptor.encrypt(v1, k0); + + // Member B is on the org at epoch 0 and (in scenario 2) exfiltrates e0. + const exfiltratedByB = Buffer.from(e0); // everything B ever held + const cRemaining = generateDeviceKeyPair(); // member C stays + + // --- Kick B: mint fresh E_1, chain, escrow, seal to remaining member C --- + const e1 = generateEpochKey(); + const historyBlob1 = wrapHistoryBlob(e0, e1); // 0 -> 1 + const escrow1 = wrapEscrowBlob(M, 1, e1); + const sealedToC = sealToDevice(cRemaining.publicKeyB64, e1); + + // --- A pushes v4 under E_1 --- + const k1 = deriveProjectKey(e1, PROJ, ORG); + const v4 = JSON.stringify({ API_KEY: 'v4-secret-post-kick' }); + const blobV4 = Encryptor.encrypt(v4, k1); + + // === Guarantee (scenario 2): B's exfiltrated key material cannot decrypt + // v4, by ANY path. E_1 is fresh randomness unreachable from e0. === + const everyKeyBHeld = [ + exfiltratedByB.toString('hex'), // e0 raw + deriveProjectKey(exfiltratedByB, PROJ, ORG), // derived k0 + ]; + for (const stolen of everyKeyBHeld) { + expect(Encryptor.canDecrypt(blobV4, stolen)).toBe(false); + } + // And there is no function from e0 to e1. + expect(() => unwrapHistoryBlob(historyBlob1, e0)).toThrow(); + + // === Remaining member C: unseal E_1, decrypt v4, walk back to read v1 === + const cE1 = openSealed(cRemaining.privateKeyPkcs8B64, sealedToC); + expect(cE1.equals(e1)).toBe(true); + expect(Encryptor.decrypt(blobV4, deriveProjectKey(cE1, PROJ, ORG))).toBe(v4); + + const cE0 = unwrapHistoryBlob(historyBlob1, cE1); // walk 1 -> 0 + expect(Encryptor.decrypt(blobV1, deriveProjectKey(cE0, PROJ, ORG))).toBe(v1); + + // === Owner break-glass (scenario 5): seed -> M -> escrow -> every epoch, + // fully offline. === + const ownerE1 = unwrapEscrowBlob(escrow1, M, 1); + expect(Encryptor.decrypt(blobV4, deriveProjectKey(ownerE1, PROJ, ORG))).toBe(v4); + const ownerE0 = deriveEpoch0(M); // epoch 0 recoverable directly from M + expect(Encryptor.decrypt(blobV1, deriveProjectKey(ownerE0, PROJ, ORG))).toBe(v1); + }); +}); diff --git a/tests/crypto/keyResolver.test.ts b/tests/crypto/keyResolver.test.ts index 8e9bc15..c091e7d 100644 --- a/tests/crypto/keyResolver.test.ts +++ b/tests/crypto/keyResolver.test.ts @@ -101,6 +101,35 @@ describe('KeyResolver', () => { .rejects.toThrow('You do not have access'); }); + it('migrates a legacy SHA256-wrapped blob onto K_local (CAP-58)', async () => { + const km = await import('../../src/crypto/keyManager'); + const gc = await import('../../src/config/globalConfig'); + const { deriveEpochInnerKey } = await import('../../src/crypto/localKeyRoot'); + const mUser = 'user_mig'; + const mOrg = 'org_mig'; + + // Legacy single-wrapped blob keyed by SHA256(userId:orgId); no K_local yet. + const legacyInner = km.deriveWrappingKey(mUser, mOrg); + saveMasterKey(mOrg, km.encryptMasterKey(masterKey, legacyInner, km.masterKeyAAD(mUser, mOrg)), mUser); + expect(gc.hasLocalRoot(mOrg, mUser)).toBe(false); + + // Resolve via the double-wrap path. Passthrough KMS: inner blob == stored blob. + const ops = { + coDecrypt: async (_o: string, ct: string) => ct, + wrapOuterLayer: async (_o: string, pt: string) => pt, + }; + const key = await resolveProjectKey(mOrg, projectId, mUser, ops); + expect(key).toBe(deriveProjectKey(masterKey, projectId, mOrg)); + + // Migration happened: K_local now exists and the stored blob opens with + // it — and NO LONGER with the legacy SHA256 key. + expect(gc.hasLocalRoot(mOrg, mUser)).toBe(true); + const kLocal = gc.readLocalRoot(mOrg, mUser)!; + const stored = gc.readMasterKey(mOrg, mUser)!; + expect(km.decryptMasterKey(stored, deriveEpochInnerKey(kLocal), km.masterKeyAAD(mUser, mOrg)).equals(masterKey)).toBe(true); + expect(() => km.decryptMasterKey(stored, legacyInner, km.masterKeyAAD(mUser, mOrg))).toThrow(); + }); + it('should re-throw 403 instead of falling through to legacy', async () => { // Setup: save a valid single-wrapped key that legacy WOULD decrypt const wrappingKey = deriveWrappingKey(userId, orgId); diff --git a/tests/crypto/localKeyRoot.test.ts b/tests/crypto/localKeyRoot.test.ts new file mode 100644 index 0000000..c9d65d6 --- /dev/null +++ b/tests/crypto/localKeyRoot.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'bun:test'; +import { createHash } from 'crypto'; +import { + generateLocalRoot, + deriveEpochInnerKey, + deriveDeviceInnerKey, +} from '../../src/crypto/localKeyRoot'; +import { encryptMasterKey, decryptMasterKey } from '../../src/crypto/keyManager'; +import { generateEpochKey } from '../../src/crypto/epochCrypto'; + +describe('K_local generation', () => { + it('is 32 random bytes', () => { + expect(generateLocalRoot()).toHaveLength(32); + expect(generateLocalRoot().equals(generateLocalRoot())).toBe(false); + }); +}); + +describe('inner key derivation', () => { + it('epoch and device inner keys are distinct and 32 bytes', () => { + const k = generateLocalRoot(); + const epochKey = deriveEpochInnerKey(k); + const deviceKey = deriveDeviceInnerKey(k); + expect(epochKey).toHaveLength(32); + expect(deviceKey).toHaveLength(32); + expect(epochKey.equals(deviceKey)).toBe(false); + }); + + it('derivation is deterministic for a given K_local', () => { + const k = generateLocalRoot(); + expect(deriveEpochInnerKey(k).equals(deriveEpochInnerKey(k))).toBe(true); + }); + + it('different K_local yields different inner keys', () => { + expect(deriveEpochInnerKey(generateLocalRoot()).equals(deriveEpochInnerKey(generateLocalRoot()))).toBe(false); + }); +}); + +describe('regression guard: inner key NOT derivable from public identifiers', () => { + // The whole point of K_local: SHA256(userId:orgId) — what the legacy wrap + // used — must NOT unwrap a K_local-wrapped blob. This is the canonical guard + // for the flaw that motivated CAP-58's amendment. + it('a K_local-wrapped epoch key does not open with SHA256(userId:orgId)', () => { + const userId = 'user_1'; + const orgId = 'org_1'; + const kLocal = generateLocalRoot(); + const epochKey = generateEpochKey(); + + const innerKey = deriveEpochInnerKey(kLocal); + const wrapped = encryptMasterKey(epochKey, innerKey); + + // The publicly-computable legacy key must fail. + const legacyKey = createHash('sha256').update(`${userId}:${orgId}`).digest(); + expect(() => decryptMasterKey(wrapped, legacyKey)).toThrow(); + + // The genuine K_local-derived key works. + expect(decryptMasterKey(wrapped, innerKey).equals(epochKey)).toBe(true); + }); +}); + +describe('service-view blindness', () => { + // Everything the service ever sees on the inner-wrap path: the KMS-stripped + // inner blob, the userId, and the orgId. None of it, alone or combined, + // recovers the wrapped key — because the inner key is HKDF(K_local) and + // K_local never leaves the machine. + it('inner blob + userId + orgId cannot recover the wrapped key', () => { + const userId = 'user_42'; + const orgId = 'org_42'; + const kLocal = generateLocalRoot(); // the service NEVER sees this + const epochKey = generateEpochKey(); + const wrapped = encryptMasterKey(epochKey, deriveEpochInnerKey(kLocal)); + + // Candidate keys the service could try from public material: + const candidates = [ + createHash('sha256').update(`${userId}:${orgId}`).digest(), + createHash('sha256').update(`${orgId}:${userId}`).digest(), + createHash('sha256').update(orgId).digest(), + createHash('sha256').update(userId).digest(), + Buffer.alloc(32), + ]; + for (const key of candidates) { + expect(() => decryptMasterKey(wrapped, key)).toThrow(); + } + }); +});