diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f25c6..c54c518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/Dockerfile b/Dockerfile index 13dd6e9..f678987 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 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..3ef0491 100755 --- a/bin/git-cas.js +++ b/bin/git-cas.js @@ -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'); @@ -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 ') .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 || 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)); @@ -419,4 +442,92 @@ vault await launchDashboard(cas); }, getJson)); -await program.parseAsync(); \ No newline at end of file +// --------------------------------------------------------------------------- +// 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