From 75eda901ccb1a94737ad8de22685b58cf51b5a94 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 28 Feb 2026 02:27:34 -0800 Subject: [PATCH 01/19] feat(schema): add RecipientSchema and recipients field to EncryptionSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M11 Locksmith Task 11.3 — extend manifest schema for multi-recipient envelope encryption. RecipientSchema validates label, wrappedDek, nonce, tag, and optional kekType. Manifest constructor deep-copies recipients for immutability. --- src/domain/schemas/ManifestSchema.d.ts | 10 ++ src/domain/schemas/ManifestSchema.js | 10 ++ src/domain/value-objects/Manifest.d.ts | 10 ++ src/domain/value-objects/Manifest.js | 4 +- .../domain/schemas/RecipientSchema.test.js | 118 ++++++++++++++++++ .../domain/value-objects/Manifest.test.js | 78 ++++++++++++ 6 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 test/unit/domain/schemas/RecipientSchema.test.js diff --git a/src/domain/schemas/ManifestSchema.d.ts b/src/domain/schemas/ManifestSchema.d.ts index 090a85e..23d7c7a 100644 --- a/src/domain/schemas/ManifestSchema.d.ts +++ b/src/domain/schemas/ManifestSchema.d.ts @@ -24,6 +24,15 @@ export declare const KdfSchema: z.ZodObject<{ keyLength: z.ZodDefault; }>; +/** Validates a single recipient entry in an envelope-encrypted manifest. */ +export declare const RecipientSchema: z.ZodObject<{ + label: z.ZodString; + wrappedDek: z.ZodString; + nonce: z.ZodString; + tag: z.ZodString; + kekType: z.ZodOptional; +}>; + /** Validates the encryption metadata attached to an encrypted manifest. */ export declare const EncryptionSchema: z.ZodObject<{ algorithm: z.ZodString; @@ -31,6 +40,7 @@ export declare const EncryptionSchema: z.ZodObject<{ tag: z.ZodString; encrypted: z.ZodDefault; kdf: z.ZodOptional; + recipients: z.ZodOptional>; }>; /** Validates compression metadata. */ diff --git a/src/domain/schemas/ManifestSchema.js b/src/domain/schemas/ManifestSchema.js index 8f90c8b..44a1a13 100644 --- a/src/domain/schemas/ManifestSchema.js +++ b/src/domain/schemas/ManifestSchema.js @@ -24,6 +24,15 @@ export const KdfSchema = z.object({ keyLength: z.number().int().positive().default(32), }); +/** Validates a single recipient entry in an envelope-encrypted manifest. */ +export const RecipientSchema = z.object({ + label: z.string().min(1), + wrappedDek: z.string().min(1), + nonce: z.string().min(1), + tag: z.string().min(1), + kekType: z.string().optional(), +}); + /** Validates the encryption metadata attached to an encrypted manifest. */ export const EncryptionSchema = z.object({ algorithm: z.string(), @@ -31,6 +40,7 @@ export const EncryptionSchema = z.object({ tag: z.string(), encrypted: z.boolean().default(true), kdf: KdfSchema.optional(), + recipients: z.array(RecipientSchema).optional(), }); /** Validates compression metadata. */ diff --git a/src/domain/value-objects/Manifest.d.ts b/src/domain/value-objects/Manifest.d.ts index ecc9b9a..2247fbe 100644 --- a/src/domain/value-objects/Manifest.d.ts +++ b/src/domain/value-objects/Manifest.d.ts @@ -11,6 +11,15 @@ export interface KdfParams { keyLength: number; } +/** A single recipient entry in an envelope-encrypted manifest. */ +export interface RecipientEntry { + label: string; + wrappedDek: string; + nonce: string; + tag: string; + kekType?: string; +} + /** AES-256-GCM encryption metadata attached to an encrypted manifest. */ export interface EncryptionMeta { algorithm: string; @@ -18,6 +27,7 @@ export interface EncryptionMeta { tag: string; encrypted: boolean; kdf?: KdfParams; + recipients?: RecipientEntry[]; } /** Compression metadata. */ diff --git a/src/domain/value-objects/Manifest.js b/src/domain/value-objects/Manifest.js index 1fb0159..3521731 100644 --- a/src/domain/value-objects/Manifest.js +++ b/src/domain/value-objects/Manifest.js @@ -27,7 +27,9 @@ export default class Manifest { this.filename = data.filename; this.size = data.size; this.chunks = data.chunks.map((c) => new Chunk(c)); - this.encryption = data.encryption ? { ...data.encryption } : undefined; + this.encryption = data.encryption + ? { ...data.encryption, recipients: data.encryption.recipients?.map((r) => ({ ...r })) } + : undefined; this.compression = data.compression ? { ...data.compression } : undefined; this.chunking = data.chunking ? { strategy: data.chunking.strategy, params: { ...data.chunking.params } } diff --git a/test/unit/domain/schemas/RecipientSchema.test.js b/test/unit/domain/schemas/RecipientSchema.test.js new file mode 100644 index 0000000..6138727 --- /dev/null +++ b/test/unit/domain/schemas/RecipientSchema.test.js @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { RecipientSchema, EncryptionSchema } from '../../../../src/domain/schemas/ManifestSchema.js'; + +// --------------------------------------------------------------------------- +// RecipientSchema +// --------------------------------------------------------------------------- +describe('RecipientSchema', () => { + const validRecipient = () => ({ + label: 'alice', + wrappedDek: 'AAAA', + nonce: 'BBBB', + tag: 'CCCC', + }); + + it('accepts a valid recipient entry', () => { + const result = RecipientSchema.safeParse(validRecipient()); + expect(result.success).toBe(true); + }); + + it('accepts optional kekType', () => { + const result = RecipientSchema.safeParse({ ...validRecipient(), kekType: 'raw' }); + expect(result.success).toBe(true); + expect(result.data.kekType).toBe('raw'); + }); + + it('rejects missing label', () => { + const { label, ...rest } = validRecipient(); + expect(RecipientSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects empty label', () => { + expect(RecipientSchema.safeParse({ ...validRecipient(), label: '' }).success).toBe(false); + }); + + it('rejects missing wrappedDek', () => { + const { wrappedDek, ...rest } = validRecipient(); + expect(RecipientSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects empty wrappedDek', () => { + expect(RecipientSchema.safeParse({ ...validRecipient(), wrappedDek: '' }).success).toBe(false); + }); + + it('rejects missing nonce', () => { + const { nonce, ...rest } = validRecipient(); + expect(RecipientSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects empty nonce', () => { + expect(RecipientSchema.safeParse({ ...validRecipient(), nonce: '' }).success).toBe(false); + }); + + it('rejects missing tag', () => { + const { tag, ...rest } = validRecipient(); + expect(RecipientSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects empty tag', () => { + expect(RecipientSchema.safeParse({ ...validRecipient(), tag: '' }).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// EncryptionSchema — recipients integration +// --------------------------------------------------------------------------- +describe('EncryptionSchema — recipients', () => { + const baseEncryption = () => ({ + algorithm: 'aes-256-gcm', + nonce: 'bm9uY2U=', + tag: 'dGFn', + encrypted: true, + }); + + const validRecipient = () => ({ + label: 'alice', + wrappedDek: 'AAAA', + nonce: 'BBBB', + tag: 'CCCC', + }); + + it('backward compat: no recipients field → valid', () => { + const result = EncryptionSchema.safeParse(baseEncryption()); + expect(result.success).toBe(true); + expect(result.data.recipients).toBeUndefined(); + }); + + it('accepts valid recipients array', () => { + const data = { ...baseEncryption(), recipients: [validRecipient()] }; + const result = EncryptionSchema.safeParse(data); + expect(result.success).toBe(true); + expect(result.data.recipients).toHaveLength(1); + }); + + it('accepts multiple recipients', () => { + const data = { + ...baseEncryption(), + recipients: [ + { ...validRecipient(), label: 'alice' }, + { ...validRecipient(), label: 'bob' }, + ], + }; + const result = EncryptionSchema.safeParse(data); + expect(result.success).toBe(true); + expect(result.data.recipients).toHaveLength(2); + }); + + it('accepts empty recipients array', () => { + const data = { ...baseEncryption(), recipients: [] }; + const result = EncryptionSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it('rejects recipients with invalid entry', () => { + const data = { ...baseEncryption(), recipients: [{ label: '' }] }; + const result = EncryptionSchema.safeParse(data); + expect(result.success).toBe(false); + }); +}); diff --git a/test/unit/domain/value-objects/Manifest.test.js b/test/unit/domain/value-objects/Manifest.test.js index 2227003..de93794 100644 --- a/test/unit/domain/value-objects/Manifest.test.js +++ b/test/unit/domain/value-objects/Manifest.test.js @@ -179,6 +179,84 @@ describe('Manifest – backward compatibility (chunking)', () => { // eslint-dis }); }); +// --------------------------------------------------------------------------- +// Recipients field +// --------------------------------------------------------------------------- +describe('Manifest – recipients', () => { + it('validates manifest with recipients in encryption', () => { + const data = { + ...validManifestData(), + encryption: { + algorithm: 'aes-256-gcm', + nonce: 'bm9uY2U=', + tag: 'dGFn', + encrypted: true, + recipients: [ + { label: 'alice', wrappedDek: 'AAAA', nonce: 'BBBB', tag: 'CCCC' }, + ], + }, + }; + const m = new Manifest(data); + expect(m.encryption.recipients).toHaveLength(1); + expect(m.encryption.recipients[0].label).toBe('alice'); + }); + + it('toJSON includes recipients when present', () => { + const data = { + ...validManifestData(), + encryption: { + algorithm: 'aes-256-gcm', + nonce: 'bm9uY2U=', + tag: 'dGFn', + encrypted: true, + recipients: [ + { label: 'alice', wrappedDek: 'AAAA', nonce: 'BBBB', tag: 'CCCC' }, + { label: 'bob', wrappedDek: 'DDDD', nonce: 'EEEE', tag: 'FFFF' }, + ], + }, + }; + const m = new Manifest(data); + const json = m.toJSON(); + expect(json.encryption.recipients).toHaveLength(2); + expect(json.encryption.recipients[0].label).toBe('alice'); + expect(json.encryption.recipients[1].label).toBe('bob'); + }); + + it('deep-copies recipients so source mutation does not affect manifest', () => { + const recipients = [ + { label: 'alice', wrappedDek: 'AAAA', nonce: 'BBBB', tag: 'CCCC' }, + ]; + const data = { + ...validManifestData(), + encryption: { + algorithm: 'aes-256-gcm', + nonce: 'bm9uY2U=', + tag: 'dGFn', + encrypted: true, + recipients, + }, + }; + const m = new Manifest(data); + // Mutate source + recipients[0].label = 'eve'; + expect(m.encryption.recipients[0].label).toBe('alice'); + }); + + it('allows encryption without recipients (backward compat)', () => { + const data = { + ...validManifestData(), + encryption: { + algorithm: 'aes-256-gcm', + nonce: 'bm9uY2U=', + tag: 'dGFn', + encrypted: true, + }, + }; + const m = new Manifest(data); + expect(m.encryption.recipients).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // Chunking value object – access and freezing // --------------------------------------------------------------------------- From 6b68438331653924e8b270eba6043830e8984950 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 28 Feb 2026 02:30:15 -0800 Subject: [PATCH 02/19] feat(envelope): DEK/KEK multi-recipient encryption model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M11 Locksmith Task 11.1 — store() accepts recipients array for envelope encryption. A random DEK encrypts content; each recipient's KEK wraps the DEK. restoreStream() scans recipient entries to unwrap the DEK. Backward compatible with legacy single-key manifests. --- index.d.ts | 2 + index.js | 3 +- src/domain/services/CasService.d.ts | 1 + src/domain/services/CasService.js | 149 +++++++- .../services/CasService.envelope.test.js | 326 ++++++++++++++++++ 5 files changed, 468 insertions(+), 13 deletions(-) create mode 100644 test/unit/domain/services/CasService.envelope.test.js diff --git a/index.d.ts b/index.d.ts index c748442..b090712 100644 --- a/index.d.ts +++ b/index.d.ts @@ -302,6 +302,7 @@ export default class ContentAddressableStore { passphrase?: string; kdfOptions?: Omit; compression?: { algorithm: "gzip" }; + recipients?: Array<{ label: string; key: Buffer }>; }): Promise; store(options: { @@ -312,6 +313,7 @@ export default class ContentAddressableStore { passphrase?: string; kdfOptions?: Omit; compression?: { algorithm: "gzip" }; + recipients?: Array<{ label: string; key: Buffer }>; }): Promise; restoreFile(options: { diff --git a/index.js b/index.js index 7bbde7b..dfb01fa 100644 --- a/index.js +++ b/index.js @@ -267,7 +267,7 @@ export default class ContentAddressableStore { * @param {{ algorithm: 'gzip' }} [options.compression] - Enable compression. * @returns {Promise} The resulting manifest. */ - async storeFile({ filePath, slug, filename, encryptionKey, passphrase, kdfOptions, compression }) { + async storeFile({ filePath, slug, filename, encryptionKey, passphrase, kdfOptions, compression, recipients }) { const source = createReadStream(filePath); const service = await this.#getService(); return await service.store({ @@ -278,6 +278,7 @@ export default class ContentAddressableStore { passphrase, kdfOptions, compression, + recipients, }); } diff --git a/src/domain/services/CasService.d.ts b/src/domain/services/CasService.d.ts index 7694e0c..7416607 100644 --- a/src/domain/services/CasService.d.ts +++ b/src/domain/services/CasService.d.ts @@ -112,6 +112,7 @@ export default class CasService { passphrase?: string; kdfOptions?: Omit; compression?: { algorithm: "gzip" }; + recipients?: Array<{ label: string; key: Buffer }>; }): Promise; createTree(options: { manifest: Manifest }): Promise; diff --git a/src/domain/services/CasService.js b/src/domain/services/CasService.js index ca47406..db02d5d 100644 --- a/src/domain/services/CasService.js +++ b/src/domain/services/CasService.js @@ -190,6 +190,103 @@ export default class CasService { } } + /** + * Wraps a DEK with a KEK using AES-256-GCM. + * @private + * @param {Buffer} dek - 32-byte data encryption key. + * @param {Buffer} kek - 32-byte key encryption key. + * @returns {{ wrappedDek: string, nonce: string, tag: string }} + */ + _wrapDek(dek, kek) { + const { buf, meta } = this.crypto.encryptBuffer(dek, kek); + return { + wrappedDek: buf.toString('base64'), + nonce: meta.nonce, + tag: meta.tag, + }; + } + + /** + * Unwraps a DEK from a recipient entry using the given KEK. + * @private + * @param {{ wrappedDek: string, nonce: string, tag: string }} recipientEntry + * @param {Buffer} kek - 32-byte key encryption key. + * @returns {Buffer} The unwrapped DEK. + * @throws {CasError} DEK_UNWRAP_FAILED if decryption fails. + */ + _unwrapDek(recipientEntry, kek) { + try { + const ciphertext = Buffer.from(recipientEntry.wrappedDek, 'base64'); + const meta = { + algorithm: 'aes-256-gcm', + nonce: recipientEntry.nonce, + tag: recipientEntry.tag, + encrypted: true, + }; + return this.crypto.decryptBuffer(ciphertext, kek, meta); + } catch (err) { + if (err instanceof CasError && err.code === 'DEK_UNWRAP_FAILED') { throw err; } + throw new CasError( + 'Failed to unwrap DEK: authentication failed', + 'DEK_UNWRAP_FAILED', + { originalError: err }, + ); + } + } + + /** + * Resolves the decryption key from a manifest, handling both legacy and + * envelope (multi-recipient) encrypted manifests. + * @private + * @param {import('../value-objects/Manifest.js').default} manifest + * @param {Buffer} [encryptionKey] + * @param {string} [passphrase] + * @returns {Promise} + */ + async _resolveDecryptionKey(manifest, encryptionKey, passphrase) { + this._validateKeySourceExclusive(encryptionKey, passphrase); + + if (passphrase) { + if (manifest.encryption?.kdf) { + encryptionKey = await this._resolveKeyFromPassphrase(passphrase, manifest.encryption.kdf); + } else { + throw new CasError( + 'Manifest was not stored with passphrase-based encryption; provide encryptionKey instead', + 'MISSING_KEY', + ); + } + } + + if (!encryptionKey) { + if (manifest.encryption?.encrypted) { + throw new CasError('Encryption key required to restore encrypted content', 'MISSING_KEY'); + } + return undefined; + } + + this.crypto._validateKey(encryptionKey); + + const recipients = manifest.encryption?.recipients; + if (!recipients || recipients.length === 0) { + // Legacy path — key is used directly + return encryptionKey; + } + + // Envelope path — try each recipient entry + for (const entry of recipients) { + try { + return this._unwrapDek(entry, encryptionKey); + } catch { + // Not this recipient's KEK, try next + } + } + + throw new CasError( + 'No recipient entry could be unwrapped with the provided key', + 'NO_MATCHING_RECIPIENT', + ); + } + /** * Validates that passphrase and encryptionKey are not both provided. * @private @@ -250,19 +347,44 @@ export default class CasService { * @param {{ algorithm: 'gzip' }} [options.compression] - Enable compression. * @returns {Promise} */ - async store({ source, slug, filename, encryptionKey, passphrase, kdfOptions, compression }) { + async store({ source, slug, filename, encryptionKey, passphrase, kdfOptions, compression, recipients }) { + // Mutual exclusivity: recipients vs encryptionKey/passphrase + if (recipients && (encryptionKey || passphrase)) { + throw new CasError( + 'Provide recipients or encryptionKey/passphrase, not both', + 'INVALID_OPTIONS', + ); + } + this._validateKeySourceExclusive(encryptionKey, passphrase); this._validateCompression(compression); let kdfParams; - if (passphrase) { - const derived = await this.deriveKey({ passphrase, ...kdfOptions }); - encryptionKey = derived.key; - kdfParams = derived.params; - } + let recipientEntries; + let actualEncryptionKey; - if (encryptionKey) { - this.crypto._validateKey(encryptionKey); + if (recipients) { + // Envelope encryption: generate random DEK, wrap for each recipient + for (const r of recipients) { + this.crypto._validateKey(r.key); + } + const dek = this.crypto.randomBytes(32); + recipientEntries = recipients.map((r) => ({ + label: r.label, + ...this._wrapDek(dek, r.key), + })); + actualEncryptionKey = dek; + } else { + if (passphrase) { + const derived = await this.deriveKey({ passphrase, ...kdfOptions }); + encryptionKey = derived.key; + kdfParams = derived.params; + } + + if (encryptionKey) { + this.crypto._validateKey(encryptionKey); + } + actualEncryptionKey = encryptionKey; } const manifestData = { slug, filename, size: 0, chunks: [] }; @@ -281,13 +403,16 @@ export default class CasService { manifestData.compression = { algorithm: 'gzip' }; } - if (encryptionKey) { - const { encrypt, finalize } = this.crypto.createEncryptionStream(encryptionKey); + if (actualEncryptionKey) { + const { encrypt, finalize } = this.crypto.createEncryptionStream(actualEncryptionKey); await this._chunkAndStore(encrypt(processedSource), manifestData); const encMeta = finalize(); if (kdfParams) { encMeta.kdf = kdfParams; } + if (recipientEntries) { + encMeta.recipients = recipientEntries; + } manifestData.encryption = encMeta; } else { await this._chunkAndStore(processedSource, manifestData); @@ -295,7 +420,7 @@ export default class CasService { const manifest = new Manifest(manifestData); this.observability.metric('file', { - action: 'stored', slug, size: manifest.size, chunkCount: manifest.chunks.length, encrypted: !!encryptionKey, + action: 'stored', slug, size: manifest.size, chunkCount: manifest.chunks.length, encrypted: !!actualEncryptionKey, }); return manifest; } @@ -495,7 +620,7 @@ export default class CasService { * @throws {CasError} INTEGRITY_ERROR if chunk verification or decryption fails. */ async *restoreStream({ manifest, encryptionKey, passphrase }) { - const key = await this._resolveEncryptionKey(manifest, encryptionKey, passphrase); + const key = await this._resolveDecryptionKey(manifest, encryptionKey, passphrase); if (manifest.chunks.length === 0) { this.observability.metric('file', { diff --git a/test/unit/domain/services/CasService.envelope.test.js b/test/unit/domain/services/CasService.envelope.test.js new file mode 100644 index 0000000..ef0045f --- /dev/null +++ b/test/unit/domain/services/CasService.envelope.test.js @@ -0,0 +1,326 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import CasService from '../../../../src/domain/services/CasService.js'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; +import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; +import SilentObserver from '../../../../src/infrastructure/adapters/SilentObserver.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function setup() { + const crypto = new NodeCryptoAdapter(); + const blobStore = new Map(); + + const mockPersistence = { + writeBlob: async (content) => { + const buf = Buffer.isBuffer(content) ? content : Buffer.from(content); + const oid = crypto.sha256(buf); + blobStore.set(oid, buf); + return oid; + }, + writeTree: async () => 'mock-tree-oid', + readBlob: async (oid) => { + const buf = blobStore.get(oid); + if (!buf) throw new Error(`Blob not found: ${oid}`); + return buf; + }, + }; + + const service = new CasService({ + persistence: mockPersistence, + crypto, + codec: new JsonCodec(), + chunkSize: 1024, + observability: new SilentObserver(), + }); + + return { service, blobStore, crypto }; +} + +async function* bufferSource(buf) { + yield buf; +} + +// --------------------------------------------------------------------------- +// Single recipient (degenerate case) +// --------------------------------------------------------------------------- +describe('CasService – envelope encryption (single recipient)', () => { + let service; + beforeEach(() => { ({ service } = setup()); }); + + it('store with 1 recipient → restore round-trips', async () => { + const kek = randomBytes(32); + const original = Buffer.from('hello envelope'); + + const manifest = await service.store({ + source: bufferSource(original), + slug: 'test', + filename: 'test.bin', + recipients: [{ label: 'alice', key: kek }], + }); + + expect(manifest.encryption).toBeDefined(); + expect(manifest.encryption.recipients).toHaveLength(1); + expect(manifest.encryption.recipients[0].label).toBe('alice'); + + const { buffer } = await service.restore({ manifest, encryptionKey: kek }); + expect(buffer.equals(original)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Multi-recipient golden path +// --------------------------------------------------------------------------- +describe('CasService – envelope encryption (multi-recipient)', () => { // eslint-disable-line max-lines-per-function + let service; + beforeEach(() => { ({ service } = setup()); }); + + it('store with 3 recipients → each can restore', async () => { + const keys = [randomBytes(32), randomBytes(32), randomBytes(32)]; + const original = randomBytes(2048); + + const manifest = await service.store({ + source: bufferSource(original), + slug: 'multi', + filename: 'multi.bin', + recipients: [ + { label: 'alice', key: keys[0] }, + { label: 'bob', key: keys[1] }, + { label: 'carol', key: keys[2] }, + ], + }); + + expect(manifest.encryption.recipients).toHaveLength(3); + + for (const key of keys) { + const { buffer } = await service.restore({ manifest, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + } + }); + + it('non-recipient KEK fails with NO_MATCHING_RECIPIENT', async () => { + const kek = randomBytes(32); + const wrongKey = randomBytes(32); + const original = Buffer.from('secret'); + + const manifest = await service.store({ + source: bufferSource(original), + slug: 'test', + filename: 'test.bin', + recipients: [{ label: 'alice', key: kek }], + }); + + await expect( + service.restore({ manifest, encryptionKey: wrongKey }), + ).rejects.toThrow(CasError); + + try { + await service.restore({ manifest, encryptionKey: wrongKey }); + } catch (err) { + expect(err.code).toBe('NO_MATCHING_RECIPIENT'); + } + }); + + it('tampered wrappedDek fails with NO_MATCHING_RECIPIENT', async () => { + const kek = randomBytes(32); + const original = Buffer.from('test data'); + + const manifest = await service.store({ + source: bufferSource(original), + slug: 'tamper', + filename: 'tamper.bin', + recipients: [{ label: 'alice', key: kek }], + }); + + // Tamper with the wrappedDek + const tampered = manifest.toJSON(); + const originalDek = Buffer.from(tampered.encryption.recipients[0].wrappedDek, 'base64'); + originalDek[0] ^= 0x01; + tampered.encryption.recipients[0].wrappedDek = originalDek.toString('base64'); + + const Manifest = (await import('../../../../src/domain/value-objects/Manifest.js')).default; + const tamperedManifest = new Manifest(tampered); + + await expect( + service.restore({ manifest: tamperedManifest, encryptionKey: kek }), + ).rejects.toThrow(CasError); + + try { + await service.restore({ manifest: tamperedManifest, encryptionKey: kek }); + } catch (err) { + expect(err.code).toBe('NO_MATCHING_RECIPIENT'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Backward compatibility +// --------------------------------------------------------------------------- +describe('CasService – envelope encryption (backward compat)', () => { + let service; + beforeEach(() => { ({ service } = setup()); }); + + it('old-style manifest (no recipients) restores with direct key', async () => { + const key = randomBytes(32); + const original = Buffer.from('legacy encrypted data'); + + // Store using legacy encryptionKey path + const manifest = await service.store({ + source: bufferSource(original), + slug: 'legacy', + filename: 'legacy.bin', + encryptionKey: key, + }); + + expect(manifest.encryption.recipients).toBeUndefined(); + + const { buffer } = await service.restore({ manifest, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + }); + + it('unencrypted manifest restores without key', async () => { + const original = Buffer.from('plaintext data'); + + const manifest = await service.store({ + source: bufferSource(original), + slug: 'plain', + filename: 'plain.bin', + }); + + const { buffer } = await service.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- +describe('CasService – envelope encryption (edge cases)', () => { // eslint-disable-line max-lines-per-function + let service; + beforeEach(() => { ({ service } = setup()); }); + + it('10 recipients all restore correctly', async () => { + const keys = Array.from({ length: 10 }, () => randomBytes(32)); + const original = randomBytes(512); + + const manifest = await service.store({ + source: bufferSource(original), + slug: 'many', + filename: 'many.bin', + recipients: keys.map((key, i) => ({ label: `r${i}`, key })), + }); + + expect(manifest.encryption.recipients).toHaveLength(10); + + for (const key of keys) { + const { buffer } = await service.restore({ manifest, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + } + }); + + it('mutual exclusivity: recipients + encryptionKey → INVALID_OPTIONS', async () => { + const key = randomBytes(32); + + await expect( + service.store({ + source: bufferSource(Buffer.from('x')), + slug: 'test', + filename: 'test.bin', + encryptionKey: key, + recipients: [{ label: 'a', key }], + }), + ).rejects.toThrow(/recipients or encryptionKey/); + }); + + it('mutual exclusivity: recipients + passphrase → INVALID_OPTIONS', async () => { + const key = randomBytes(32); + + await expect( + service.store({ + source: bufferSource(Buffer.from('x')), + slug: 'test', + filename: 'test.bin', + passphrase: 'secret', + recipients: [{ label: 'a', key }], + }), + ).rejects.toThrow(/recipients or encryptionKey/); + }); + + it('envelope manifest includes encryption metadata (algorithm, nonce, tag)', async () => { + const kek = randomBytes(32); + + const manifest = await service.store({ + source: bufferSource(Buffer.from('data')), + slug: 'meta', + filename: 'meta.bin', + recipients: [{ label: 'alice', key: kek }], + }); + + expect(manifest.encryption.algorithm).toBe('aes-256-gcm'); + expect(manifest.encryption.nonce).toBeDefined(); + expect(manifest.encryption.tag).toBeDefined(); + expect(manifest.encryption.encrypted).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Fuzz +// --------------------------------------------------------------------------- +describe('CasService – envelope encryption (fuzz)', () => { + let service; + beforeEach(() => { ({ service } = setup()); }); + + it('50 random plaintexts × 3 random KEKs all round-trip', async () => { + for (let i = 0; i < 50; i++) { + const size = Math.floor(Math.random() * 4096); + const original = randomBytes(size); + const keys = [randomBytes(32), randomBytes(32), randomBytes(32)]; + + const manifest = await service.store({ + source: bufferSource(original), + slug: `fuzz-${i}`, + filename: `fuzz-${i}.bin`, + recipients: keys.map((key, j) => ({ label: `k${j}`, key })), + }); + + const idx = Math.floor(Math.random() * 3); + const { buffer } = await service.restore({ + manifest, + encryptionKey: keys[idx], + }); + expect(buffer.equals(original)).toBe(true); + } + }); + + it('tamper each recipient entry independently → fails', async () => { + const keys = [randomBytes(32), randomBytes(32), randomBytes(32)]; + const original = randomBytes(256); + + const manifest = await service.store({ + source: bufferSource(original), + slug: 'tamper-fuzz', + filename: 'tamper.bin', + recipients: keys.map((key, j) => ({ label: `k${j}`, key })), + }); + + const Manifest = (await import('../../../../src/domain/value-objects/Manifest.js')).default; + + // Tamper each entry and verify the legitimate key no longer works for that entry + for (let t = 0; t < 3; t++) { + const json = JSON.parse(JSON.stringify(manifest.toJSON())); + const dek = Buffer.from(json.encryption.recipients[t].wrappedDek, 'base64'); + dek[0] ^= 0xff; + json.encryption.recipients[t].wrappedDek = dek.toString('base64'); + + // Remove the other two recipients so only the tampered one remains + json.encryption.recipients = [json.encryption.recipients[t]]; + const tamperedManifest = new Manifest(json); + + await expect( + service.restore({ manifest: tamperedManifest, encryptionKey: keys[t] }), + ).rejects.toThrow(CasError); + } + }); +}); From 13f05bbde9571e812a9e57c0e7092f196eade0c1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 28 Feb 2026 02:31:42 -0800 Subject: [PATCH 03/19] feat(recipients): add/remove/list recipient management API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M11 Locksmith Task 11.2 — addRecipient unwraps the DEK with an existing key and re-wraps for a new recipient. removeRecipient drops an entry (guards against removing the last one). listRecipients returns labels. Facade delegates added. --- index.d.ts | 14 + index.js | 40 +++ src/domain/services/CasService.d.ts | 14 + src/domain/services/CasService.js | 120 ++++++++ .../services/CasService.recipients.test.js | 277 ++++++++++++++++++ 5 files changed, 465 insertions(+) create mode 100644 test/unit/domain/services/CasService.recipients.test.js diff --git a/index.d.ts b/index.d.ts index b090712..855d61a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -351,6 +351,20 @@ export default class ContentAddressableStore { deriveKey(options: DeriveKeyOptions): Promise; + addRecipient(options: { + manifest: Manifest; + existingKey: Buffer; + newRecipientKey: Buffer; + label: string; + }): Promise; + + removeRecipient(options: { + manifest: Manifest; + label: string; + }): Promise; + + listRecipients(manifest: Manifest): Promise; + // Vault — delegates to VaultService static VAULT_REF: string; diff --git a/index.js b/index.js index dfb01fa..264f134 100644 --- a/index.js +++ b/index.js @@ -422,6 +422,46 @@ export default class ContentAddressableStore { return await service.deriveKey(options); } + // --------------------------------------------------------------------------- + // Recipient management — delegates to CasService + // --------------------------------------------------------------------------- + + /** + * Adds a recipient to an envelope-encrypted manifest. + * @param {Object} options + * @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest + * @param {Buffer} options.existingKey - KEK of an existing recipient. + * @param {Buffer} options.newRecipientKey - KEK for the new recipient. + * @param {string} options.label - Label for the new recipient. + * @returns {Promise} + */ + async addRecipient(options) { + const service = await this.#getService(); + return await service.addRecipient(options); + } + + /** + * Removes a recipient from an envelope-encrypted manifest. + * @param {Object} options + * @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest + * @param {string} options.label - Label to remove. + * @returns {Promise} + */ + async removeRecipient(options) { + const service = await this.#getService(); + return await service.removeRecipient(options); + } + + /** + * Lists recipient labels from an envelope-encrypted manifest. + * @param {import('./src/domain/value-objects/Manifest.js').default} manifest + * @returns {Promise} + */ + async listRecipients(manifest) { + const service = await this.#getService(); + return service.listRecipients(manifest); + } + // --------------------------------------------------------------------------- // Vault — delegates to VaultService // --------------------------------------------------------------------------- diff --git a/src/domain/services/CasService.d.ts b/src/domain/services/CasService.d.ts index 7416607..6c44ee0 100644 --- a/src/domain/services/CasService.d.ts +++ b/src/domain/services/CasService.d.ts @@ -139,6 +139,20 @@ export default class CasService { treeOids: string[]; }): Promise<{ referenced: Set; total: number }>; + addRecipient(options: { + manifest: Manifest; + existingKey: Buffer; + newRecipientKey: Buffer; + label: string; + }): Promise; + + removeRecipient(options: { + manifest: Manifest; + label: string; + }): Promise; + + listRecipients(manifest: Manifest): string[]; + verifyIntegrity(manifest: Manifest): Promise; deriveKey(options: DeriveKeyOptions): Promise; diff --git a/src/domain/services/CasService.js b/src/domain/services/CasService.js index db02d5d..e517626 100644 --- a/src/domain/services/CasService.js +++ b/src/domain/services/CasService.js @@ -868,6 +868,126 @@ export default class CasService { return await this.crypto.deriveKey(options); } + /** + * Adds a new recipient to an envelope-encrypted manifest. + * + * Unwraps the DEK using `existingKey`, wraps it with `newRecipientKey`, + * and returns a new Manifest with the appended recipient entry. + * + * @param {Object} options + * @param {import('../value-objects/Manifest.js').default} options.manifest + * @param {Buffer} options.existingKey - KEK of an existing recipient. + * @param {Buffer} options.newRecipientKey - KEK for the new recipient. + * @param {string} options.label - Label for the new recipient. + * @returns {Promise} + * @throws {CasError} INVALID_OPTIONS if manifest has no recipients. + * @throws {CasError} RECIPIENT_ALREADY_EXISTS if label is a duplicate. + * @throws {CasError} DEK_UNWRAP_FAILED if existingKey doesn't match any recipient. + */ + async addRecipient({ manifest, existingKey, newRecipientKey, label }) { + const recipients = manifest.encryption?.recipients; + if (!recipients || recipients.length === 0) { + throw new CasError( + 'Manifest does not use envelope encryption (no recipients)', + 'INVALID_OPTIONS', + ); + } + + if (recipients.some((r) => r.label === label)) { + throw new CasError( + `Recipient "${label}" already exists`, + 'RECIPIENT_ALREADY_EXISTS', + { label }, + ); + } + + this.crypto._validateKey(existingKey); + this.crypto._validateKey(newRecipientKey); + + // Unwrap DEK using the existing key + let dek; + for (const entry of recipients) { + try { + dek = this._unwrapDek(entry, existingKey); + break; + } catch { + // Try next + } + } + if (!dek) { + throw new CasError( + 'Failed to unwrap DEK: authentication failed', + 'DEK_UNWRAP_FAILED', + ); + } + + // Wrap DEK for the new recipient + const newEntry = { label, ...this._wrapDek(dek, newRecipientKey) }; + + const json = manifest.toJSON(); + const updatedEncryption = { + ...json.encryption, + recipients: [...recipients.map((r) => ({ ...r })), newEntry], + }; + + return new Manifest({ ...json, encryption: updatedEncryption }); + } + + /** + * Removes a recipient from an envelope-encrypted manifest. + * + * Returns a new Manifest with the recipient entry removed. Does not + * require a key — this is a manifest-only mutation. + * + * @param {Object} options + * @param {import('../value-objects/Manifest.js').default} options.manifest + * @param {string} options.label - Label of the recipient to remove. + * @returns {Promise} + * @throws {CasError} RECIPIENT_NOT_FOUND if label doesn't exist. + * @throws {CasError} CANNOT_REMOVE_LAST_RECIPIENT if only one recipient remains. + */ + async removeRecipient({ manifest, label }) { + const recipients = manifest.encryption?.recipients; + if (!recipients || recipients.length === 0) { + throw new CasError( + `Recipient "${label}" not found`, + 'RECIPIENT_NOT_FOUND', + { label }, + ); + } + + if (!recipients.some((r) => r.label === label)) { + throw new CasError( + `Recipient "${label}" not found`, + 'RECIPIENT_NOT_FOUND', + { label }, + ); + } + + if (recipients.length === 1) { + throw new CasError( + 'Cannot remove the last recipient', + 'CANNOT_REMOVE_LAST_RECIPIENT', + ); + } + + const filtered = recipients.filter((r) => r.label !== label).map((r) => ({ ...r })); + const json = manifest.toJSON(); + const updatedEncryption = { ...json.encryption, recipients: filtered }; + + return new Manifest({ ...json, encryption: updatedEncryption }); + } + + /** + * Lists recipient labels from an envelope-encrypted manifest. + * + * @param {import('../value-objects/Manifest.js').default} manifest + * @returns {string[]} Recipient labels, or empty array if not envelope-encrypted. + */ + listRecipients(manifest) { + return (manifest.encryption?.recipients || []).map((r) => r.label); + } + /** * Verifies the integrity of a stored file by re-hashing its chunks. * @param {import('../value-objects/Manifest.js').default} manifest diff --git a/test/unit/domain/services/CasService.recipients.test.js b/test/unit/domain/services/CasService.recipients.test.js new file mode 100644 index 0000000..fda6448 --- /dev/null +++ b/test/unit/domain/services/CasService.recipients.test.js @@ -0,0 +1,277 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import CasService from '../../../../src/domain/services/CasService.js'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; +import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; +import SilentObserver from '../../../../src/infrastructure/adapters/SilentObserver.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; +import Manifest from '../../../../src/domain/value-objects/Manifest.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function setup() { + const crypto = new NodeCryptoAdapter(); + const blobStore = new Map(); + + const mockPersistence = { + writeBlob: async (content) => { + const buf = Buffer.isBuffer(content) ? content : Buffer.from(content); + const oid = crypto.sha256(buf); + blobStore.set(oid, buf); + return oid; + }, + writeTree: async () => 'mock-tree-oid', + readBlob: async (oid) => { + const buf = blobStore.get(oid); + if (!buf) throw new Error(`Blob not found: ${oid}`); + return buf; + }, + }; + + const service = new CasService({ + persistence: mockPersistence, + crypto, + codec: new JsonCodec(), + chunkSize: 1024, + observability: new SilentObserver(), + }); + + return { service, blobStore, crypto }; +} + +async function* bufferSource(buf) { + yield buf; +} + +// --------------------------------------------------------------------------- +// addRecipient — golden path +// --------------------------------------------------------------------------- +describe('CasService – addRecipient', () => { // eslint-disable-line max-lines-per-function + let service; + beforeEach(() => { ({ service } = setup()); }); + + it('adds a recipient, and both can restore', async () => { + const alice = randomBytes(32); + const bob = randomBytes(32); + const original = Buffer.from('shared secret'); + + const manifest = await service.store({ + source: bufferSource(original), + slug: 'shared', + filename: 'shared.bin', + recipients: [{ label: 'alice', key: alice }], + }); + + const updated = await service.addRecipient({ + manifest, + existingKey: alice, + newRecipientKey: bob, + label: 'bob', + }); + + expect(updated.encryption.recipients).toHaveLength(2); + + // Both keys can restore + for (const key of [alice, bob]) { + const { buffer } = await service.restore({ manifest: updated, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + } + }); + + it('wrong existingKey → DEK_UNWRAP_FAILED', async () => { + const alice = randomBytes(32); + const wrongKey = randomBytes(32); + const bob = randomBytes(32); + + const manifest = await service.store({ + source: bufferSource(Buffer.from('data')), + slug: 'test', + filename: 'test.bin', + recipients: [{ label: 'alice', key: alice }], + }); + + try { + await service.addRecipient({ + manifest, + existingKey: wrongKey, + newRecipientKey: bob, + label: 'bob', + }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('DEK_UNWRAP_FAILED'); + } + }); + + it('duplicate label → RECIPIENT_ALREADY_EXISTS', async () => { + const alice = randomBytes(32); + const bob = randomBytes(32); + + const manifest = await service.store({ + source: bufferSource(Buffer.from('data')), + slug: 'test', + filename: 'test.bin', + recipients: [{ label: 'alice', key: alice }], + }); + + try { + await service.addRecipient({ + manifest, + existingKey: alice, + newRecipientKey: bob, + label: 'alice', + }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('RECIPIENT_ALREADY_EXISTS'); + } + }); + + it('non-envelope manifest → INVALID_OPTIONS', async () => { + const key = randomBytes(32); + + const manifest = await service.store({ + source: bufferSource(Buffer.from('data')), + slug: 'test', + filename: 'test.bin', + encryptionKey: key, + }); + + try { + await service.addRecipient({ + manifest, + existingKey: key, + newRecipientKey: randomBytes(32), + label: 'bob', + }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('INVALID_OPTIONS'); + } + }); +}); + +// --------------------------------------------------------------------------- +// removeRecipient +// --------------------------------------------------------------------------- +describe('CasService – removeRecipient', () => { // eslint-disable-line max-lines-per-function + let service; + beforeEach(() => { ({ service } = setup()); }); + + it('removes a recipient, remaining can restore, removed cannot', async () => { + const alice = randomBytes(32); + const bob = randomBytes(32); + const original = Buffer.from('shared data'); + + const manifest = await service.store({ + source: bufferSource(original), + slug: 'rm', + filename: 'rm.bin', + recipients: [ + { label: 'alice', key: alice }, + { label: 'bob', key: bob }, + ], + }); + + const updated = await service.removeRecipient({ manifest, label: 'bob' }); + expect(updated.encryption.recipients).toHaveLength(1); + expect(updated.encryption.recipients[0].label).toBe('alice'); + + // Alice can still restore + const { buffer } = await service.restore({ manifest: updated, encryptionKey: alice }); + expect(buffer.equals(original)).toBe(true); + + // Bob cannot + try { + await service.restore({ manifest: updated, encryptionKey: bob }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err.code).toBe('NO_MATCHING_RECIPIENT'); + } + }); + + it('nonexistent label → RECIPIENT_NOT_FOUND', async () => { + const key = randomBytes(32); + + const manifest = await service.store({ + source: bufferSource(Buffer.from('x')), + slug: 'test', + filename: 'test.bin', + recipients: [{ label: 'alice', key }], + }); + + try { + await service.removeRecipient({ manifest, label: 'eve' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('RECIPIENT_NOT_FOUND'); + } + }); + + it('last recipient → CANNOT_REMOVE_LAST_RECIPIENT', async () => { + const key = randomBytes(32); + + const manifest = await service.store({ + source: bufferSource(Buffer.from('x')), + slug: 'test', + filename: 'test.bin', + recipients: [{ label: 'alice', key }], + }); + + try { + await service.removeRecipient({ manifest, label: 'alice' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('CANNOT_REMOVE_LAST_RECIPIENT'); + } + }); +}); + +// --------------------------------------------------------------------------- +// listRecipients +// --------------------------------------------------------------------------- +describe('CasService – listRecipients', () => { + let service; + beforeEach(() => { ({ service } = setup()); }); + + it('returns labels from envelope-encrypted manifest', async () => { + const manifest = await service.store({ + source: bufferSource(Buffer.from('data')), + slug: 'test', + filename: 'test.bin', + recipients: [ + { label: 'alice', key: randomBytes(32) }, + { label: 'bob', key: randomBytes(32) }, + ], + }); + + expect(service.listRecipients(manifest)).toEqual(['alice', 'bob']); + }); + + it('returns empty array for non-envelope encrypted manifest', async () => { + const manifest = await service.store({ + source: bufferSource(Buffer.from('data')), + slug: 'test', + filename: 'test.bin', + encryptionKey: randomBytes(32), + }); + + expect(service.listRecipients(manifest)).toEqual([]); + }); + + it('returns empty array for unencrypted manifest', async () => { + const manifest = await service.store({ + source: bufferSource(Buffer.from('data')), + slug: 'test', + filename: 'test.bin', + }); + + expect(service.listRecipients(manifest)).toEqual([]); + }); +}); From 81a06c3771e6b2bca6a547189a4ca9a834e22a3d Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 28 Feb 2026 02:35:49 -0800 Subject: [PATCH 04/19] feat(cli): multi-recipient store and recipient management commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M11 Locksmith Task 11.4 — add --recipient flag to store command, recipient add/remove/list subcommands, and error hints for all envelope encryption error codes. Refactor store() and _resolveDecryptionKey() to satisfy lint complexity limits. --- bin/actions.js | 5 + bin/git-cas.js | 151 ++++++++++++++--- src/domain/services/CasService.js | 152 +++++++++--------- test/unit/cli/actions.test.js | 8 + .../domain/schemas/RecipientSchema.test.js | 77 +++------ .../services/CasService.envelope.test.js | 22 +-- .../services/CasService.recipients.test.js | 3 +- .../domain/value-objects/Manifest.test.js | 57 +++---- 8 files changed, 273 insertions(+), 202 deletions(-) diff --git a/bin/actions.js b/bin/actions.js index dfe299c..444b0c8 100644 --- a/bin/actions.js +++ b/bin/actions.js @@ -8,6 +8,11 @@ const HINTS = { VAULT_ENTRY_NOT_FOUND: "Run 'git cas vault list' to see available entries", VAULT_ENTRY_EXISTS: 'Use --force to overwrite', INTEGRITY_ERROR: 'Check that the correct key or passphrase was used', + NO_MATCHING_RECIPIENT: 'The provided key does not match any recipient in the manifest', + DEK_UNWRAP_FAILED: 'The existing key does not match any recipient — cannot unwrap DEK', + RECIPIENT_NOT_FOUND: 'No recipient with that label exists in the manifest', + RECIPIENT_ALREADY_EXISTS: 'A recipient with that label already exists', + CANNOT_REMOVE_LAST_RECIPIENT: 'At least one recipient must remain in the manifest', }; /** diff --git a/bin/git-cas.js b/bin/git-cas.js index 40c2c22..083b7ad 100755 --- a/bin/git-cas.js +++ b/bin/git-cas.js @@ -96,52 +96,72 @@ function validateRestoreFlags(opts) { // --------------------------------------------------------------------------- // store // --------------------------------------------------------------------------- +/** + * Build store options, resolving encryption key or recipients. + */ +async function buildStoreOpts(cas, file, opts) { + const storeOpts = { filePath: file, slug: opts.slug }; + if (opts.recipient) { + storeOpts.recipients = opts.recipient; + } else { + const encryptionKey = await resolveEncryptionKey(cas, opts); + if (encryptionKey) { storeOpts.encryptionKey = encryptionKey; } + } + return storeOpts; +} + +/** + * Parse a --recipient flag value into { label, key }. + * Format: label:keyfile + */ +function parseRecipient(value, previous) { + const sep = value.indexOf(':'); + if (sep < 1) { + throw new Error(`Invalid --recipient format "${value}": expected label:keyfile`); + } + const label = value.slice(0, sep); + const keyfile = value.slice(sep + 1); + const key = readKeyFile(keyfile); + const list = previous || []; + list.push({ label, key }); + return list; +} + program .command('store ') .description('Store a file into Git CAS') .requiredOption('--slug ', 'Asset slug identifier') .option('--key-file ', 'Path to 32-byte raw encryption key file') + .option('--recipient ', 'Envelope recipient (repeatable)', parseRecipient) .option('--tree', 'Also create a Git tree and print its OID') .option('--force', 'Overwrite existing vault entry') .option('--vault-passphrase ', 'Vault-level passphrase for encryption (prefer GIT_CAS_PASSPHRASE env var)') .option('--cwd ', 'Git working directory', '.') .action(runAction(async (file, opts) => { + if (opts.recipient && opts.keyFile) { + throw new Error('Provide --key-file or --recipient, not both'); + } + if (opts.force && !opts.tree) { + throw new Error('--force requires --tree'); + } const json = program.opts().json; const quiet = program.opts().quiet || json; const observer = new EventEmitterObserver(); const cas = createCas(opts.cwd, { observability: observer }); - const encryptionKey = await resolveEncryptionKey(cas, opts); - if (opts.force && !opts.tree) { - throw new Error('--force requires --tree'); - } - const storeOpts = { filePath: file, slug: opts.slug }; - if (encryptionKey) { - storeOpts.encryptionKey = encryptionKey; - } - const progress = createStoreProgress({ - filePath: file, chunkSize: cas.chunkSize, quiet, - }); + const storeOpts = await buildStoreOpts(cas, file, opts); + const progress = createStoreProgress({ filePath: file, chunkSize: cas.chunkSize, quiet }); progress.attach(observer); let manifest; - try { - manifest = await cas.storeFile(storeOpts); - } finally { - progress.detach(); - } + try { manifest = await cas.storeFile(storeOpts); } finally { progress.detach(); } if (opts.tree) { const treeOid = await cas.createTree({ manifest }); await cas.addToVault({ slug: opts.slug, treeOid, force: !!opts.force }); - if (json) { - process.stdout.write(`${JSON.stringify({ treeOid })}\n`); - } else { - process.stdout.write(`${treeOid}\n`); - } - } else if (json) { - process.stdout.write(`${JSON.stringify({ manifest: manifest.toJSON() })}\n`); + process.stdout.write(json ? `${JSON.stringify({ treeOid })}\n` : `${treeOid}\n`); } else { - process.stdout.write(`${JSON.stringify(manifest.toJSON(), null, 2)}\n`); + const output = json ? JSON.stringify({ manifest: manifest.toJSON() }) : JSON.stringify(manifest.toJSON(), null, 2); + process.stdout.write(`${output}\n`); } }, getJson)); @@ -419,4 +439,87 @@ vault await launchDashboard(cas); }, getJson)); +// --------------------------------------------------------------------------- +// recipient add / remove / list +// --------------------------------------------------------------------------- +const recipient = program + .command('recipient') + .description('Manage envelope encryption recipients'); + +recipient + .command('add ') + .description('Add a recipient to an envelope-encrypted asset') + .requiredOption('--label