Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
75eda90
feat(schema): add RecipientSchema and recipients field to EncryptionS…
flyingrobots Feb 28, 2026
6b68438
feat(envelope): DEK/KEK multi-recipient encryption model
flyingrobots Feb 28, 2026
13f05bb
feat(recipients): add/remove/list recipient management API
flyingrobots Feb 28, 2026
81a06c3
feat(cli): multi-recipient store and recipient management commands
flyingrobots Feb 28, 2026
23a8f47
fix: await async crypto in DEK wrap/unwrap, clean up M11 review issues
flyingrobots Feb 28, 2026
933949f
fix: validate recipients array and harden review findings
flyingrobots Feb 28, 2026
aa333d3
fix: narrow error handling, consistent envelope guards, test gaps
flyingrobots Feb 28, 2026
0d0c3a9
fix: post-filter recipient guard, schema min(1), empty keyfile valida…
flyingrobots Feb 28, 2026
9234b26
docs: update CHANGELOG with review feedback fixes
flyingrobots Feb 28, 2026
468a8e1
fix: optimize Deno Docker image — pin version, drop system Node, add …
flyingrobots Feb 28, 2026
b7c39ac
fix: restore nodejs in Deno image for CLI integration tests
flyingrobots Feb 28, 2026
6d81227
fix: add Array.isArray guard in _resolveRecipientsForStore
flyingrobots Feb 28, 2026
679dde1
fix: force process.exit in CLI to prevent 30s hang in Docker
flyingrobots Feb 28, 2026
bf2551d
fix: use Node 22 multi-stage copy in Deno image, drop apt nodejs
flyingrobots Feb 28, 2026
813567a
feat: runtime-adaptive crypto in tests, CLI on all runtimes
flyingrobots Feb 28, 2026
c781a0f
fix: address review nits — runtime-neutral Docker hint, guard afterAl…
flyingrobots Feb 28, 2026
2e43828
chore: bump version to 5.1.0 and finalize CHANGELOG for Locksmith rel…
flyingrobots Feb 28, 2026
75d4f4e
fix: collapse double restore() calls into single rejects.toMatchObject
flyingrobots Feb 28, 2026
2225b22
docs: replace volatile test count with stable wording in CHANGELOG
flyingrobots Feb 28, 2026
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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [5.1.0] — Locksmith (2026-02-28)

### Added
- **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.
- **`RecipientSchema`** — Zod schema for validating recipient entries in manifests.
- **`recipients` field on `EncryptionSchema`** — optional array of `{ label, wrappedDek, nonce, tag }` entries.
- **`CasService.addRecipient()` / `removeRecipient()` / `listRecipients()`** — manage envelope recipients on existing manifests.
- **`--recipient <label:keyfile>` CLI flag** — repeatable flag on `git cas store` for envelope encryption.
- **`git cas recipient add/remove/list`** subcommands — CLI management of envelope recipients.
- **`RecipientEntry` type re-exported** from `index.d.ts`.
- 48 new unit tests covering envelope store/restore, recipient management, edge cases, and fuzz round-trips.

### Fixed
- **`_wrapDek` / `_unwrapDek` missing `await`** — these called async `encryptBuffer()` / `decryptBuffer()` without `await`, silently producing garbage on Bun/Deno runtimes where crypto is async.
- **`--recipient` + `--vault-passphrase` not guarded** — CLI now rejects combining `--recipient` with `--key-file` or `--vault-passphrase`.
- **Dead `_resolveEncryptionKey` method removed** — superseded by `_resolveDecryptionKey` but left behind.
- **Redundant `RECIPIENT_NOT_FOUND` guards** in `removeRecipient` collapsed into one.
- **`addRecipient` duplicated unwrap loop** replaced with `_resolveKeyForRecipients` reuse.
- **`removeRecipient` post-filter guard** — defense-in-depth check prevents zero recipients when duplicate labels exist in corrupted/crafted manifests.
- **`EncryptionSchema` empty recipients** — `recipients` array now enforces `min(1)` to reject undecryptable envelope manifests.
- **`parseRecipient` empty keyfile** — CLI now rejects `--recipient alice:` (missing keyfile path) with a clear error.
- **CLI 30s hang in Docker** — `process.exit()` with I/O flushing prevents `setTimeout` leak in containerized runtimes.
- **Deno Dockerfile** — multi-stage Node 22 copy replaces `apt install nodejs`, improving layer caching and image size.
- **Runtime-neutral Docker hint** in integration tests; `afterAll` guards `rmSync` against partial `beforeAll` failures.

## [5.0.0] — Hydra (2026-02-28)

### Breaking Changes
Expand Down
6 changes: 4 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ ENV GIT_STUNTS_DOCKER=1
CMD ["bunx", "vitest", "run", "test/unit"]

# --- Deno ---
FROM denoland/deno:latest AS deno
FROM denoland/deno:2.7.1 AS deno
USER root
RUN apt-get update && apt-get install -y git nodejs npm && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json deno.lock* ./
RUN deno install --allow-scripts || true
COPY . .
RUN deno install --allow-scripts
ENV GIT_STUNTS_DOCKER=1
Expand Down
5 changes: 5 additions & 0 deletions bin/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};

/**
Expand Down
163 changes: 137 additions & 26 deletions bin/git-cas.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const getJson = () => program.opts().json;
program
.name('git-cas')
.description('Content Addressable Storage backed by Git')
.version('4.0.1')
.version('5.0.0')
.option('-q, --quiet', 'Suppress progress output')
.option('--json', 'Output results as JSON');

Expand Down Expand Up @@ -96,52 +96,75 @@ 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);
if (!keyfile) {
throw new Error(`Invalid --recipient format "${value}": missing keyfile path`);
}
const key = readKeyFile(keyfile);
const list = previous || [];
list.push({ label, key });
return list;
}

program
.command('store <file>')
.description('Store a file into Git CAS')
.requiredOption('--slug <slug>', 'Asset slug identifier')
.option('--key-file <path>', 'Path to 32-byte raw encryption key file')
.option('--recipient <label:keyfile>', 'Envelope recipient (repeatable)', parseRecipient)
.option('--tree', 'Also create a Git tree and print its OID')
.option('--force', 'Overwrite existing vault entry')
.option('--vault-passphrase <pass>', 'Vault-level passphrase for encryption (prefer GIT_CAS_PASSPHRASE env var)')
.option('--cwd <dir>', 'Git working directory', '.')
.action(runAction(async (file, opts) => {
if (opts.recipient && (opts.keyFile || resolvePassphrase(opts))) {
throw new Error('Provide --key-file/--vault-passphrase 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));

Expand Down Expand Up @@ -419,4 +442,92 @@ vault
await launchDashboard(cas);
}, getJson));

await program.parseAsync();
// ---------------------------------------------------------------------------
// recipient add / remove / list
// ---------------------------------------------------------------------------
const recipient = program
.command('recipient')
.description('Manage envelope encryption recipients');

recipient
.command('add <slug>')
.description('Add a recipient to an envelope-encrypted asset')
.requiredOption('--label <label>', 'Label for the new recipient')
.requiredOption('--key-file <path>', 'Path to 32-byte key file for the new recipient')
.requiredOption('--existing-key-file <path>', 'Path to key file of an existing recipient')
.option('--cwd <dir>', 'Git working directory', '.')
.action(runAction(async (slug, opts) => {
const cas = createCas(opts.cwd);
const treeOid = await cas.resolveVaultEntry({ slug });
const manifest = await cas.readManifest({ treeOid });

const existingKey = readKeyFile(opts.existingKeyFile);
const newRecipientKey = readKeyFile(opts.keyFile);

const updated = await cas.addRecipient({
manifest,
existingKey,
newRecipientKey,
label: opts.label,
});

const newTreeOid = await cas.createTree({ manifest: updated });
await cas.addToVault({ slug, treeOid: newTreeOid, force: true });

const json = program.opts().json;
if (json) {
process.stdout.write(`${JSON.stringify({ treeOid: newTreeOid })}\n`);
} else {
process.stdout.write(`${newTreeOid}\n`);
}
}, getJson));

recipient
.command('remove <slug>')
.description('Remove a recipient from an envelope-encrypted asset')
.requiredOption('--label <label>', 'Label of the recipient to remove')
.option('--cwd <dir>', 'Git working directory', '.')
.action(runAction(async (slug, opts) => {
const cas = createCas(opts.cwd);
const treeOid = await cas.resolveVaultEntry({ slug });
const manifest = await cas.readManifest({ treeOid });

const updated = await cas.removeRecipient({ manifest, label: opts.label });

const newTreeOid = await cas.createTree({ manifest: updated });
await cas.addToVault({ slug, treeOid: newTreeOid, force: true });

const json = program.opts().json;
if (json) {
process.stdout.write(`${JSON.stringify({ treeOid: newTreeOid })}\n`);
} else {
process.stdout.write(`${newTreeOid}\n`);
}
}, getJson));

recipient
.command('list <slug>')
.description('List recipients of an envelope-encrypted asset')
.option('--cwd <dir>', 'Git working directory', '.')
.action(runAction(async (slug, opts) => {
const cas = createCas(opts.cwd);
const treeOid = await cas.resolveVaultEntry({ slug });
const manifest = await cas.readManifest({ treeOid });

const labels = await cas.listRecipients(manifest);
const json = program.opts().json;
if (json) {
process.stdout.write(`${JSON.stringify(labels)}\n`);
} else {
for (const label of labels) {
process.stdout.write(`${label}\n`);
}
}
}, getJson));

await program.parseAsync();

// Flush stdout/stderr before exiting — spawned git child processes leave
// libuv handles that prevent natural exit in containerized environments.
const code = process.exitCode || 0;
process.stdout.write('', () => process.stderr.write('', () => process.exit(code)));
20 changes: 18 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import Manifest from "./src/domain/value-objects/Manifest.js";
import type { EncryptionMeta, ManifestData, CompressionMeta, KdfParams, SubManifestRef } from "./src/domain/value-objects/Manifest.js";
import type { EncryptionMeta, ManifestData, CompressionMeta, KdfParams, SubManifestRef, RecipientEntry } from "./src/domain/value-objects/Manifest.js";
import Chunk from "./src/domain/value-objects/Chunk.js";
import CasService from "./src/domain/services/CasService.js";
import type {
Expand All @@ -18,7 +18,7 @@ import type {
} from "./src/domain/services/CasService.js";

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

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

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

restoreFile(options: {
Expand Down Expand Up @@ -349,6 +351,20 @@ export default class ContentAddressableStore {

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

addRecipient(options: {
manifest: Manifest;
existingKey: Buffer;
newRecipientKey: Buffer;
label: string;
}): Promise<Manifest>;

removeRecipient(options: {
manifest: Manifest;
label: string;
}): Promise<Manifest>;

listRecipients(manifest: Manifest): Promise<string[]>;

// Vault — delegates to VaultService

static VAULT_REF: string;
Expand Down
45 changes: 44 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,10 @@ export default class ContentAddressableStore {
* @param {string} [options.passphrase] - Derive encryption key from passphrase.
* @param {Object} [options.kdfOptions] - KDF options when using passphrase.
* @param {{ algorithm: 'gzip' }} [options.compression] - Enable compression.
* @param {Array<{label: string, key: Buffer}>} [options.recipients] - Envelope recipients (mutually exclusive with encryptionKey/passphrase).
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>} 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({
Expand All @@ -278,6 +279,7 @@ export default class ContentAddressableStore {
passphrase,
kdfOptions,
compression,
recipients,
});
}

Expand All @@ -291,6 +293,7 @@ export default class ContentAddressableStore {
* @param {string} [options.passphrase] - Derive encryption key from passphrase.
* @param {Object} [options.kdfOptions] - KDF options when using passphrase.
* @param {{ algorithm: 'gzip' }} [options.compression] - Enable compression.
* @param {Array<{label: string, key: Buffer}>} [options.recipients] - Envelope recipients (mutually exclusive with encryptionKey/passphrase).
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>} The resulting manifest.
*/
async store(options) {
Expand Down Expand Up @@ -421,6 +424,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<import('./src/domain/value-objects/Manifest.js').default>}
*/
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<import('./src/domain/value-objects/Manifest.js').default>}
*/
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<string[]>}
*/
async listRecipients(manifest) {
const service = await this.#getService();
return service.listRecipients(manifest);
}

// ---------------------------------------------------------------------------
// Vault — delegates to VaultService
// ---------------------------------------------------------------------------
Expand Down
Loading