Skip to content

Commit 96f4d16

Browse files
authored
Merge pull request #12 from git-stunts/feat/m11-locksmith
Resolved 7 CodeRabbit issues (1 critical, 2 major, 4 minor); v5.1.0 Locksmith. M11 Envelope Encryption: DEK/KEK multi-recipient model, addRecipient/removeRecipient/listRecipients APIs, CLI subcommands, 48 new tests, all 3 runtimes green.
2 parents a83dcec + 2225b22 commit 96f4d16

41 files changed

Lines changed: 1602 additions & 187 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [5.1.0] — Locksmith (2026-02-28)
9+
10+
### Added
11+
- **Envelope encryption (DEK/KEK)** — multi-recipient model where a random DEK encrypts content and per-recipient KEKs wrap the DEK. Recipients can be added/removed without re-encrypting data.
12+
- **`RecipientSchema`** — Zod schema for validating recipient entries in manifests.
13+
- **`recipients` field on `EncryptionSchema`** — optional array of `{ label, wrappedDek, nonce, tag }` entries.
14+
- **`CasService.addRecipient()` / `removeRecipient()` / `listRecipients()`** — manage envelope recipients on existing manifests.
15+
- **`--recipient <label:keyfile>` CLI flag** — repeatable flag on `git cas store` for envelope encryption.
16+
- **`git cas recipient add/remove/list`** subcommands — CLI management of envelope recipients.
17+
- **`RecipientEntry` type re-exported** from `index.d.ts`.
18+
- 48 new unit tests covering envelope store/restore, recipient management, edge cases, and fuzz round-trips.
19+
20+
### Fixed
21+
- **`_wrapDek` / `_unwrapDek` missing `await`** — these called async `encryptBuffer()` / `decryptBuffer()` without `await`, silently producing garbage on Bun/Deno runtimes where crypto is async.
22+
- **`--recipient` + `--vault-passphrase` not guarded** — CLI now rejects combining `--recipient` with `--key-file` or `--vault-passphrase`.
23+
- **Dead `_resolveEncryptionKey` method removed** — superseded by `_resolveDecryptionKey` but left behind.
24+
- **Redundant `RECIPIENT_NOT_FOUND` guards** in `removeRecipient` collapsed into one.
25+
- **`addRecipient` duplicated unwrap loop** replaced with `_resolveKeyForRecipients` reuse.
26+
- **`removeRecipient` post-filter guard** — defense-in-depth check prevents zero recipients when duplicate labels exist in corrupted/crafted manifests.
27+
- **`EncryptionSchema` empty recipients**`recipients` array now enforces `min(1)` to reject undecryptable envelope manifests.
28+
- **`parseRecipient` empty keyfile** — CLI now rejects `--recipient alice:` (missing keyfile path) with a clear error.
29+
- **CLI 30s hang in Docker**`process.exit()` with I/O flushing prevents `setTimeout` leak in containerized runtimes.
30+
- **Deno Dockerfile** — multi-stage Node 22 copy replaces `apt install nodejs`, improving layer caching and image size.
31+
- **Runtime-neutral Docker hint** in integration tests; `afterAll` guards `rmSync` against partial `beforeAll` failures.
32+
833
## [5.0.0] — Hydra (2026-02-28)
934

1035
### Breaking Changes

Dockerfile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ ENV GIT_STUNTS_DOCKER=1
2020
CMD ["bunx", "vitest", "run", "test/unit"]
2121

2222
# --- Deno ---
23-
FROM denoland/deno:latest AS deno
23+
FROM denoland/deno:2.7.1 AS deno
2424
USER root
25-
RUN apt-get update && apt-get install -y git nodejs npm && rm -rf /var/lib/apt/lists/*
25+
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
2626
WORKDIR /app
27+
COPY package.json deno.lock* ./
28+
RUN deno install --allow-scripts || true
2729
COPY . .
2830
RUN deno install --allow-scripts
2931
ENV GIT_STUNTS_DOCKER=1

bin/actions.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ const HINTS = {
88
VAULT_ENTRY_NOT_FOUND: "Run 'git cas vault list' to see available entries",
99
VAULT_ENTRY_EXISTS: 'Use --force to overwrite',
1010
INTEGRITY_ERROR: 'Check that the correct key or passphrase was used',
11+
NO_MATCHING_RECIPIENT: 'The provided key does not match any recipient in the manifest',
12+
DEK_UNWRAP_FAILED: 'The existing key does not match any recipient — cannot unwrap DEK',
13+
RECIPIENT_NOT_FOUND: 'No recipient with that label exists in the manifest',
14+
RECIPIENT_ALREADY_EXISTS: 'A recipient with that label already exists',
15+
CANNOT_REMOVE_LAST_RECIPIENT: 'At least one recipient must remain in the manifest',
1116
};
1217

1318
/**

bin/git-cas.js

Lines changed: 137 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const getJson = () => program.opts().json;
1818
program
1919
.name('git-cas')
2020
.description('Content Addressable Storage backed by Git')
21-
.version('4.0.1')
21+
.version('5.0.0')
2222
.option('-q, --quiet', 'Suppress progress output')
2323
.option('--json', 'Output results as JSON');
2424

@@ -96,52 +96,75 @@ function validateRestoreFlags(opts) {
9696
// ---------------------------------------------------------------------------
9797
// store
9898
// ---------------------------------------------------------------------------
99+
/**
100+
* Build store options, resolving encryption key or recipients.
101+
*/
102+
async function buildStoreOpts(cas, file, opts) {
103+
const storeOpts = { filePath: file, slug: opts.slug };
104+
if (opts.recipient) {
105+
storeOpts.recipients = opts.recipient;
106+
} else {
107+
const encryptionKey = await resolveEncryptionKey(cas, opts);
108+
if (encryptionKey) { storeOpts.encryptionKey = encryptionKey; }
109+
}
110+
return storeOpts;
111+
}
112+
113+
/**
114+
* Parse a --recipient flag value into { label, key }.
115+
* Format: label:keyfile
116+
*/
117+
function parseRecipient(value, previous) {
118+
const sep = value.indexOf(':');
119+
if (sep < 1) {
120+
throw new Error(`Invalid --recipient format "${value}": expected label:keyfile`);
121+
}
122+
const label = value.slice(0, sep);
123+
const keyfile = value.slice(sep + 1);
124+
if (!keyfile) {
125+
throw new Error(`Invalid --recipient format "${value}": missing keyfile path`);
126+
}
127+
const key = readKeyFile(keyfile);
128+
const list = previous || [];
129+
list.push({ label, key });
130+
return list;
131+
}
132+
99133
program
100134
.command('store <file>')
101135
.description('Store a file into Git CAS')
102136
.requiredOption('--slug <slug>', 'Asset slug identifier')
103137
.option('--key-file <path>', 'Path to 32-byte raw encryption key file')
138+
.option('--recipient <label:keyfile>', 'Envelope recipient (repeatable)', parseRecipient)
104139
.option('--tree', 'Also create a Git tree and print its OID')
105140
.option('--force', 'Overwrite existing vault entry')
106141
.option('--vault-passphrase <pass>', 'Vault-level passphrase for encryption (prefer GIT_CAS_PASSPHRASE env var)')
107142
.option('--cwd <dir>', 'Git working directory', '.')
108143
.action(runAction(async (file, opts) => {
144+
if (opts.recipient && (opts.keyFile || resolvePassphrase(opts))) {
145+
throw new Error('Provide --key-file/--vault-passphrase or --recipient, not both');
146+
}
147+
if (opts.force && !opts.tree) {
148+
throw new Error('--force requires --tree');
149+
}
109150
const json = program.opts().json;
110151
const quiet = program.opts().quiet || json;
111152
const observer = new EventEmitterObserver();
112153
const cas = createCas(opts.cwd, { observability: observer });
113-
const encryptionKey = await resolveEncryptionKey(cas, opts);
114-
if (opts.force && !opts.tree) {
115-
throw new Error('--force requires --tree');
116-
}
117-
const storeOpts = { filePath: file, slug: opts.slug };
118-
if (encryptionKey) {
119-
storeOpts.encryptionKey = encryptionKey;
120-
}
121154

122-
const progress = createStoreProgress({
123-
filePath: file, chunkSize: cas.chunkSize, quiet,
124-
});
155+
const storeOpts = await buildStoreOpts(cas, file, opts);
156+
const progress = createStoreProgress({ filePath: file, chunkSize: cas.chunkSize, quiet });
125157
progress.attach(observer);
126158
let manifest;
127-
try {
128-
manifest = await cas.storeFile(storeOpts);
129-
} finally {
130-
progress.detach();
131-
}
159+
try { manifest = await cas.storeFile(storeOpts); } finally { progress.detach(); }
132160

133161
if (opts.tree) {
134162
const treeOid = await cas.createTree({ manifest });
135163
await cas.addToVault({ slug: opts.slug, treeOid, force: !!opts.force });
136-
if (json) {
137-
process.stdout.write(`${JSON.stringify({ treeOid })}\n`);
138-
} else {
139-
process.stdout.write(`${treeOid}\n`);
140-
}
141-
} else if (json) {
142-
process.stdout.write(`${JSON.stringify({ manifest: manifest.toJSON() })}\n`);
164+
process.stdout.write(json ? `${JSON.stringify({ treeOid })}\n` : `${treeOid}\n`);
143165
} else {
144-
process.stdout.write(`${JSON.stringify(manifest.toJSON(), null, 2)}\n`);
166+
const output = json ? JSON.stringify({ manifest: manifest.toJSON() }) : JSON.stringify(manifest.toJSON(), null, 2);
167+
process.stdout.write(`${output}\n`);
145168
}
146169
}, getJson));
147170

@@ -419,4 +442,92 @@ vault
419442
await launchDashboard(cas);
420443
}, getJson));
421444

422-
await program.parseAsync();
445+
// ---------------------------------------------------------------------------
446+
// recipient add / remove / list
447+
// ---------------------------------------------------------------------------
448+
const recipient = program
449+
.command('recipient')
450+
.description('Manage envelope encryption recipients');
451+
452+
recipient
453+
.command('add <slug>')
454+
.description('Add a recipient to an envelope-encrypted asset')
455+
.requiredOption('--label <label>', 'Label for the new recipient')
456+
.requiredOption('--key-file <path>', 'Path to 32-byte key file for the new recipient')
457+
.requiredOption('--existing-key-file <path>', 'Path to key file of an existing recipient')
458+
.option('--cwd <dir>', 'Git working directory', '.')
459+
.action(runAction(async (slug, opts) => {
460+
const cas = createCas(opts.cwd);
461+
const treeOid = await cas.resolveVaultEntry({ slug });
462+
const manifest = await cas.readManifest({ treeOid });
463+
464+
const existingKey = readKeyFile(opts.existingKeyFile);
465+
const newRecipientKey = readKeyFile(opts.keyFile);
466+
467+
const updated = await cas.addRecipient({
468+
manifest,
469+
existingKey,
470+
newRecipientKey,
471+
label: opts.label,
472+
});
473+
474+
const newTreeOid = await cas.createTree({ manifest: updated });
475+
await cas.addToVault({ slug, treeOid: newTreeOid, force: true });
476+
477+
const json = program.opts().json;
478+
if (json) {
479+
process.stdout.write(`${JSON.stringify({ treeOid: newTreeOid })}\n`);
480+
} else {
481+
process.stdout.write(`${newTreeOid}\n`);
482+
}
483+
}, getJson));
484+
485+
recipient
486+
.command('remove <slug>')
487+
.description('Remove a recipient from an envelope-encrypted asset')
488+
.requiredOption('--label <label>', 'Label of the recipient to remove')
489+
.option('--cwd <dir>', 'Git working directory', '.')
490+
.action(runAction(async (slug, opts) => {
491+
const cas = createCas(opts.cwd);
492+
const treeOid = await cas.resolveVaultEntry({ slug });
493+
const manifest = await cas.readManifest({ treeOid });
494+
495+
const updated = await cas.removeRecipient({ manifest, label: opts.label });
496+
497+
const newTreeOid = await cas.createTree({ manifest: updated });
498+
await cas.addToVault({ slug, treeOid: newTreeOid, force: true });
499+
500+
const json = program.opts().json;
501+
if (json) {
502+
process.stdout.write(`${JSON.stringify({ treeOid: newTreeOid })}\n`);
503+
} else {
504+
process.stdout.write(`${newTreeOid}\n`);
505+
}
506+
}, getJson));
507+
508+
recipient
509+
.command('list <slug>')
510+
.description('List recipients of an envelope-encrypted asset')
511+
.option('--cwd <dir>', 'Git working directory', '.')
512+
.action(runAction(async (slug, opts) => {
513+
const cas = createCas(opts.cwd);
514+
const treeOid = await cas.resolveVaultEntry({ slug });
515+
const manifest = await cas.readManifest({ treeOid });
516+
517+
const labels = await cas.listRecipients(manifest);
518+
const json = program.opts().json;
519+
if (json) {
520+
process.stdout.write(`${JSON.stringify(labels)}\n`);
521+
} else {
522+
for (const label of labels) {
523+
process.stdout.write(`${label}\n`);
524+
}
525+
}
526+
}, getJson));
527+
528+
await program.parseAsync();
529+
530+
// Flush stdout/stderr before exiting — spawned git child processes leave
531+
// libuv handles that prevent natural exit in containerized environments.
532+
const code = process.exitCode || 0;
533+
process.stdout.write('', () => process.stderr.write('', () => process.exit(code)));

index.d.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import Manifest from "./src/domain/value-objects/Manifest.js";
7-
import type { EncryptionMeta, ManifestData, CompressionMeta, KdfParams, SubManifestRef } from "./src/domain/value-objects/Manifest.js";
7+
import type { EncryptionMeta, ManifestData, CompressionMeta, KdfParams, SubManifestRef, RecipientEntry } from "./src/domain/value-objects/Manifest.js";
88
import Chunk from "./src/domain/value-objects/Chunk.js";
99
import CasService from "./src/domain/services/CasService.js";
1010
import type {
@@ -18,7 +18,7 @@ import type {
1818
} from "./src/domain/services/CasService.js";
1919

2020
export { CasService, Manifest, Chunk };
21-
export type { EncryptionMeta, ManifestData, CompressionMeta, KdfParams, SubManifestRef, CryptoPort, CodecPort, GitPersistencePort, ObservabilityPort, CasServiceOptions, DeriveKeyOptions, DeriveKeyResult };
21+
export type { EncryptionMeta, ManifestData, CompressionMeta, KdfParams, SubManifestRef, RecipientEntry, CryptoPort, CodecPort, GitPersistencePort, ObservabilityPort, CasServiceOptions, DeriveKeyOptions, DeriveKeyResult };
2222

2323
/** Abstract port for splitting a byte stream into chunks. */
2424
export declare class ChunkingPort {
@@ -302,6 +302,7 @@ export default class ContentAddressableStore {
302302
passphrase?: string;
303303
kdfOptions?: Omit<DeriveKeyOptions, "passphrase">;
304304
compression?: { algorithm: "gzip" };
305+
recipients?: Array<{ label: string; key: Buffer }>;
305306
}): Promise<Manifest>;
306307

307308
store(options: {
@@ -312,6 +313,7 @@ export default class ContentAddressableStore {
312313
passphrase?: string;
313314
kdfOptions?: Omit<DeriveKeyOptions, "passphrase">;
314315
compression?: { algorithm: "gzip" };
316+
recipients?: Array<{ label: string; key: Buffer }>;
315317
}): Promise<Manifest>;
316318

317319
restoreFile(options: {
@@ -349,6 +351,20 @@ export default class ContentAddressableStore {
349351

350352
deriveKey(options: DeriveKeyOptions): Promise<DeriveKeyResult>;
351353

354+
addRecipient(options: {
355+
manifest: Manifest;
356+
existingKey: Buffer;
357+
newRecipientKey: Buffer;
358+
label: string;
359+
}): Promise<Manifest>;
360+
361+
removeRecipient(options: {
362+
manifest: Manifest;
363+
label: string;
364+
}): Promise<Manifest>;
365+
366+
listRecipients(manifest: Manifest): Promise<string[]>;
367+
352368
// Vault — delegates to VaultService
353369

354370
static VAULT_REF: string;

index.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,9 +265,10 @@ export default class ContentAddressableStore {
265265
* @param {string} [options.passphrase] - Derive encryption key from passphrase.
266266
* @param {Object} [options.kdfOptions] - KDF options when using passphrase.
267267
* @param {{ algorithm: 'gzip' }} [options.compression] - Enable compression.
268+
* @param {Array<{label: string, key: Buffer}>} [options.recipients] - Envelope recipients (mutually exclusive with encryptionKey/passphrase).
268269
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>} The resulting manifest.
269270
*/
270-
async storeFile({ filePath, slug, filename, encryptionKey, passphrase, kdfOptions, compression }) {
271+
async storeFile({ filePath, slug, filename, encryptionKey, passphrase, kdfOptions, compression, recipients }) {
271272
const source = createReadStream(filePath);
272273
const service = await this.#getService();
273274
return await service.store({
@@ -278,6 +279,7 @@ export default class ContentAddressableStore {
278279
passphrase,
279280
kdfOptions,
280281
compression,
282+
recipients,
281283
});
282284
}
283285

@@ -291,6 +293,7 @@ export default class ContentAddressableStore {
291293
* @param {string} [options.passphrase] - Derive encryption key from passphrase.
292294
* @param {Object} [options.kdfOptions] - KDF options when using passphrase.
293295
* @param {{ algorithm: 'gzip' }} [options.compression] - Enable compression.
296+
* @param {Array<{label: string, key: Buffer}>} [options.recipients] - Envelope recipients (mutually exclusive with encryptionKey/passphrase).
294297
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>} The resulting manifest.
295298
*/
296299
async store(options) {
@@ -421,6 +424,46 @@ export default class ContentAddressableStore {
421424
return await service.deriveKey(options);
422425
}
423426

427+
// ---------------------------------------------------------------------------
428+
// Recipient management — delegates to CasService
429+
// ---------------------------------------------------------------------------
430+
431+
/**
432+
* Adds a recipient to an envelope-encrypted manifest.
433+
* @param {Object} options
434+
* @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest
435+
* @param {Buffer} options.existingKey - KEK of an existing recipient.
436+
* @param {Buffer} options.newRecipientKey - KEK for the new recipient.
437+
* @param {string} options.label - Label for the new recipient.
438+
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>}
439+
*/
440+
async addRecipient(options) {
441+
const service = await this.#getService();
442+
return await service.addRecipient(options);
443+
}
444+
445+
/**
446+
* Removes a recipient from an envelope-encrypted manifest.
447+
* @param {Object} options
448+
* @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest
449+
* @param {string} options.label - Label to remove.
450+
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>}
451+
*/
452+
async removeRecipient(options) {
453+
const service = await this.#getService();
454+
return await service.removeRecipient(options);
455+
}
456+
457+
/**
458+
* Lists recipient labels from an envelope-encrypted manifest.
459+
* @param {import('./src/domain/value-objects/Manifest.js').default} manifest
460+
* @returns {Promise<string[]>}
461+
*/
462+
async listRecipients(manifest) {
463+
const service = await this.#getService();
464+
return service.listRecipients(manifest);
465+
}
466+
424467
// ---------------------------------------------------------------------------
425468
// Vault — delegates to VaultService
426469
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)