Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/commands/capyCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/commands/checkoutCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 2 additions & 0 deletions src/commands/editCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/commands/exportCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ async function resolveEnv(devMode: boolean): Promise<ResolvedEnv> {
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);
Expand Down
35 changes: 13 additions & 22 deletions src/commands/inviteCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/commands/kickCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
2 changes: 2 additions & 0 deletions src/commands/pushCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!,
Expand Down
18 changes: 17 additions & 1 deletion src/commands/redeemCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down
2 changes: 2 additions & 0 deletions src/commands/runCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/commands/statusCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!,
Expand Down
29 changes: 12 additions & 17 deletions src/commands/transportCommand.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions src/config/globalConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<orgId>/users/<userId>/, 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');
}
Expand Down
Loading
Loading