From e993971f788c3c1dab4f996ceae8e055e94849cc Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 28 Feb 2026 08:00:38 -0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20M12=20Carousel=20=E2=80=94=20key=20?= =?UTF-8?q?rotation=20without=20re-encrypting=20data=20(v5.2.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add rotateKey() on CasService for re-wrapping the DEK with a new KEK, leaving data blobs untouched. Add rotateVaultPassphrase() on the facade for atomic vault-wide passphrase rotation. keyVersion tracking at both manifest-level and per-recipient level enables audit compliance. New CLI commands: `git cas rotate` and `git cas vault rotate`. New error code: ROTATION_NOT_SUPPORTED. 27 new unit tests (784 total). All three runtimes pass. --- CHANGELOG.md | 11 + GUIDE.md | 67 ++++ README.md | 36 ++- ROADMAP.md | 35 +- bin/actions.js | 1 + bin/git-cas.js | 78 ++++- docs/API.md | 71 ++++ docs/SECURITY.md | 26 +- index.d.ts | 17 + index.js | 134 ++++++++ jsr.json | 2 +- package.json | 2 +- src/domain/schemas/ManifestSchema.d.ts | 2 + src/domain/schemas/ManifestSchema.js | 2 + src/domain/services/CasService.d.ts | 7 + src/domain/services/CasService.js | 90 ++++++ src/domain/value-objects/Manifest.d.ts | 2 + test/integration/vault-cli.test.js | 62 ++++ .../schemas/ManifestSchema.keyVersion.test.js | 108 +++++++ .../services/CasService.rotateKey.test.js | 303 ++++++++++++++++++ .../ContentAddressableStore.rotation.test.js | 175 ++++++++++ 21 files changed, 1197 insertions(+), 34 deletions(-) create mode 100644 test/unit/domain/schemas/ManifestSchema.keyVersion.test.js create mode 100644 test/unit/domain/services/CasService.rotateKey.test.js create mode 100644 test/unit/facade/ContentAddressableStore.rotation.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c54c518..15cfce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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.2.0] — Carousel (2026-02-28) + +### Added +- **Key rotation without re-encrypting data** — `CasService.rotateKey()` re-wraps the DEK with a new KEK, leaving data blobs untouched. Enables key compromise response without re-storing assets. +- **`keyVersion` tracking** — manifest-level and per-recipient `keyVersion` counters track rotation history for audit compliance. Optional field, backward-compatible with existing manifests. +- **`git cas rotate` CLI command** — rotate a recipient's key via `--slug` (vault round-trip) or `--oid` (manifest-only). Supports `--label` for targeted single-recipient rotation. +- **`rotateVaultPassphrase()`** — rotate the vault-level encryption passphrase across all envelope-encrypted entries in a single atomic commit. Non-envelope entries are skipped with reporting. +- **`git cas vault rotate` CLI command** — rotate vault passphrase from the command line with `--old-passphrase` and `--new-passphrase`. +- **`ROTATION_NOT_SUPPORTED` error code** — thrown when `rotateKey()` is called on a manifest without envelope encryption (legacy/direct-key). +- 27 new unit tests covering key rotation, schema validation, and vault passphrase rotation. + ## [5.1.0] — Locksmith (2026-02-28) ### Added diff --git a/GUIDE.md b/GUIDE.md index 5a898e7..b6b4e8c 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -912,6 +912,73 @@ const manifest = await cas.storeFile({ --- +## 11b. Multi-Recipient Encryption & Key Rotation + +*New in v5.1.0 (recipients), v5.2.0 (rotation).* + +### Envelope Encryption + +Instead of encrypting with a single key, you can encrypt for multiple recipients. A random DEK encrypts the data; each recipient's KEK wraps the DEK: + +```javascript +const manifest = await cas.store({ + source, slug: 'shared', filename: 'shared.bin', + recipients: [ + { label: 'alice', key: aliceKey }, + { label: 'bob', key: bobKey }, + ], +}); +``` + +Any recipient can restore independently: + +```javascript +const { buffer } = await cas.restore({ manifest, encryptionKey: bobKey }); +``` + +### Key Rotation + +When a key is compromised, rotate it without re-encrypting data: + +```javascript +const rotated = await cas.rotateKey({ + manifest, oldKey: aliceOldKey, newKey: aliceNewKey, label: 'alice', +}); +// Persist the updated manifest +const treeOid = await cas.createTree({ manifest: rotated }); +``` + +The `keyVersion` counter increments with each rotation: + +```javascript +console.log(rotated.encryption.keyVersion); // 1 +console.log(rotated.encryption.recipients[0].keyVersion); // 1 +``` + +### Vault Passphrase Rotation + +Rotate the master passphrase for all vault entries at once: + +```javascript +const { commitOid, rotatedSlugs, skippedSlugs } = await cas.rotateVaultPassphrase({ + oldPassphrase: 'old-secret', newPassphrase: 'new-secret', +}); +``` + +Non-envelope entries (direct-key encryption) are skipped — they require manual re-store. + +### CLI + +```bash +# Rotate a single recipient's key +git cas rotate --slug shared --old-key-file old.key --new-key-file new.key --label alice + +# Rotate vault passphrase +git cas vault rotate --old-passphrase old-secret --new-passphrase new-secret +``` + +--- + ## 12. Merkle Manifests *New in v2.0.0.* diff --git a/README.md b/README.md index 27dc658..af478f5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ We use the object database. - **Chunked storage** big files become stable, reusable blobs. Fixed-size or content-defined chunking (CDC). - **Optional AES-256-GCM encryption** store secrets without leaking plaintext into the ODB. - **Multi-recipient encryption** envelope model (DEK/KEK) — add/remove access without re-encrypting data. +- **Key rotation** rotate keys without re-encrypting data blobs. Respond to compromise in seconds. - **Compression** gzip before encryption — smaller blobs, same round-trip. - **Passphrase encryption** derive keys from passphrases via PBKDF2 or scrypt — no raw key management. - **Merkle manifests** large files auto-split into sub-manifests for scalability. @@ -37,6 +38,32 @@ We use the object database. git-cas demo +## What's new in v5.2.0 + +**Key rotation without re-encrypting data** — Rotate a recipient's key by re-wrapping the DEK. Data blobs are never touched. Respond to key compromise in seconds, not hours. + +```js +// Rotate a single recipient's key +const rotated = await cas.rotateKey({ + manifest, oldKey: aliceOldKey, newKey: aliceNewKey, label: 'alice', +}); + +// Rotate the vault passphrase (all entries, atomic commit) +const { commitOid, rotatedSlugs, skippedSlugs } = await cas.rotateVaultPassphrase({ + oldPassphrase: 'old-secret', newPassphrase: 'new-secret', +}); +``` + +```bash +# Rotate a recipient key +git cas rotate --slug prod-secrets --old-key-file old.key --new-key-file new.key + +# Rotate vault passphrase +git cas vault rotate --old-passphrase old-secret --new-passphrase new-secret +``` + +See [CHANGELOG.md](./CHANGELOG.md) for the full list of changes. + ## What's new in v5.1.0 **Multi-recipient envelope encryption** — Each file is encrypted with a random DEK; recipient KEKs wrap the DEK. Add or remove team members without re-encrypting data. @@ -225,6 +252,13 @@ git cas recipient list shared git cas recipient add shared --label carol --key-file ./keys/carol.key --existing-key-file ./keys/alice.key git cas recipient remove shared --label bob +# Key rotation (no re-encryption) +git cas rotate --slug shared --old-key-file old.key --new-key-file new.key +git cas rotate --slug shared --old-key-file old.key --new-key-file new.key --label alice + +# Vault passphrase rotation +git cas vault rotate --old-passphrase old-secret --new-passphrase new-secret + # Encrypted vault round-trip (passphrase via env var or --vault-passphrase flag) export GIT_CAS_PASSPHRASE="secret" git cas vault init @@ -254,7 +288,7 @@ That's git-cas. The orphan branch gives you none of: | | Orphan branch | git-cas | |---|---|---| -| **Encryption** | None — plaintext forever in history | AES-256-GCM + passphrase KDF + multi-recipient | +| **Encryption** | None — plaintext forever in history | AES-256-GCM + passphrase KDF + multi-recipient + key rotation | | **Large files** | Bloats `git clone` for everyone | Chunked, restored on demand | | **Dedup** | None | Chunk-level content addressing | | **Integrity** | Git SHA-1 | SHA-256 per chunk + GCM auth tag | diff --git a/ROADMAP.md b/ROADMAP.md index a42a8aa..523c18b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,29 +138,29 @@ Return and throw semantics for every public method (current and planned). - **Throws:** `CasError('MISSING_KEY')` if encrypted and no key provided. - **Memory:** O(chunkSize) — never buffers full file. -### `rotateKey({ manifest, oldKey, newKey, label? })` *(planned — Task 12.1)* +### `rotateKey({ manifest, oldKey, newKey, label? })` *(implemented — v5.2.0)* - **Returns:** `Promise` — updated manifest with re-wrapped DEK and incremented `keyVersion`. - **Throws:** `CasError('DEK_UNWRAP_FAILED')` if `oldKey` cannot unwrap the DEK. - **Throws:** `CasError('ROTATION_NOT_SUPPORTED')` if manifest uses legacy (non-envelope) encryption. - **Side effects:** None. Caller must persist via `createTree()`. -### `addRecipient({ manifest, existingKey, newRecipientKey, label })` *(planned — Task 11.2)* +### `addRecipient({ manifest, existingKey, newRecipientKey, label })` *(implemented — v5.1.0)* - **Returns:** `Promise` — updated manifest with additional recipient entry. - **Throws:** `CasError('DEK_UNWRAP_FAILED')` if `existingKey` is wrong. - **Throws:** `CasError('RECIPIENT_ALREADY_EXISTS')` if `label` already exists. - **Side effects:** None. Caller must persist. -### `removeRecipient({ manifest, label })` *(planned — Task 11.2)* +### `removeRecipient({ manifest, label })` *(implemented — v5.1.0)* - **Returns:** `Promise` — updated manifest without the named recipient. - **Throws:** `CasError('RECIPIENT_NOT_FOUND')` if `label` not in recipient list. - **Throws:** `CasError('CANNOT_REMOVE_LAST_RECIPIENT')` if only 1 recipient remains. -### CLI: `git cas verify --oid | --slug ` *(planned — Task 9.2)* +### CLI: `git cas verify --oid | --slug ` *(implemented — v4.0.1)* - **Output:** `ok` on success, `fail` on failure. - **Exit 0:** All chunks verified. - **Exit 1:** Verification failed or error. -### CLI: `git cas rotate --slug --old-key-file --new-key-file ` *(planned — Task 12.3)* +### CLI: `git cas rotate --slug --old-key-file --new-key-file ` *(implemented — v5.2.0)* - **Output:** New tree OID on success. - **Exit 0:** Rotation succeeded, vault updated. - **Exit 1:** Wrong old key, unsupported manifest, or vault error. @@ -191,7 +191,7 @@ Return and throw semantics for every public method (current and planned). | v3.1.0 | M13 | Bijou | TUI dashboard & progress | ✅ | | v5.0.0 | M10 | Hydra | Content-defined chunking | ✅ | | v5.1.0 | M11 | Locksmith | Multi-recipient encryption | ✅ | -| v5.2.0 | M12 | Carousel | Key rotation | | +| v5.2.0 | M12 | Carousel | Key rotation | ✅ | --- @@ -205,7 +205,7 @@ M8 Spit Shine + M9 Cockpit (v4.0.1) ✅ M10 Hydra ──────────── ✅ v5.0.0 M11 Locksmith ──────── ✅ v5.1.0 - └──► M12 Carousel ── (ready) + └──► M12 Carousel ── ✅ v5.2.0 ``` --- @@ -222,7 +222,7 @@ M11 Locksmith ──────── ✅ v5.1.0 | M9 | Cockpit | CLI improvements | v4.0.1 | 4 | ~190 | ~5h | ✅ CLOSED | | M10| Hydra | Content-defined chunking | v5.0.0 | 4 | ~690 | ~22h | ✅ CLOSED | | M11| Locksmith | Multi-recipient encryption | v5.1.0 | 4 | ~580 | ~20h | ✅ CLOSED | -| M12| Carousel | Key rotation | v5.2.0 | 4 | ~400 | ~13h | open | +| M12| Carousel | Key rotation | v5.2.0 | 4 | ~400 | ~13h | ✅ CLOSED | Completed task cards are in [COMPLETED_TASKS.md](./COMPLETED_TASKS.md). Superseded tasks are in [GRAVEYARD.md](./GRAVEYARD.md). @@ -256,11 +256,14 @@ All tasks completed (11.1–11.4). See [COMPLETED_TASKS.md](./COMPLETED_TASKS.md --- -# M12 — Carousel (v5.2.0) -**Theme:** Key rotation without re-encrypting data. The DEK/KEK model from M11 makes this possible — rotating a key means re-wrapping the DEK, not re-encrypting blobs. Includes vault-level rotation for changing the master passphrase. +# M12 — Carousel (v5.2.0) ✅ CLOSED + +All tasks completed (12.1–12.4). See [COMPLETED_TASKS.md](./COMPLETED_TASKS.md). --- +# Completed Task Cards (M12) + ## Task 12.1: Key rotation workflow **User Story** @@ -512,8 +515,8 @@ Competitive landscape for content-addressed storage, encrypted binary assets, an | Client-side encryption | ✅ AES-256-GCM | — | ❌ | ✅ GPG | ✅ AES-256-CTR + Poly1305 | ✅ ChaCha20-Poly1305 | ❌ | Protect data at rest in untrusted storage | git-cas is the only Git-native tool with integrated encryption | — | | Authenticated encryption (AEAD) | ✅ GCM auth tag | — | ❌ | ⚠️ GPG signature optional | ✅ Poly1305 | ✅ Poly1305 | ❌ | Tamper detection + confidentiality | GCM and Poly1305 both provide authentication. GPG can but doesn't by default | — | | Per-chunk encryption | ✅ Streaming | — | ❌ | ❌ Whole-file | ❌ Per-pack | ✅ 64 KiB chunks | ❌ | Encrypt without buffering full file | git-cas and Age both stream; Restic encrypts packed blobs | — | -| Multi-recipient encryption | ❌ | ✅ M11 Locksmith | ❌ | ✅ Multiple GPG keys | ✅ Multiple passwords | ✅ Multiple X25519 | ❌ | Team access without sharing a single key | Envelope encryption (DEK/KEK model). ~220 LoC, ~8h (Task 11.1) | DEK/KEK model + recipient management. ~580 LoC total, ~20h (M11) | -| Key rotation (no re-encrypt) | ❌ | 🗓 M12 Carousel | N/A | ⚠️ Can add keys; revoke requires re-encrypt | ✅ Re-wrap master key | ❌ | N/A | Respond to key compromise without re-storing data | Requires DEK/KEK model. Re-wraps DEK, data blobs untouched | Depends on M11. rotateKey + vault rotation. ~400 LoC, ~13h (M12) | +| Multi-recipient encryption | ✅ M11 Locksmith | — | ❌ | ✅ Multiple GPG keys | ✅ Multiple passwords | ✅ Multiple X25519 | ❌ | Team access without sharing a single key | Envelope encryption (DEK/KEK model) | — | +| Key rotation (no re-encrypt) | ✅ M12 Carousel | — | N/A | ⚠️ Can add keys; revoke requires re-encrypt | ✅ Re-wrap master key | ❌ | N/A | Respond to key compromise without re-storing data | Re-wraps DEK, data blobs untouched | — | | KDF / passphrase keys | ✅ PBKDF2, scrypt | — | ❌ | ✅ GPG S2K | ✅ scrypt | ✅ scrypt | ❌ | Derive keys from passwords instead of managing raw bytes | git-cas supports both PBKDF2 (100k iterations) and scrypt | — | | Argon2 KDF | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | Memory-hard KDF resists GPU/ASIC attacks | No tool in this space supports Argon2 yet. Would require native/WASM addon | ~80 LoC + native dep. ~4h. Low priority — scrypt is adequate | | Hardware security (YubiKey/HSM) | ❌ | ❌ | ❌ | ✅ GPG smartcard | ❌ | ✅ age-plugin-yubikey | ❌ | Keys never leave hardware token | Would require plugin system or GPG integration | Plugin architecture + PIV applet integration. ~300 LoC, ~16h. Low priority | @@ -568,9 +571,9 @@ Competitive landscape for content-addressed storage, encrypted binary assets, an | Multi-runtime support | ✅ Node, Bun, Deno | — | ❌ Go only | ❌ Haskell only | ❌ Go only | ✅ Go, Rust, JS, Java, Python | ❌ Python only | Same library works across JS runtimes | Only git-cas and Age support multiple runtimes | — | | Progress events (structured) | ✅ ObservabilityPort (metric/log/span) | — | ✅ Transfer protocol | ⚠️ Terminal bars | ✅ JSON Lines | ❌ | ⚠️ Terminal bars | Build progress bars, logging, monitoring | git-cas emits typed metrics per chunk via ObservabilityPort (v4.0.0) | — | | CLI progress feedback | ✅ Animated (bijou) | — | ✅ | ✅ | ✅ | ❌ | ✅ | Users know operations are working | Implemented in v3.1.0 (M13 Bijou) | — | -| Structured output (--json) | ❌ | 🗓 M9 Cockpit | ❌ | ❌ | ✅ `--json` | ❌ | ✅ `--json` | CI/CD pipeline integration | Restic is the gold standard here (JSON Lines for all output) | Global `--json` flag. ~50 LoC, ~1.5h (Task 9.3) | -| CLI `verify` command | ❌ API only | 🗓 M9 Cockpit | ✅ Implicit on checkout | ✅ `annex fsck` | ✅ `restic check` | ❌ | ✅ `dvc check-ignore` | Audit integrity without restoring | API exists (`verifyIntegrity`); CLI just needs to expose it | 25 LoC, ~1h (Task 9.2) | -| Actionable error messages | ❌ Generic `err.message` | 🗓 M9 Cockpit | ⚠️ | ⚠️ | ✅ | ❌ | ✅ | Users know what went wrong and what to do next | Error codes exist but CLI doesn't show hints | Error handler + hint map. ~45 LoC, ~1h (Task 9.4) | +| Structured output (--json) | ✅ `--json` | — | ❌ | ❌ | ✅ `--json` | ❌ | ✅ `--json` | CI/CD pipeline integration | Global `--json` flag on all commands | — | +| CLI `verify` command | ✅ `git cas verify` | — | ✅ Implicit on checkout | ✅ `annex fsck` | ✅ `restic check` | ❌ | ✅ `dvc check-ignore` | Audit integrity without restoring | Per-chunk SHA-256 verification | — | +| Actionable error messages | ✅ Hints | — | ⚠️ | ⚠️ | ✅ | ❌ | ✅ | Users know what went wrong and what to do next | Error codes + actionable hint map | — | --- @@ -593,7 +596,7 @@ Competitive landscape for content-addressed storage, encrypted binary assets, an |---|---|---|---|---|---|---| | **Core identity** | Git-native CAS with encryption | Git large file offloading | Distributed file management | Encrypted backup with dedup | File encryption primitive | ML data version control | | **Strongest at** | Git ODB integration, pluggable codecs, Merkle manifests, vault | Simplicity, file locking, ecosystem adoption | Backend diversity, location tracking, metadata views | CDC dedup, retention policies, FUSE mount | Multi-recipient, HSM, multi-language, simplicity | ML pipelines, experiment tracking, Python ecosystem | -| **Weakest at** | No multi-backend, single-key encryption, gzip only, no CDC | No encryption, no compression, requires server | Complexity, Haskell-only, no CDC | No Git integration, no library API | Not a storage system | No encryption, no chunking, no streaming | +| **Weakest at** | No multi-backend, gzip only | No encryption, no compression, requires server | Complexity, Haskell-only, no CDC | No Git integration, no library API | Not a storage system | No encryption, no chunking, no streaming | | **Server required** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | **Best use case** | Encrypted binary assets in Git repos | Large files in GitHub/GitLab repos | Distributed archive management | Encrypted backups of filesystems | Encrypting files for recipients | ML model/data versioning | diff --git a/bin/actions.js b/bin/actions.js index 444b0c8..c4d58ec 100644 --- a/bin/actions.js +++ b/bin/actions.js @@ -13,6 +13,7 @@ const HINTS = { 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', + ROTATION_NOT_SUPPORTED: 'Key rotation requires envelope encryption — store with --recipient first', }; /** diff --git a/bin/git-cas.js b/bin/git-cas.js index 3ef0491..7e12ab2 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('5.0.0') + .version('5.2.0') .option('-q, --quiet', 'Suppress progress output') .option('--json', 'Output results as JSON'); @@ -429,6 +429,40 @@ vault } }, getJson)); +// --------------------------------------------------------------------------- +// vault rotate +// --------------------------------------------------------------------------- +vault + .command('rotate') + .description('Rotate vault-level encryption passphrase') + .requiredOption('--old-passphrase ', 'Current vault passphrase') + .requiredOption('--new-passphrase ', 'New vault passphrase') + .option('--algorithm ', 'KDF algorithm (pbkdf2 or scrypt)') + .option('--cwd ', 'Git working directory', '.') + .action(runAction(async (opts) => { + const cas = createCas(opts.cwd); + const rotateOpts = { + oldPassphrase: opts.oldPassphrase, + newPassphrase: opts.newPassphrase, + }; + if (opts.algorithm) { + rotateOpts.kdfOptions = { algorithm: opts.algorithm }; + } + const { commitOid, rotatedSlugs, skippedSlugs } = await cas.rotateVaultPassphrase(rotateOpts); + const json = program.opts().json; + if (json) { + process.stdout.write(`${JSON.stringify({ commitOid, rotatedSlugs, skippedSlugs })}\n`); + } else { + process.stdout.write(`${commitOid}\n`); + if (rotatedSlugs.length) { + process.stderr.write(`rotated: ${rotatedSlugs.join(', ')}\n`); + } + if (skippedSlugs.length) { + process.stderr.write(`skipped: ${skippedSlugs.join(', ')}\n`); + } + } + }, getJson)); + // --------------------------------------------------------------------------- // vault dashboard // --------------------------------------------------------------------------- @@ -442,6 +476,48 @@ vault await launchDashboard(cas); }, getJson)); +// --------------------------------------------------------------------------- +// rotate +// --------------------------------------------------------------------------- +program + .command('rotate') + .description('Rotate an encryption key without re-encrypting data') + .option('--slug ', 'Resolve tree OID from vault slug') + .option('--oid ', 'Direct tree OID') + .requiredOption('--old-key-file ', 'Path to current 32-byte key file') + .requiredOption('--new-key-file ', 'Path to new 32-byte key file') + .option('--label