diff --git a/.gitignore b/.gitignore index 0f49e6d..281dd2b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ .DS_Store .vite/ coverage/ +.claude/ +lastchat.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f2bb9..85be86e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **`CryptoPortBase.sha256()` type** — `index.d.ts` declaration corrected from `string | Promise` to `Promise`, matching the async implementation since v5.2.3. +- **`keyLength` passthrough** — `KeyResolver.#resolveKeyFromPassphrase` and `deriveKekFromKdf` now forward `kdf.keyLength` to `deriveKey()`, fixing a latent bug for vaults configured with non-default key lengths. +- **Deno test compatibility** — `createCryptoAdapter.test.js` no longer crashes on Deno by guarding immutable `globalThis.Deno` restoration with try/catch and skipping Node-only tests on non-Node runtimes. +- **README wording** — "no public API changes" corrected to "no breaking API changes" in the v5.2.3 summary. +- **Barrel re-export description** — README and CHANGELOG now show the correct `export { default as X } from '...'` syntax. +- **Vestigial `lastchat.txt`** removed from `jsr.json` exclude list. + +### Changed +- **`keyResolver` is now private** — `CasService.keyResolver` changed to `#keyResolver`, preventing external access to an internal implementation detail. +- **`VaultPassphraseRotator.js` → `rotateVaultPassphrase.js`** — renamed to follow camelCase convention for files that export a function (PascalCase is reserved for classes). +- **`resolveChunker` validation** — `chunkSize` now validated as a finite positive number before constructing `FixedChunker`; invalid values fall through to CasService default. +- **`@fileoverview` JSDoc** added to `FileIOHelper.js`, `createCryptoAdapter.js`, and `resolveChunker.js`. +- **`KeyResolver` design note** — class JSDoc now documents the direct `CryptoPort.deriveKey()` call (bypasses `CasService.deriveKey()`). +- **Long function signature wrapped** — `rotateVaultPassphrase()` export signature broken across multiple lines. +- **Test hardening** — salt assertion in `KeyResolver.resolveForStore`, `keyLength` round-trip test, `resolveChunker` edge-case tests, guarded `rmSync` teardown in `FileIOHelper.test.js`. + +## [5.2.3] — Prism refactor (2026-03-03) + ### Changed +- **Async `sha256()` across all adapters** — `NodeCryptoAdapter.sha256()` now returns `Promise` (was sync `string`), matching Bun and Web adapters. Fixes Liskov Substitution violation; all callers already `await`. `CryptoPort` JSDoc and `CasService.d.ts` updated to `Promise`. +- **Extract `KeyResolver`** — ~170 lines of key resolution logic (`wrapDek`, `unwrapDek`, `resolveForDecryption`, `resolveForStore`, `resolveRecipients`, `resolveKeyForRecipients`, passphrase derivation, mutual-exclusion validation) extracted from `CasService` into `src/domain/services/KeyResolver.js`. CasService delegates via `this.keyResolver`. No public API changes. 24 new unit tests. +- **Move `createCryptoAdapter`** — runtime crypto detection moved from `index.js` to `src/infrastructure/adapters/createCryptoAdapter.js`; test helper now delegates instead of duplicating. +- **Factor out `resolveChunker`** — chunker factory resolution moved from `index.js` private method to `src/infrastructure/chunkers/resolveChunker.js`. +- **Move file I/O helpers** — `storeFile()` and `restoreFile()` moved from `index.js` to `src/infrastructure/adapters/FileIOHelper.js`; all `node:*` imports removed from facade. +- **Factor out `rotateVaultPassphrase`** — passphrase rotation orchestration (~100 lines with retry/backoff) moved from `index.js` to `src/domain/services/rotateVaultPassphrase.js`; `CasError` and `buildKdfMetadata` imports removed from facade. +- **Private `#config` field** — facade constructor stores options in a single private `#config` field instead of 10 public `this.fooConfig` properties. +- **Barrel re-exports** — 10 re-export-only modules (`NodeCryptoAdapter`, `Manifest`, `Chunk`, ports, observers, chunkers) converted to `export { default as X } from '...'` form, eliminating unnecessary local bindings. +- **Configurable retry** — `rotateVaultPassphrase()` now accepts optional `maxRetries` (default 3) and `retryBaseMs` (default 50) options for tuning optimistic-concurrency backoff. - **Deterministic fuzz test** — envelope fuzz round-trip test now uses a seeded xorshift32 PRNG instead of `Math.random()`, making failures reproducible across runs. - **DRY chunk verification** — extracted `_readAndVerifyChunk()` in `CasService`; both the buffered and streaming restore paths now delegate to the same single-chunk verification method. - **DRY KDF metadata** — extracted `buildKdfMetadata()` helper (`src/domain/helpers/buildKdfMetadata.js`); `VaultService` and `ContentAddressableStore` both call it instead of duplicating the KDF object construction. diff --git a/README.md b/README.md index 9026bd7..b26c654 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,17 @@ We use the object database. git-cas demo +## What's new in v5.2.3 + +**Internal refactoring — no breaking API changes.** The facade, CasService, and crypto adapters were restructured for better separation of concerns: + +- **Consistent async `sha256()`** — `NodeCryptoAdapter.sha256()` now returns `Promise` like Bun and Web adapters, fixing a Liskov Substitution violation. +- **`KeyResolver` extracted** — ~170 lines of key resolution logic (DEK wrap/unwrap, passphrase derivation, envelope recipients) moved from CasService (1085 → 909 lines) into a dedicated `KeyResolver` service. +- **Facade decomposed** — `createCryptoAdapter`, `resolveChunker`, `FileIOHelper`, `rotateVaultPassphrase`, and `buildKdfMetadata` extracted from the monolithic `index.js` into focused modules. +- **Barrel re-exports** — 10 re-export-only modules converted to `export { default as X } from '...'` form. + +See [CHANGELOG.md](./CHANGELOG.md) for the full list of changes. + ## What's new in v5.2.1 Bug fix: `rotateVaultPassphrase` now honours `kdfOptions.algorithm` — previously the `--algorithm` flag was silently ignored, always reusing the old KDF algorithm. CLI flag tables in `docs/API.md` are now split per command with `--cwd` documented. diff --git a/ROADMAP.md b/ROADMAP.md index 8ab1781..99ddfc4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -605,48 +605,47 @@ All tasks completed (13.1–13.6). See [COMPLETED_TASKS.md](./COMPLETED_TASKS.md --- -## M15 — Prism (code hygiene) +## M15 — Prism (code hygiene) ✅ Consistency and DRY fixes surfaced by architecture audit. No new features, no API changes. -### 14.1 — Consistent async `sha256()` across CryptoPort adapters +### 15.1 — Consistent async `sha256()` across CryptoPort adapters ✅ **Problem:** `NodeCryptoAdapter.sha256()` returns `string` (sync), while `BunCryptoAdapter` and `WebCryptoAdapter` return `Promise`. Callers must defensively `await` every call. This is a Liskov Substitution violation — adapters are not interchangeable without the caller knowing which one it got. **Fix:** Make `NodeCryptoAdapter.sha256()` return `Promise` (wrap the sync result). All three adapters then have the same async signature. CasService already awaits every call via `_sha256()`, so no call-site changes are needed outside the adapter itself. **Files:** -- `src/infrastructure/adapters/NodeCryptoAdapter.js` — wrap return in `Promise.resolve()` +- `src/infrastructure/adapters/NodeCryptoAdapter.js` — add `async` keyword - `src/ports/CryptoPort.js` — update JSDoc to document `Promise` as the contract +- `src/domain/services/CasService.d.ts` — update `CryptoPort.sha256` type signature **Risk:** None. All callers already `await`. Changing sync→async is backward compatible for awaiting code. -**Tests:** Existing crypto adapter tests already assert on the resolved value. Add an explicit test: `expect(adapter.sha256(buf)).toBeInstanceOf(Promise)`. +**Tests:** Explicit `expect(adapter.sha256(buf)).toBeInstanceOf(Promise)` test added. -### 14.2 — Extract `KeyResolver` from CasService +### 15.2 — Extract `KeyResolver` from CasService ✅ -**Problem:** `CasService` is a ~1087-line god object. Key resolution logic (`_resolveDecryptionKey`, `_resolvePassphraseForDecryption`, `_resolveKeyForRecipients`, `_unwrapDek`, `_wrapDek`, `_validateKeySourceExclusive`) is ~70 lines of self-contained logic that has nothing to do with chunking, storage, or manifests. It's a distinct responsibility: "given a manifest and caller-provided credentials, produce the decryption key." +**Problem:** `CasService` is a ~1085-line god object. Key resolution logic (~170 lines) is a distinct responsibility: "given a manifest and caller-provided credentials, produce the decryption key." -**Fix:** Extract a `KeyResolver` class into `src/domain/services/KeyResolver.js`. It receives a `CryptoPort` via constructor injection. CasService delegates to it. +**Fix:** Extracted `KeyResolver` class into `src/domain/services/KeyResolver.js`. Receives `CryptoPort` via constructor injection. CasService delegates via `this.keyResolver`. -**API:** -```js -class KeyResolver { - constructor(crypto) { this.crypto = crypto; } - async resolveForDecryption(manifest, encryptionKey, passphrase) { ... } - async resolveForStore(encryptionKey, passphrase, kdfOptions) { ... } - async resolveRecipients(recipients) { ... } - async wrapDek(dek, kek) { ... } - async unwrapDek(recipientEntry, kek) { ... } -} -``` +**Extracted methods:** +- `validateKeySourceExclusive()` (static) — mutual-exclusion guard +- `wrapDek()` / `unwrapDek()` — DEK envelope operations +- `resolveForDecryption()` — manifest + credentials → decryption key +- `resolveForStore()` — key/passphrase → store-ready key + metadata +- `resolveRecipients()` — multi-recipient DEK generation +- `resolveKeyForRecipients()` — envelope unwrap with fallback iteration +- `#resolvePassphraseForDecryption()` — passphrase → key via manifest KDF +- `#resolveKeyFromPassphrase()` — passphrase + KDF params → derived key **Files:** - New: `src/domain/services/KeyResolver.js` -- Modified: `src/domain/services/CasService.js` — delegate key resolution -- New: `test/unit/domain/services/KeyResolver.test.js` +- Modified: `src/domain/services/CasService.js` — 1085 → 909 lines +- New: `test/unit/domain/services/KeyResolver.test.js` — 24 tests -**Risk:** Low — internal refactor, no public API change. CasService methods remain unchanged. +**Risk:** None — internal refactor, no public API change. CasService methods remain unchanged. --- diff --git a/index.d.ts b/index.d.ts index afd1e4d..c59de13 100644 --- a/index.d.ts +++ b/index.d.ts @@ -47,7 +47,7 @@ export declare class CdcChunker extends ChunkingPort { /** Abstract port for cryptographic operations. */ export declare class CryptoPortBase { - sha256(buf: Buffer): string | Promise; + sha256(buf: Buffer): Promise; randomBytes(n: number): Buffer; encryptBuffer( buffer: Buffer, @@ -401,6 +401,10 @@ export default class ContentAddressableStore { oldPassphrase: string; newPassphrase: string; kdfOptions?: Omit; + /** Maximum optimistic-concurrency retries on VAULT_CONFLICT. @default 3 */ + maxRetries?: number; + /** Base delay in ms for exponential backoff between retries. @default 50 */ + retryBaseMs?: number; }): Promise<{ commitOid: string; rotatedSlugs: string[]; diff --git a/index.js b/index.js index 5fd77e9..1a643fb 100644 --- a/index.js +++ b/index.js @@ -3,65 +3,47 @@ * @fileoverview Content Addressable Store - Managed blob storage in Git. */ -import { createReadStream, createWriteStream } from 'node:fs'; -import path from 'node:path'; -import { Readable, Transform } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; +// --------------------------------------------------------------------------- +// Imports used in the class body +// --------------------------------------------------------------------------- import CasService from './src/domain/services/CasService.js'; import VaultService from './src/domain/services/VaultService.js'; -import CasError from './src/domain/errors/CasError.js'; +import rotateVaultPassphrase from './src/domain/services/rotateVaultPassphrase.js'; import GitPersistenceAdapter from './src/infrastructure/adapters/GitPersistenceAdapter.js'; import GitRefAdapter from './src/infrastructure/adapters/GitRefAdapter.js'; -import NodeCryptoAdapter from './src/infrastructure/adapters/NodeCryptoAdapter.js'; -import Manifest from './src/domain/value-objects/Manifest.js'; -import Chunk from './src/domain/value-objects/Chunk.js'; -import CryptoPort from './src/ports/CryptoPort.js'; -import ChunkingPort from './src/ports/ChunkingPort.js'; -import ObservabilityPort from './src/ports/ObservabilityPort.js'; +import createCryptoAdapter from './src/infrastructure/adapters/createCryptoAdapter.js'; +import { storeFile, restoreFile } from './src/infrastructure/adapters/FileIOHelper.js'; import JsonCodec from './src/infrastructure/codecs/JsonCodec.js'; import CborCodec from './src/infrastructure/codecs/CborCodec.js'; import SilentObserver from './src/infrastructure/adapters/SilentObserver.js'; -import EventEmitterObserver from './src/infrastructure/adapters/EventEmitterObserver.js'; -import StatsCollector from './src/infrastructure/adapters/StatsCollector.js'; -import FixedChunker from './src/infrastructure/chunkers/FixedChunker.js'; -import CdcChunker from './src/infrastructure/chunkers/CdcChunker.js'; -import buildKdfMetadata from './src/domain/helpers/buildKdfMetadata.js'; +import resolveChunker from './src/infrastructure/chunkers/resolveChunker.js'; +// --------------------------------------------------------------------------- +// Re-exports — modules used in the class body +// --------------------------------------------------------------------------- export { CasService, VaultService, GitPersistenceAdapter, GitRefAdapter, - NodeCryptoAdapter, - CryptoPort, - ChunkingPort, - ObservabilityPort, - Manifest, - Chunk, JsonCodec, CborCodec, SilentObserver, - EventEmitterObserver, - StatsCollector, - FixedChunker, - CdcChunker, }; -/** - * Detects the best crypto adapter for the current runtime. - * @returns {Promise} A runtime-appropriate CryptoPort implementation. - */ -async function getDefaultCryptoAdapter() { - if (globalThis.Bun) { - const { default: BunCryptoAdapter } = await import('./src/infrastructure/adapters/BunCryptoAdapter.js'); - return new BunCryptoAdapter(); - } - if (globalThis.Deno) { - const { default: WebCryptoAdapter } = await import('./src/infrastructure/adapters/WebCryptoAdapter.js'); - return new WebCryptoAdapter(); - } - return new NodeCryptoAdapter(); -} +// --------------------------------------------------------------------------- +// Re-exports — barrel-only (no local binding needed) +// --------------------------------------------------------------------------- +export { default as NodeCryptoAdapter } from './src/infrastructure/adapters/NodeCryptoAdapter.js'; +export { default as CryptoPort } from './src/ports/CryptoPort.js'; +export { default as ChunkingPort } from './src/ports/ChunkingPort.js'; +export { default as ObservabilityPort } from './src/ports/ObservabilityPort.js'; +export { default as Manifest } from './src/domain/value-objects/Manifest.js'; +export { default as Chunk } from './src/domain/value-objects/Chunk.js'; +export { default as EventEmitterObserver } from './src/infrastructure/adapters/EventEmitterObserver.js'; +export { default as StatsCollector } from './src/infrastructure/adapters/StatsCollector.js'; +export { default as FixedChunker } from './src/infrastructure/chunkers/FixedChunker.js'; +export { default as CdcChunker } from './src/infrastructure/chunkers/CdcChunker.js'; /** * High-level facade for the Content Addressable Store library. @@ -84,20 +66,13 @@ export default class ContentAddressableStore { * @param {import('./src/ports/ChunkingPort.js').default} [options.chunker] - Pre-built ChunkingPort instance (advanced). */ constructor({ plumbing, chunkSize, codec, policy, crypto, observability, merkleThreshold, concurrency, chunking, chunker }) { - this.plumbing = plumbing; - this.chunkSizeConfig = chunkSize; - this.codecConfig = codec; - this.policyConfig = policy; - this.cryptoConfig = crypto; - this.observabilityConfig = observability; - this.merkleThresholdConfig = merkleThreshold; - this.concurrencyConfig = concurrency; - this.chunkingConfig = chunking; - this.chunkerConfig = chunker; + this.#config = { plumbing, chunkSize, codec, policy, crypto, observability, merkleThreshold, concurrency, chunking, chunker }; this.service = null; this.#servicePromise = null; } + /** @type {{ plumbing: *, chunkSize?: number, codec?: *, policy?: *, crypto?: *, observability?: *, merkleThreshold?: number, concurrency?: number, chunking?: *, chunker?: * }} */ + #config; /** @type {VaultService|null} */ #vault = null; #servicePromise = null; @@ -114,60 +89,33 @@ export default class ContentAddressableStore { return await this.#servicePromise; } - /** - * Resolves the chunker from config options. - * @private - * @returns {import('./src/ports/ChunkingPort.js').default|undefined} - */ - #resolveChunker() { - // Direct ChunkingPort instance takes precedence - if (this.chunkerConfig) { - return this.chunkerConfig; - } - // Build from declarative chunking config - if (this.chunkingConfig) { - if (this.chunkingConfig.strategy === 'cdc') { - return new CdcChunker({ - targetChunkSize: this.chunkingConfig.targetChunkSize, - minChunkSize: this.chunkingConfig.minChunkSize, - maxChunkSize: this.chunkingConfig.maxChunkSize, - }); - } - // 'fixed' or unrecognized — fall through to default (FixedChunker via CasService) - if (this.chunkingConfig.strategy === 'fixed' && this.chunkingConfig.chunkSize) { - return new FixedChunker({ chunkSize: this.chunkingConfig.chunkSize }); - } - } - // undefined → CasService will default to FixedChunker - return undefined; - } - /** * Constructs adapters, resolves crypto, and creates CasService + VaultService. * @private * @returns {Promise} */ async #initService() { + const cfg = this.#config; const persistence = new GitPersistenceAdapter({ - plumbing: this.plumbing, - policy: this.policyConfig + plumbing: cfg.plumbing, + policy: cfg.policy, }); - const crypto = this.cryptoConfig || await getDefaultCryptoAdapter(); - const chunker = this.#resolveChunker(); + const crypto = cfg.crypto || await createCryptoAdapter(); + const chunker = resolveChunker({ chunker: cfg.chunker, chunking: cfg.chunking }); this.service = new CasService({ persistence, - chunkSize: this.chunkSizeConfig, - codec: this.codecConfig || new JsonCodec(), + chunkSize: cfg.chunkSize, + codec: cfg.codec || new JsonCodec(), crypto, - observability: this.observabilityConfig || new SilentObserver(), - merkleThreshold: this.merkleThresholdConfig, - concurrency: this.concurrencyConfig, + observability: cfg.observability || new SilentObserver(), + merkleThreshold: cfg.merkleThreshold, + concurrency: cfg.concurrency, chunker, }); const ref = new GitRefAdapter({ - plumbing: this.plumbing, - policy: this.policyConfig, + plumbing: cfg.plumbing, + policy: cfg.policy, }); this.#vault = new VaultService({ persistence, ref, crypto }); @@ -229,7 +177,7 @@ export default class ContentAddressableStore { * @returns {number} */ get chunkSize() { - return this.service?.chunkSize || this.chunkSizeConfig || 256 * 1024; + return this.service?.chunkSize || this.#config.chunkSize || 256 * 1024; } /** @@ -270,19 +218,9 @@ export default class ContentAddressableStore { * @param {Array<{label: string, key: Buffer}>} [options.recipients] - Envelope recipients (mutually exclusive with encryptionKey/passphrase). * @returns {Promise} The resulting manifest. */ - async storeFile({ filePath, slug, filename, encryptionKey, passphrase, kdfOptions, compression, recipients }) { - const source = createReadStream(filePath); + async storeFile(options) { const service = await this.#getService(); - return await service.store({ - source, - slug, - filename: filename || path.basename(filePath), - encryptionKey, - passphrase, - kdfOptions, - compression, - recipients, - }); + return await storeFile(service, options); } /** @@ -312,20 +250,9 @@ export default class ContentAddressableStore { * @param {string} options.outputPath - Destination file path. * @returns {Promise<{ bytesWritten: number }>} */ - async restoreFile({ manifest, encryptionKey, passphrase, outputPath }) { + async restoreFile(options) { const service = await this.#getService(); - const iterable = service.restoreStream({ manifest, encryptionKey, passphrase }); - const readable = Readable.from(iterable); - const writable = createWriteStream(outputPath); - let bytesWritten = 0; - const counter = new Transform({ - transform(chunk, _encoding, cb) { - bytesWritten += chunk.length; - cb(null, chunk); - }, - }); - await pipeline(readable, counter, writable); - return { bytesWritten }; + return await restoreFile(service, options); } /** @@ -535,101 +462,13 @@ export default class ContentAddressableStore { * @param {string} options.oldPassphrase - Current vault passphrase. * @param {string} options.newPassphrase - New vault passphrase. * @param {Object} [options.kdfOptions] - KDF options for new passphrase. + * @param {number} [options.maxRetries=3] - Maximum optimistic-concurrency retries on VAULT_CONFLICT. + * @param {number} [options.retryBaseMs=50] - Base delay in ms for exponential backoff between retries. * @returns {Promise<{ commitOid: string, rotatedSlugs: string[], skippedSlugs: string[] }>} */ - async rotateVaultPassphrase({ oldPassphrase, newPassphrase, kdfOptions }) { + async rotateVaultPassphrase(options) { const service = await this.#getService(); const vault = await this.#getVault(); - - const MAX_RETRIES = 3; - const RETRY_BASE_MS = 50; - - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { - const state = await vault.readState(); - if (!state.metadata?.encryption) { - throw new CasError('Vault is not encrypted — nothing to rotate', 'VAULT_METADATA_INVALID'); - } - - const { kdf } = state.metadata.encryption; - const oldKek = await ContentAddressableStore.#deriveKekFromKdf(service, oldPassphrase, kdf); - const { key: newKek, salt: newSalt, params: newParams } = await service.deriveKey({ - passphrase: newPassphrase, ...kdfOptions, algorithm: kdfOptions?.algorithm || kdf.algorithm, - }); - - const result = await ContentAddressableStore.#rotateEntries({ service, entries: state.entries, oldKek, newKek }); - const newMetadata = ContentAddressableStore.#buildRotatedMetadata(state.metadata, newSalt, newParams); - - try { - const { commitOid } = await vault.writeCommit({ - entries: result.updatedEntries, - metadata: newMetadata, - parentCommitOid: state.parentCommitOid, - message: `vault: rotate passphrase (${result.rotatedSlugs.length} rotated, ${result.skippedSlugs.length} skipped)`, - }); - return { commitOid, rotatedSlugs: result.rotatedSlugs, skippedSlugs: result.skippedSlugs }; - } catch (err) { - if (err instanceof CasError && err.code === 'VAULT_CONFLICT' && attempt < MAX_RETRIES - 1) { - await new Promise((r) => setTimeout(r, RETRY_BASE_MS * (2 ** attempt))); - continue; - } - throw err; - } - } - /* c8 ignore next 2 */ - throw new CasError('Vault CAS retries exhausted', 'VAULT_CONFLICT'); - } - - /** - * Derives a KEK from a passphrase using stored KDF params. - * @private - */ - static async #deriveKekFromKdf(service, passphrase, kdf) { - const { key } = await service.deriveKey({ - passphrase, - salt: Buffer.from(kdf.salt, 'base64'), - algorithm: kdf.algorithm, - iterations: kdf.iterations, - cost: kdf.cost, - blockSize: kdf.blockSize, - parallelization: kdf.parallelization, - }); - return key; - } - - /** - * Iterates vault entries, rotating envelope-encrypted ones and skipping others. - * @private - */ - static async #rotateEntries({ service, entries, oldKek, newKek }) { - const rotatedSlugs = []; - const skippedSlugs = []; - const updatedEntries = new Map(entries); - - for (const [slug, treeOid] of entries) { - const manifest = await service.readManifest({ treeOid }); - if (!manifest.encryption?.recipients?.length) { - skippedSlugs.push(slug); - continue; - } - const rotated = await service.rotateKey({ manifest, oldKey: oldKek, newKey: newKek }); - updatedEntries.set(slug, await service.createTree({ manifest: rotated })); - rotatedSlugs.push(slug); - } - - return { updatedEntries, rotatedSlugs, skippedSlugs }; - } - - /** - * Builds updated vault metadata with new KDF params. - * @private - */ - static #buildRotatedMetadata(metadata, newSalt, newParams) { - return { - ...metadata, - encryption: { - cipher: metadata.encryption.cipher, - kdf: buildKdfMetadata(newSalt, newParams), - }, - }; + return await rotateVaultPassphrase({ service, vault }, options); } } diff --git a/jsr.json b/jsr.json index 103ca72..5c65694 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-cas", - "version": "5.2.2", + "version": "5.2.3", "exports": { ".": "./index.js", "./service": "./src/domain/services/CasService.js", diff --git a/package.json b/package.json index 19f3e55..69ae12f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-cas", - "version": "5.2.2", + "version": "5.2.3", "description": "Content-addressed storage backed by Git's object database, with optional encryption and pluggable codecs", "type": "module", "main": "index.js", diff --git a/src/domain/services/CasService.d.ts b/src/domain/services/CasService.d.ts index 890ea49..358579b 100644 --- a/src/domain/services/CasService.d.ts +++ b/src/domain/services/CasService.d.ts @@ -8,7 +8,7 @@ import type { EncryptionMeta, CompressionMeta, KdfParams } from "../value-object /** Port interface for cryptographic operations (hashing, encryption, random bytes). */ export interface CryptoPort { - sha256(buf: Buffer): string | Promise; + sha256(buf: Buffer): Promise; randomBytes(n: number): Buffer; encryptBuffer( buffer: Buffer, diff --git a/src/domain/services/CasService.js b/src/domain/services/CasService.js index e04ddcb..9d1370c 100644 --- a/src/domain/services/CasService.js +++ b/src/domain/services/CasService.js @@ -10,6 +10,7 @@ import Manifest from '../value-objects/Manifest.js'; import CasError from '../errors/CasError.js'; import Semaphore from './Semaphore.js'; import FixedChunker from '../../infrastructure/chunkers/FixedChunker.js'; +import KeyResolver from './KeyResolver.js'; const gunzipAsync = promisify(gunzip); @@ -20,6 +21,9 @@ const gunzipAsync = promisify(gunzip); * arbitrary data in Git's object database. */ export default class CasService { + /** @type {KeyResolver} */ + #keyResolver; + /** * @param {Object} options * @param {import('../../ports/GitPersistencePort.js').default} options.persistence @@ -51,6 +55,7 @@ export default class CasService { throw new Error('Concurrency must be a positive integer'); } this.concurrency = concurrency; + this.#keyResolver = new KeyResolver(crypto); } /** @@ -190,129 +195,6 @@ 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 {Promise<{ wrappedDek: string, nonce: string, tag: string }>} - */ - async _wrapDek(dek, kek) { - const { buf, meta } = await 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 {Promise} The unwrapped DEK. - * @throws {CasError} DEK_UNWRAP_FAILED if decryption fails. - */ - async _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 await 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); - - const key = passphrase - ? await this._resolvePassphraseForDecryption(manifest, passphrase) - : encryptionKey; - - if (!key) { - if (manifest.encryption?.encrypted) { - throw new CasError('Encryption key required to restore encrypted content', 'MISSING_KEY'); - } - return undefined; - } - - this.crypto._validateKey(key); - return await this._resolveKeyForRecipients(manifest, key); - } - - /** - * Resolves passphrase to a key for decryption. - * @private - */ - async _resolvePassphraseForDecryption(manifest, passphrase) { - if (!manifest.encryption?.kdf) { - throw new CasError( - 'Manifest was not stored with passphrase-based encryption; provide encryptionKey instead', - 'MISSING_KEY', - ); - } - return this._resolveKeyFromPassphrase(passphrase, manifest.encryption.kdf); - } - - /** - * If manifest uses envelope encryption, unwraps the DEK. Otherwise returns key directly. - * @private - */ - async _resolveKeyForRecipients(manifest, key) { - const recipients = manifest.encryption?.recipients; - if (!recipients || recipients.length === 0) { - return key; - } - - for (const entry of recipients) { - try { - return await this._unwrapDek(entry, key); - } catch (err) { - if (!(err instanceof CasError && err.code === 'DEK_UNWRAP_FAILED')) { throw err; } - // 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 - */ - _validateKeySourceExclusive(encryptionKey, passphrase) { - if (passphrase && encryptionKey) { - throw new CasError( - 'Provide either encryptionKey or passphrase, not both', - 'INVALID_OPTIONS', - ); - } - } - /** * Validates and normalizes compression options. * @private @@ -365,12 +247,12 @@ export default class CasService { if (recipients && (encryptionKey || passphrase)) { throw new CasError('Provide recipients or encryptionKey/passphrase, not both', 'INVALID_OPTIONS'); } - this._validateKeySourceExclusive(encryptionKey, passphrase); + KeyResolver.validateKeySourceExclusive(encryptionKey, passphrase); this._validateCompression(compression); const keyInfo = recipients - ? await this._resolveRecipientsForStore(recipients) - : await this._resolveKeyForStore(encryptionKey, passphrase, kdfOptions); + ? await this.#keyResolver.resolveRecipients(recipients) + : await this.#keyResolver.resolveForStore(encryptionKey, passphrase, kdfOptions); const manifestData = this._buildManifestData(slug, filename, compression); const processedSource = compression ? this._compressStream(source) : source; @@ -390,42 +272,6 @@ export default class CasService { return manifest; } - /** - * Resolves envelope recipients into a DEK and wrapped entries for store(). - * @private - */ - async _resolveRecipientsForStore(recipients) { - if (!Array.isArray(recipients) || recipients.length === 0) { - throw new CasError('At least one recipient is required', 'INVALID_OPTIONS'); - } - const labels = recipients.map((r) => r.label); - if (new Set(labels).size !== labels.length) { - throw new CasError('Duplicate recipient labels are not allowed', 'INVALID_OPTIONS'); - } - const dek = this.crypto.randomBytes(32); - const entries = []; - for (const r of recipients) { - this.crypto._validateKey(r.key); - entries.push({ label: r.label, ...(await this._wrapDek(dek, r.key)) }); - } - return { key: dek, encExtra: { recipients: entries } }; - } - - /** - * Resolves encryptionKey/passphrase into a key and optional KDF params for store(). - * @private - */ - async _resolveKeyForStore(encryptionKey, passphrase, kdfOptions) { - let kdfParams; - if (passphrase) { - const derived = await this.deriveKey({ passphrase, ...kdfOptions }); - encryptionKey = derived.key; - kdfParams = derived.params; - } - if (encryptionKey) { this.crypto._validateKey(encryptionKey); } - return { key: encryptionKey, encExtra: kdfParams ? { kdf: kdfParams } : {} }; - } - /** * Builds initial manifest data with optional chunking and compression metadata. * @private @@ -558,26 +404,6 @@ export default class CasService { return buffers; } - /** - * Resolves the encryption key from a passphrase using KDF params from the manifest. - * @private - * @param {string} passphrase - * @param {Object} kdf - KDF params from manifest.encryption.kdf. - * @returns {Promise} - */ - async _resolveKeyFromPassphrase(passphrase, kdf) { - const { key } = await this.deriveKey({ - passphrase, - salt: Buffer.from(kdf.salt, 'base64'), - algorithm: kdf.algorithm, - iterations: kdf.iterations, - cost: kdf.cost, - blockSize: kdf.blockSize, - parallelization: kdf.parallelization, - }); - return key; - } - /** * Restores a file from its manifest by reading and reassembling chunks. * @@ -622,7 +448,7 @@ export default class CasService { * @throws {CasError} INTEGRITY_ERROR if chunk verification or decryption fails. */ async *restoreStream({ manifest, encryptionKey, passphrase }) { - const key = await this._resolveDecryptionKey(manifest, encryptionKey, passphrase); + const key = await this.#keyResolver.resolveForDecryption(manifest, encryptionKey, passphrase); if (manifest.chunks.length === 0) { this.observability.metric('file', { @@ -896,7 +722,7 @@ export default class CasService { // Unwrap DEK using the existing key let dek; try { - dek = await this._resolveKeyForRecipients(manifest, existingKey); + dek = await this.#keyResolver.resolveKeyForRecipients(manifest, existingKey); } catch (err) { if (err instanceof CasError && err.code === 'NO_MATCHING_RECIPIENT') { throw new CasError('Failed to unwrap DEK: authentication failed', 'DEK_UNWRAP_FAILED', { originalError: err }); @@ -905,7 +731,7 @@ export default class CasService { } // Wrap DEK for the new recipient - const newEntry = { label, ...(await this._wrapDek(dek, newRecipientKey)) }; + const newEntry = { label, ...(await this.#keyResolver.wrapDek(dek, newRecipientKey)) }; const json = manifest.toJSON(); const updatedEncryption = { @@ -1019,7 +845,7 @@ export default class CasService { if (matchIndex === -1) { throw new CasError(`Recipient "${label}" not found`, 'RECIPIENT_NOT_FOUND', { label }); } - const dek = await this._unwrapDek(recipients[matchIndex], oldKey); + const dek = await this.#keyResolver.unwrapDek(recipients[matchIndex], oldKey); return { matchIndex, dek }; } @@ -1029,7 +855,7 @@ export default class CasService { async #findRecipientByKey(recipients, oldKey) { for (let i = 0; i < recipients.length; i++) { try { - const dek = await this._unwrapDek(recipients[i], oldKey); + const dek = await this.#keyResolver.unwrapDek(recipients[i], oldKey); return { matchIndex: i, dek }; } catch (err) { if (!(err instanceof CasError && err.code === 'DEK_UNWRAP_FAILED')) { throw err; } @@ -1045,7 +871,7 @@ export default class CasService { * Builds a new Manifest with the rotated recipient entry and updated keyVersions. */ async #buildRotatedManifest({ manifest, recipients, matchIndex, dek, newKey }) { - const newWrapped = await this._wrapDek(dek, newKey); + const newWrapped = await this.#keyResolver.wrapDek(dek, newKey); const manifestKeyVersion = (manifest.encryption.keyVersion || 0) + 1; const recipientKeyVersion = (recipients[matchIndex].keyVersion || 0) + 1; diff --git a/src/domain/services/KeyResolver.js b/src/domain/services/KeyResolver.js new file mode 100644 index 0000000..fa62159 --- /dev/null +++ b/src/domain/services/KeyResolver.js @@ -0,0 +1,220 @@ +/** + * @fileoverview Key resolution service extracted from CasService. + * + * Handles all key-related logic: validation, wrapping/unwrapping DEKs, + * resolving encryption keys from passphrases, and envelope recipient management. + */ +import CasError from '../errors/CasError.js'; + +/** + * Resolves encryption keys for store and restore operations. + * + * Encapsulates the key resolution responsibility that was previously + * spread across ~170 lines of CasService. Receives a CryptoPort via + * constructor injection. + * + * **Design note:** KeyResolver calls `CryptoPort.deriveKey()` directly + * rather than going through `CasService.deriveKey()`. If CasService ever + * adds validation or observability around its `deriveKey()` wrapper, + * KeyResolver will need updating to route through the service instead. + */ +export default class KeyResolver { + /** @type {import('../../ports/CryptoPort.js').default} */ + #crypto; + + /** + * @param {import('../../ports/CryptoPort.js').default} crypto - CryptoPort adapter. + */ + constructor(crypto) { + this.#crypto = crypto; + } + + /** + * Validates that passphrase and encryptionKey are not both provided. + * @param {Buffer} [encryptionKey] + * @param {string} [passphrase] + * @throws {CasError} INVALID_OPTIONS if both are provided. + */ + static validateKeySourceExclusive(encryptionKey, passphrase) { + if (passphrase && encryptionKey) { + throw new CasError( + 'Provide either encryptionKey or passphrase, not both', + 'INVALID_OPTIONS', + ); + } + } + + /** + * Wraps a DEK with a KEK using AES-256-GCM. + * @param {Buffer} dek - 32-byte data encryption key. + * @param {Buffer} kek - 32-byte key encryption key. + * @returns {Promise<{ wrappedDek: string, nonce: string, tag: string }>} + */ + async wrapDek(dek, kek) { + const { buf, meta } = await 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. + * @param {{ wrappedDek: string, nonce: string, tag: string }} recipientEntry + * @param {Buffer} kek - 32-byte key encryption key. + * @returns {Promise} The unwrapped DEK. + * @throws {CasError} DEK_UNWRAP_FAILED if decryption fails. + */ + async 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 await 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. + * @param {import('../value-objects/Manifest.js').default} manifest + * @param {Buffer} [encryptionKey] + * @param {string} [passphrase] + * @returns {Promise} + */ + async resolveForDecryption(manifest, encryptionKey, passphrase) { + KeyResolver.validateKeySourceExclusive(encryptionKey, passphrase); + + const key = passphrase + ? await this.#resolvePassphraseForDecryption(manifest, passphrase) + : encryptionKey; + + if (!key) { + if (manifest.encryption?.encrypted) { + throw new CasError('Encryption key required to restore encrypted content', 'MISSING_KEY'); + } + return undefined; + } + + this.#crypto._validateKey(key); + return await this.resolveKeyForRecipients(manifest, key); + } + + /** + * Resolves encryptionKey/passphrase into a key and optional KDF params for store(). + * @param {Buffer} [encryptionKey] + * @param {string} [passphrase] + * @param {Object} [kdfOptions] - KDF options when using passphrase. + * @returns {Promise<{ key: Buffer|undefined, encExtra: Object }>} + */ + async resolveForStore(encryptionKey, passphrase, kdfOptions) { + let kdfParams; + if (passphrase) { + const derived = await this.#crypto.deriveKey({ passphrase, ...kdfOptions }); + encryptionKey = derived.key; + kdfParams = derived.params; + } + if (encryptionKey) { this.#crypto._validateKey(encryptionKey); } + return { key: encryptionKey, encExtra: kdfParams ? { kdf: kdfParams } : {} }; + } + + /** + * Resolves envelope recipients into a DEK and wrapped entries for store(). + * @param {Array<{label: string, key: Buffer}>} recipients + * @returns {Promise<{ key: Buffer, encExtra: { recipients: Array } }>} + * @throws {CasError} INVALID_OPTIONS if recipients is empty, non-array, or has duplicate labels. + */ + async resolveRecipients(recipients) { + if (!Array.isArray(recipients) || recipients.length === 0) { + throw new CasError('At least one recipient is required', 'INVALID_OPTIONS'); + } + const labels = recipients.map((r) => r.label); + if (new Set(labels).size !== labels.length) { + throw new CasError('Duplicate recipient labels are not allowed', 'INVALID_OPTIONS'); + } + const dek = this.#crypto.randomBytes(32); + const entries = []; + for (const r of recipients) { + this.#crypto._validateKey(r.key); + entries.push({ label: r.label, ...(await this.wrapDek(dek, r.key)) }); + } + return { key: dek, encExtra: { recipients: entries } }; + } + + /** + * If manifest uses envelope encryption, unwraps the DEK. Otherwise returns key directly. + * @param {import('../value-objects/Manifest.js').default} manifest + * @param {Buffer} key + * @returns {Promise} + * @throws {CasError} NO_MATCHING_RECIPIENT if no recipient entry can be unwrapped. + */ + async resolveKeyForRecipients(manifest, key) { + const recipients = manifest.encryption?.recipients; + if (!recipients || recipients.length === 0) { + return key; + } + + for (const entry of recipients) { + try { + return await this.unwrapDek(entry, key); + } catch (err) { + if (!(err instanceof CasError && err.code === 'DEK_UNWRAP_FAILED')) { throw err; } + // Not this recipient's KEK, try next + } + } + + throw new CasError( + 'No recipient entry could be unwrapped with the provided key', + 'NO_MATCHING_RECIPIENT', + ); + } + + /** + * Resolves passphrase to a key for decryption. + * @param {import('../value-objects/Manifest.js').default} manifest + * @param {string} passphrase + * @returns {Promise} + * @throws {CasError} MISSING_KEY if manifest has no KDF params. + */ + async #resolvePassphraseForDecryption(manifest, passphrase) { + if (!manifest.encryption?.kdf) { + throw new CasError( + 'Manifest was not stored with passphrase-based encryption; provide encryptionKey instead', + 'MISSING_KEY', + ); + } + return this.#resolveKeyFromPassphrase(passphrase, manifest.encryption.kdf); + } + + /** + * Derives a key from a passphrase using stored KDF params. + * @param {string} passphrase + * @param {Object} kdf - KDF params from manifest.encryption.kdf. + * @returns {Promise} + */ + async #resolveKeyFromPassphrase(passphrase, kdf) { + const { key } = await this.#crypto.deriveKey({ + passphrase, + salt: Buffer.from(kdf.salt, 'base64'), + algorithm: kdf.algorithm, + iterations: kdf.iterations, + cost: kdf.cost, + blockSize: kdf.blockSize, + parallelization: kdf.parallelization, + keyLength: kdf.keyLength, + }); + return key; + } +} diff --git a/src/domain/services/rotateVaultPassphrase.js b/src/domain/services/rotateVaultPassphrase.js new file mode 100644 index 0000000..bec55dd --- /dev/null +++ b/src/domain/services/rotateVaultPassphrase.js @@ -0,0 +1,143 @@ +import CasError from '../errors/CasError.js'; +import buildKdfMetadata from '../helpers/buildKdfMetadata.js'; + +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_RETRY_BASE_MS = 50; + +/** + * Derives a KEK from a passphrase using stored KDF params. + * + * @param {import('./CasService.js').default} service - CasService instance. + * @param {string} passphrase - The passphrase. + * @param {Object} kdf - Stored KDF params (algorithm, salt, iterations, etc.). + * @returns {Promise} The derived KEK. + */ +async function deriveKekFromKdf(service, passphrase, kdf) { + const { key } = await service.deriveKey({ + passphrase, + salt: Buffer.from(kdf.salt, 'base64'), + algorithm: kdf.algorithm, + iterations: kdf.iterations, + cost: kdf.cost, + blockSize: kdf.blockSize, + parallelization: kdf.parallelization, + keyLength: kdf.keyLength, + }); + return key; +} + +/** + * Iterates vault entries, rotating envelope-encrypted ones and skipping others. + * + * @param {Object} options + * @param {import('./CasService.js').default} options.service - CasService instance. + * @param {Map} options.entries - Vault entries (slug → treeOid). + * @param {Buffer} options.oldKek - Old key-encryption key. + * @param {Buffer} options.newKek - New key-encryption key. + * @returns {Promise<{ updatedEntries: Map, rotatedSlugs: string[], skippedSlugs: string[] }>} + */ +async function rotateEntries({ service, entries, oldKek, newKek }) { + const rotatedSlugs = []; + const skippedSlugs = []; + const updatedEntries = new Map(entries); + + for (const [slug, treeOid] of entries) { + const manifest = await service.readManifest({ treeOid }); + if (!manifest.encryption?.recipients?.length) { + skippedSlugs.push(slug); + continue; + } + const rotated = await service.rotateKey({ manifest, oldKey: oldKek, newKey: newKek }); + updatedEntries.set(slug, await service.createTree({ manifest: rotated })); + rotatedSlugs.push(slug); + } + + return { updatedEntries, rotatedSlugs, skippedSlugs }; +} + +/** + * Returns true if the error is a retryable VAULT_CONFLICT and there are attempts remaining. + * + * @param {Error} err - Caught error. + * @param {number} attempt - Zero-based current attempt index. + * @param {number} maxRetries - Maximum number of attempts. + * @returns {boolean} + */ +function isRetryableConflict(err, attempt, maxRetries) { + return err instanceof CasError && err.code === 'VAULT_CONFLICT' && attempt < maxRetries - 1; +} + +/** + * Builds updated vault metadata with new KDF params. + * + * @param {Object} metadata - Existing vault metadata. + * @param {Buffer} newSalt - New KDF salt. + * @param {Object} newParams - New KDF parameters. + * @returns {Object} Updated metadata. + */ +function buildRotatedMetadata(metadata, newSalt, newParams) { + return { + ...metadata, + encryption: { + cipher: metadata.encryption.cipher, + kdf: buildKdfMetadata(newSalt, newParams), + }, + }; +} + +/** + * Rotates the vault-level passphrase. Re-wraps every envelope-encrypted + * entry's DEK with a new KEK derived from `newPassphrase`. Entries using + * direct-key encryption are skipped. + * + * Uses optimistic concurrency with retry/backoff on VAULT_CONFLICT. + * + * @param {Object} deps + * @param {import('./CasService.js').default} deps.service - CasService instance. + * @param {import('./VaultService.js').default} deps.vault - VaultService instance. + * @param {Object} options + * @param {string} options.oldPassphrase - Current vault passphrase. + * @param {string} options.newPassphrase - New vault passphrase. + * @param {Object} [options.kdfOptions] - KDF options for new passphrase. + * @param {number} [options.maxRetries=3] - Maximum optimistic-concurrency retries on VAULT_CONFLICT. + * @param {number} [options.retryBaseMs=50] - Base delay in ms for exponential backoff between retries. + * @returns {Promise<{ commitOid: string, rotatedSlugs: string[], skippedSlugs: string[] }>} + */ +export default async function rotateVaultPassphrase( + { service, vault }, + { oldPassphrase, newPassphrase, kdfOptions, maxRetries = DEFAULT_MAX_RETRIES, retryBaseMs = DEFAULT_RETRY_BASE_MS }, +) { + for (let attempt = 0; attempt < maxRetries; attempt++) { + const state = await vault.readState(); + if (!state.metadata?.encryption) { + throw new CasError('Vault is not encrypted — nothing to rotate', 'VAULT_METADATA_INVALID'); + } + + const { kdf } = state.metadata.encryption; + const oldKek = await deriveKekFromKdf(service, oldPassphrase, kdf); + const { key: newKek, salt: newSalt, params: newParams } = await service.deriveKey({ + passphrase: newPassphrase, ...kdfOptions, algorithm: kdfOptions?.algorithm || kdf.algorithm, + }); + + const result = await rotateEntries({ service, entries: state.entries, oldKek, newKek }); + const newMetadata = buildRotatedMetadata(state.metadata, newSalt, newParams); + + try { + const { commitOid } = await vault.writeCommit({ + entries: result.updatedEntries, + metadata: newMetadata, + parentCommitOid: state.parentCommitOid, + message: `vault: rotate passphrase (${result.rotatedSlugs.length} rotated, ${result.skippedSlugs.length} skipped)`, + }); + return { commitOid, rotatedSlugs: result.rotatedSlugs, skippedSlugs: result.skippedSlugs }; + } catch (err) { + if (isRetryableConflict(err, attempt, maxRetries)) { + await new Promise((r) => setTimeout(r, retryBaseMs * (2 ** attempt))); + continue; + } + throw err; + } + } + /* c8 ignore next 2 */ + throw new CasError('Vault CAS retries exhausted', 'VAULT_CONFLICT'); +} diff --git a/src/infrastructure/adapters/FileIOHelper.js b/src/infrastructure/adapters/FileIOHelper.js new file mode 100644 index 0000000..cf311a8 --- /dev/null +++ b/src/infrastructure/adapters/FileIOHelper.js @@ -0,0 +1,64 @@ +/** + * @fileoverview File I/O helpers for storing and restoring files via CasService. + */ +import { createReadStream, createWriteStream } from 'node:fs'; +import path from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +/** + * Reads a file from disk and stores it in Git as chunked blobs via + * the given {@link import('../../domain/services/CasService.js').default CasService}. + * + * @param {import('../../domain/services/CasService.js').default} service - Initialized CasService. + * @param {Object} options + * @param {string} options.filePath - Absolute or relative path to the file. + * @param {string} options.slug - Logical identifier for the stored asset. + * @param {string} [options.filename] - Override filename (defaults to basename of filePath). + * @param {Buffer} [options.encryptionKey] - 32-byte key for AES-256-GCM encryption. + * @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. + * @returns {Promise} The resulting manifest. + */ +export async function storeFile(service, { filePath, slug, filename, encryptionKey, passphrase, kdfOptions, compression, recipients }) { + const source = createReadStream(filePath); + return await service.store({ + source, + slug, + filename: filename || path.basename(filePath), + encryptionKey, + passphrase, + kdfOptions, + compression, + recipients, + }); +} + +/** + * Restores a file from its manifest and writes it to disk via the given + * {@link import('../../domain/services/CasService.js').default CasService}. + * + * @param {import('../../domain/services/CasService.js').default} service - Initialized CasService. + * @param {Object} options + * @param {import('../../domain/value-objects/Manifest.js').default} options.manifest - The file manifest. + * @param {Buffer} [options.encryptionKey] - 32-byte key, required if manifest is encrypted. + * @param {string} [options.passphrase] - Passphrase for KDF-based decryption. + * @param {string} options.outputPath - Destination file path. + * @returns {Promise<{ bytesWritten: number }>} + */ +export async function restoreFile(service, { manifest, encryptionKey, passphrase, outputPath }) { + const iterable = service.restoreStream({ manifest, encryptionKey, passphrase }); + const readable = Readable.from(iterable); + const writable = createWriteStream(outputPath); + let bytesWritten = 0; + const counter = new Transform({ + transform(chunk, _encoding, cb) { + bytesWritten += chunk.length; + cb(null, chunk); + }, + }); + await pipeline(readable, counter, writable); + return { bytesWritten }; +} diff --git a/src/infrastructure/adapters/NodeCryptoAdapter.js b/src/infrastructure/adapters/NodeCryptoAdapter.js index ea6c963..f89898c 100644 --- a/src/infrastructure/adapters/NodeCryptoAdapter.js +++ b/src/infrastructure/adapters/NodeCryptoAdapter.js @@ -9,9 +9,9 @@ export default class NodeCryptoAdapter extends CryptoPort { /** * @override * @param {Buffer|Uint8Array} buf - Data to hash. - * @returns {string} 64-char hex digest. + * @returns {Promise} 64-char hex digest. */ - sha256(buf) { + async sha256(buf) { return createHash('sha256').update(buf).digest('hex'); } diff --git a/src/infrastructure/adapters/createCryptoAdapter.js b/src/infrastructure/adapters/createCryptoAdapter.js new file mode 100644 index 0000000..e929e0a --- /dev/null +++ b/src/infrastructure/adapters/createCryptoAdapter.js @@ -0,0 +1,25 @@ +/** + * @fileoverview Runtime-adaptive crypto adapter factory. + */ +import NodeCryptoAdapter from './NodeCryptoAdapter.js'; + +/** + * Detects the best crypto adapter for the current runtime. + * + * - Bun → BunCryptoAdapter (dynamic import) + * - Deno → WebCryptoAdapter (dynamic import) + * - Node → NodeCryptoAdapter (static import) + * + * @returns {Promise} A runtime-appropriate CryptoPort implementation. + */ +export default async function createCryptoAdapter() { + if (globalThis.Bun) { + const { default: BunCryptoAdapter } = await import('./BunCryptoAdapter.js'); + return new BunCryptoAdapter(); + } + if (globalThis.Deno) { + const { default: WebCryptoAdapter } = await import('./WebCryptoAdapter.js'); + return new WebCryptoAdapter(); + } + return new NodeCryptoAdapter(); +} diff --git a/src/infrastructure/chunkers/resolveChunker.js b/src/infrastructure/chunkers/resolveChunker.js new file mode 100644 index 0000000..4985927 --- /dev/null +++ b/src/infrastructure/chunkers/resolveChunker.js @@ -0,0 +1,45 @@ +/** + * @fileoverview Chunker factory — resolves a ChunkingPort from facade config. + */ +import FixedChunker from './FixedChunker.js'; +import CdcChunker from './CdcChunker.js'; + +/** + * Resolves a {@link import('../../ports/ChunkingPort.js').default ChunkingPort} + * instance from facade configuration options. + * + * Resolution order: + * 1. A pre-built `chunker` instance takes precedence. + * 2. A declarative `chunking` config is used to construct the appropriate chunker. + * 3. `undefined` — CasService will fall back to its built-in FixedChunker default. + * + * @param {Object} options + * @param {import('../../ports/ChunkingPort.js').default} [options.chunker] - Pre-built ChunkingPort instance (advanced). + * @param {{ strategy: string, chunkSize?: number, targetChunkSize?: number, minChunkSize?: number, maxChunkSize?: number }} [options.chunking] - Declarative chunking strategy config. + * @returns {import('../../ports/ChunkingPort.js').default|undefined} + */ +export default function resolveChunker({ chunker, chunking } = {}) { + // Direct ChunkingPort instance takes precedence + if (chunker) { + return chunker; + } + // Build from declarative chunking config + if (chunking) { + if (chunking.strategy === 'cdc') { + return new CdcChunker({ + targetChunkSize: chunking.targetChunkSize, + minChunkSize: chunking.minChunkSize, + maxChunkSize: chunking.maxChunkSize, + }); + } + // 'fixed' or unrecognized — fall through to default (FixedChunker via CasService) + if (chunking.strategy === 'fixed' + && typeof chunking.chunkSize === 'number' + && Number.isFinite(chunking.chunkSize) + && chunking.chunkSize > 0) { + return new FixedChunker({ chunkSize: chunking.chunkSize }); + } + } + // undefined → CasService will default to FixedChunker + return undefined; +} diff --git a/src/ports/CryptoPort.js b/src/ports/CryptoPort.js index 7259ed5..7b8f305 100644 --- a/src/ports/CryptoPort.js +++ b/src/ports/CryptoPort.js @@ -40,7 +40,7 @@ export default class CryptoPort { /** * Returns the SHA-256 hex digest of a buffer. * @param {Buffer|Uint8Array} _buf - Data to hash. - * @returns {string|Promise} 64-char hex digest. + * @returns {Promise} 64-char hex digest. */ sha256(_buf) { throw new Error('Not implemented'); diff --git a/test/helpers/crypto-adapter.js b/test/helpers/crypto-adapter.js index bf53250..df82c1b 100644 --- a/test/helpers/crypto-adapter.js +++ b/test/helpers/crypto-adapter.js @@ -1,27 +1,8 @@ /** * Returns the runtime-appropriate crypto adapter for tests. * - * - Node → NodeCryptoAdapter - * - Bun → BunCryptoAdapter - * - Deno → WebCryptoAdapter - * - * Mirrors the detection logic in index.js getDefaultCryptoAdapter(). + * Delegates to the shared runtime detection in createCryptoAdapter. */ -export async function getTestCryptoAdapter() { - if (globalThis.Bun) { - const { default: BunCryptoAdapter } = await import( - '../../src/infrastructure/adapters/BunCryptoAdapter.js' - ); - return new BunCryptoAdapter(); - } - if (globalThis.Deno) { - const { default: WebCryptoAdapter } = await import( - '../../src/infrastructure/adapters/WebCryptoAdapter.js' - ); - return new WebCryptoAdapter(); - } - const { default: NodeCryptoAdapter } = await import( - '../../src/infrastructure/adapters/NodeCryptoAdapter.js' - ); - return new NodeCryptoAdapter(); -} +import createCryptoAdapter from '../../src/infrastructure/adapters/createCryptoAdapter.js'; + +export { createCryptoAdapter as getTestCryptoAdapter }; diff --git a/test/unit/domain/services/KeyResolver.test.js b/test/unit/domain/services/KeyResolver.test.js new file mode 100644 index 0000000..5472155 --- /dev/null +++ b/test/unit/domain/services/KeyResolver.test.js @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import KeyResolver from '../../../../src/domain/services/KeyResolver.js'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; + +/** @type {NodeCryptoAdapter} */ +let crypto; +/** @type {KeyResolver} */ +let resolver; +/** @type {Buffer} */ +let key; +/** @type {Buffer} */ +let wrongKey; + +beforeEach(() => { + crypto = new NodeCryptoAdapter(); + resolver = new KeyResolver(crypto); + key = crypto.randomBytes(32); + wrongKey = crypto.randomBytes(32); +}); + +describe('KeyResolver.validateKeySourceExclusive', () => { + it('throws INVALID_OPTIONS when both provided', () => { + expect(() => KeyResolver.validateKeySourceExclusive(key, 'secret')) + .toThrow(expect.objectContaining({ code: 'INVALID_OPTIONS' })); + }); + + it('accepts key-only', () => { + expect(() => KeyResolver.validateKeySourceExclusive(key, undefined)).not.toThrow(); + }); + + it('accepts passphrase-only', () => { + expect(() => KeyResolver.validateKeySourceExclusive(undefined, 'secret')).not.toThrow(); + }); + + it('accepts neither', () => { + expect(() => KeyResolver.validateKeySourceExclusive(undefined, undefined)).not.toThrow(); + }); +}); + +describe('KeyResolver.wrapDek / unwrapDek', () => { + it('round-trips: unwrapDek recovers the original DEK', async () => { + const dek = crypto.randomBytes(32); + const wrapped = await resolver.wrapDek(dek, key); + const unwrapped = await resolver.unwrapDek(wrapped, key); + expect(Buffer.from(unwrapped)).toEqual(dek); + }); + + it('wrong KEK throws DEK_UNWRAP_FAILED', async () => { + const dek = crypto.randomBytes(32); + const wrapped = await resolver.wrapDek(dek, key); + await expect(resolver.unwrapDek(wrapped, wrongKey)) + .rejects.toThrow(expect.objectContaining({ code: 'DEK_UNWRAP_FAILED' })); + }); +}); + +describe('KeyResolver.resolveForDecryption — direct key', () => { + it('unencrypted manifest → undefined', async () => { + const manifest = { encryption: null }; + const result = await resolver.resolveForDecryption(manifest, undefined, undefined); + expect(result).toBeUndefined(); + }); + + it('encrypted manifest + key → returns key', async () => { + const manifest = { encryption: { encrypted: true } }; + const result = await resolver.resolveForDecryption(manifest, key, undefined); + expect(Buffer.from(result)).toEqual(key); + }); + + it('encrypted manifest + no key → throws MISSING_KEY', async () => { + const manifest = { encryption: { encrypted: true } }; + await expect(resolver.resolveForDecryption(manifest, undefined, undefined)) + .rejects.toThrow(expect.objectContaining({ code: 'MISSING_KEY' })); + }); + + it('both ek + pp → throws INVALID_OPTIONS', async () => { + const manifest = { encryption: { encrypted: true } }; + await expect(resolver.resolveForDecryption(manifest, key, 'secret')) + .rejects.toThrow(expect.objectContaining({ code: 'INVALID_OPTIONS' })); + }); +}); + +describe('KeyResolver.resolveForDecryption — envelope & passphrase', () => { + it('envelope + correct KEK → unwrapped DEK', async () => { + const dek = crypto.randomBytes(32); + const wrapped = await resolver.wrapDek(dek, key); + const manifest = { + encryption: { + encrypted: true, + recipients: [{ label: 'alice', ...wrapped }], + }, + }; + const result = await resolver.resolveForDecryption(manifest, key, undefined); + expect(Buffer.from(result)).toEqual(dek); + }); + + it('envelope + wrong KEK → NO_MATCHING_RECIPIENT', async () => { + const dek = crypto.randomBytes(32); + const wrapped = await resolver.wrapDek(dek, key); + const manifest = { + encryption: { + encrypted: true, + recipients: [{ label: 'alice', ...wrapped }], + }, + }; + await expect(resolver.resolveForDecryption(manifest, wrongKey, undefined)) + .rejects.toThrow(expect.objectContaining({ code: 'NO_MATCHING_RECIPIENT' })); + }); + + it('passphrase + KDF → derived key', async () => { + const passphrase = 'test-passphrase'; + const derived = await crypto.deriveKey({ passphrase, iterations: 1000 }); + const manifest = { + encryption: { encrypted: true, kdf: derived.params }, + }; + const result = await resolver.resolveForDecryption(manifest, undefined, passphrase); + expect(Buffer.from(result)).toEqual(derived.key); + }); + + it('passphrase without KDF → throws MISSING_KEY', async () => { + const manifest = { encryption: { encrypted: true } }; + await expect(resolver.resolveForDecryption(manifest, undefined, 'secret')) + .rejects.toThrow(expect.objectContaining({ code: 'MISSING_KEY' })); + }); +}); + +describe('KeyResolver.resolveForDecryption — keyLength', () => { + it('forwards stored keyLength to deriveKey', async () => { + const passphrase = 'test-passphrase'; + const derived = await crypto.deriveKey({ passphrase, iterations: 1000, keyLength: 32 }); + expect(derived.params.keyLength).toBe(32); + const manifest = { + encryption: { encrypted: true, kdf: derived.params }, + }; + const result = await resolver.resolveForDecryption(manifest, undefined, passphrase); + expect(Buffer.from(result)).toEqual(derived.key); + }); +}); + +describe('KeyResolver.resolveForStore', () => { + it('with key → returns key and empty encExtra', async () => { + const result = await resolver.resolveForStore(key, undefined, undefined); + expect(Buffer.from(result.key)).toEqual(key); + expect(result.encExtra).toEqual({}); + }); + + it('with passphrase → returns derived key and kdf encExtra', async () => { + const result = await resolver.resolveForStore(undefined, 'secret', { iterations: 1000 }); + expect(result.key).toHaveLength(32); + expect(result.encExtra).toHaveProperty('kdf'); + expect(result.encExtra.kdf).toHaveProperty('algorithm', 'pbkdf2'); + expect(result.encExtra.kdf).toHaveProperty('salt'); + }); + + it('with neither → returns undefined key and empty encExtra', async () => { + const result = await resolver.resolveForStore(undefined, undefined, undefined); + expect(result.key).toBeUndefined(); + expect(result.encExtra).toEqual({}); + }); +}); + +describe('KeyResolver.resolveRecipients', () => { + it('generates DEK + wrapped entries', async () => { + const k1 = crypto.randomBytes(32); + const k2 = crypto.randomBytes(32); + const recipients = [ + { label: 'alice', key: k1 }, + { label: 'bob', key: k2 }, + ]; + + const result = await resolver.resolveRecipients(recipients); + expect(result.key).toHaveLength(32); + expect(result.encExtra.recipients).toHaveLength(2); + expect(result.encExtra.recipients[0]).toHaveProperty('label', 'alice'); + expect(result.encExtra.recipients[0]).toHaveProperty('wrappedDek'); + expect(result.encExtra.recipients[1]).toHaveProperty('label', 'bob'); + + // Verify each recipient can unwrap the DEK + for (let i = 0; i < recipients.length; i++) { + const dek = await resolver.unwrapDek(result.encExtra.recipients[i], recipients[i].key); + expect(Buffer.from(dek)).toEqual(result.key); + } + }); + + it('empty recipients → INVALID_OPTIONS', async () => { + await expect(resolver.resolveRecipients([])) + .rejects.toThrow(expect.objectContaining({ code: 'INVALID_OPTIONS' })); + }); + + it('non-array → INVALID_OPTIONS', async () => { + await expect(resolver.resolveRecipients(null)) + .rejects.toThrow(expect.objectContaining({ code: 'INVALID_OPTIONS' })); + }); + + it('duplicate labels → INVALID_OPTIONS', async () => { + const k = crypto.randomBytes(32); + await expect(resolver.resolveRecipients([ + { label: 'alice', key: k }, + { label: 'alice', key: k }, + ])).rejects.toThrow(expect.objectContaining({ code: 'INVALID_OPTIONS' })); + }); +}); + +describe('KeyResolver.resolveKeyForRecipients', () => { + it('correct key unwraps DEK from recipients', async () => { + const dek = crypto.randomBytes(32); + const wrapped = await resolver.wrapDek(dek, key); + const manifest = { + encryption: { + recipients: [{ label: 'alice', ...wrapped }], + }, + }; + const result = await resolver.resolveKeyForRecipients(manifest, key); + expect(Buffer.from(result)).toEqual(dek); + }); + + it('wrong key → NO_MATCHING_RECIPIENT', async () => { + const dek = crypto.randomBytes(32); + const wrapped = await resolver.wrapDek(dek, key); + const manifest = { + encryption: { + recipients: [{ label: 'alice', ...wrapped }], + }, + }; + await expect(resolver.resolveKeyForRecipients(manifest, wrongKey)) + .rejects.toThrow(expect.objectContaining({ code: 'NO_MATCHING_RECIPIENT' })); + }); + + it('no recipients → returns key directly', async () => { + const manifest = { encryption: {} }; + const result = await resolver.resolveKeyForRecipients(manifest, key); + expect(Buffer.from(result)).toEqual(key); + }); +}); diff --git a/test/unit/domain/services/rotateVaultPassphrase.test.js b/test/unit/domain/services/rotateVaultPassphrase.test.js new file mode 100644 index 0000000..4539557 --- /dev/null +++ b/test/unit/domain/services/rotateVaultPassphrase.test.js @@ -0,0 +1,348 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import path from 'node:path'; +import os from 'node:os'; +import { execSync } from 'node:child_process'; +import GitPlumbing from '@git-stunts/plumbing'; +import CasService from '../../../../src/domain/services/CasService.js'; +import VaultService from '../../../../src/domain/services/VaultService.js'; +import GitPersistenceAdapter from '../../../../src/infrastructure/adapters/GitPersistenceAdapter.js'; +import GitRefAdapter from '../../../../src/infrastructure/adapters/GitRefAdapter.js'; +import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; +import SilentObserver from '../../../../src/infrastructure/adapters/SilentObserver.js'; +import { getTestCryptoAdapter } from '../../../helpers/crypto-adapter.js'; +import rotateVaultPassphrase from '../../../../src/domain/services/rotateVaultPassphrase.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function createRepo() { + const dir = mkdtempSync(path.join(os.tmpdir(), 'cas-rotator-')); + execSync('git init --bare', { cwd: dir, stdio: 'ignore' }); + execSync('git config user.name "test"', { cwd: dir, stdio: 'ignore' }); + execSync('git config user.email "test@test"', { cwd: dir, stdio: 'ignore' }); + return dir; +} + +async function createDeps(repoDir) { + const plumbing = GitPlumbing.createDefault({ cwd: repoDir }); + const crypto = await getTestCryptoAdapter(); + const persistence = new GitPersistenceAdapter({ plumbing }); + const ref = new GitRefAdapter({ plumbing }); + const service = new CasService({ + persistence, codec: new JsonCodec(), crypto, observability: new SilentObserver(), chunkSize: 1024, + }); + const vault = new VaultService({ persistence, ref, crypto }); + return { service, vault }; +} + +async function* bufferSource(buf) { + yield buf; +} + +async function storeEnvelope({ service, vault, slug, data, passphrase }) { + const metadata = (await vault.readState()).metadata; + const { key } = await service.deriveKey({ + passphrase, + salt: Buffer.from(metadata.encryption.kdf.salt, 'base64'), + algorithm: metadata.encryption.kdf.algorithm, + iterations: metadata.encryption.kdf.iterations, + }); + const manifest = await service.store({ + source: bufferSource(data), slug, filename: `${slug}.bin`, + recipients: [{ label: 'vault', key }], + }); + const treeOid = await service.createTree({ manifest }); + await vault.addToVault({ slug, treeOid, force: true }); + return treeOid; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('rotateVaultPassphrase – 3 envelope entries', () => { + let repoDir; + let service; + let vault; + + beforeEach(async () => { + repoDir = createRepo(); + ({ service, vault } = await createDeps(repoDir)); + }); + afterEach(() => { if (repoDir) { rmSync(repoDir, { recursive: true, force: true }); } }); + + it('rotates all entries and returns correct slugs', async () => { + const oldPass = 'old-pass'; + const newPass = 'new-pass'; + await vault.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); + + const originals = {}; + for (const name of ['alpha', 'beta', 'gamma']) { + const data = randomBytes(256); + originals[name] = data; + await storeEnvelope({ service, vault, slug: name, data, passphrase: oldPass }); + } + + const { commitOid, rotatedSlugs, skippedSlugs } = await rotateVaultPassphrase( + { service, vault }, + { oldPassphrase: oldPass, newPassphrase: newPass }, + ); + + expect(commitOid).toMatch(/^[0-9a-f]{40}$/); + expect(rotatedSlugs.sort()).toEqual(['alpha', 'beta', 'gamma']); + expect(skippedSlugs).toEqual([]); + + // Verify all restorable with new passphrase + const state = await vault.readState(); + const { key: newKey } = await service.deriveKey({ + passphrase: newPass, + salt: Buffer.from(state.metadata.encryption.kdf.salt, 'base64'), + algorithm: state.metadata.encryption.kdf.algorithm, + iterations: state.metadata.encryption.kdf.iterations, + }); + + for (const name of ['alpha', 'beta', 'gamma']) { + const treeOid = await vault.resolveVaultEntry({ slug: name }); + const manifest = await service.readManifest({ treeOid }); + const { buffer } = await service.restore({ manifest, encryptionKey: newKey }); + expect(buffer.equals(originals[name])).toBe(true); + } + }); +}); + +describe('rotateVaultPassphrase – mixed entries', () => { + let repoDir; + let service; + let vault; + + beforeEach(async () => { + repoDir = createRepo(); + ({ service, vault } = await createDeps(repoDir)); + }); + afterEach(() => { if (repoDir) { rmSync(repoDir, { recursive: true, force: true }); } }); + + it('2 envelope + 1 non-envelope → 2 rotated, 1 skipped', async () => { + const oldPass = 'old-pass'; + const newPass = 'new-pass'; + await vault.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); + + await storeEnvelope({ service, vault, slug: 'env1', data: randomBytes(128), passphrase: oldPass }); + await storeEnvelope({ service, vault, slug: 'env2', data: randomBytes(128), passphrase: oldPass }); + + // 1 non-envelope (direct key) + const state = await vault.readState(); + const { key: directKey } = await service.deriveKey({ + passphrase: oldPass, + salt: Buffer.from(state.metadata.encryption.kdf.salt, 'base64'), + algorithm: state.metadata.encryption.kdf.algorithm, + iterations: state.metadata.encryption.kdf.iterations, + }); + const plainManifest = await service.store({ + source: bufferSource(randomBytes(128)), slug: 'direct', filename: 'direct.bin', + encryptionKey: directKey, + }); + const plainTree = await service.createTree({ manifest: plainManifest }); + await vault.addToVault({ slug: 'direct', treeOid: plainTree }); + + const { rotatedSlugs, skippedSlugs } = await rotateVaultPassphrase( + { service, vault }, + { oldPassphrase: oldPass, newPassphrase: newPass }, + ); + + expect(rotatedSlugs.sort()).toEqual(['env1', 'env2']); + expect(skippedSlugs).toEqual(['direct']); + }); +}); + +describe('rotateVaultPassphrase – error cases', () => { + let repoDir; + let service; + let vault; + + beforeEach(async () => { + repoDir = createRepo(); + ({ service, vault } = await createDeps(repoDir)); + }); + afterEach(() => { if (repoDir) { rmSync(repoDir, { recursive: true, force: true }); } }); + + it('wrong old passphrase → error', async () => { + const oldPass = 'old-pass'; + await vault.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); + await storeEnvelope({ service, vault, slug: 'asset', data: randomBytes(128), passphrase: oldPass }); + + await expect( + rotateVaultPassphrase({ service, vault }, { oldPassphrase: 'wrong', newPassphrase: 'new' }), + ).rejects.toThrow(); + }); + + it('vault not encrypted → VAULT_METADATA_INVALID', async () => { + await vault.initVault(); + const manifest = await service.store({ + source: bufferSource(randomBytes(64)), slug: 'plain', filename: 'plain.bin', + }); + const tree = await service.createTree({ manifest }); + await vault.addToVault({ slug: 'plain', treeOid: tree }); + + await expect( + rotateVaultPassphrase({ service, vault }, { oldPassphrase: 'any', newPassphrase: 'new' }), + ).rejects.toMatchObject({ code: 'VAULT_METADATA_INVALID' }); + }); +}); + +describe('rotateVaultPassphrase – KDF options', () => { + let repoDir; + let service; + let vault; + + beforeEach(async () => { + repoDir = createRepo(); + ({ service, vault } = await createDeps(repoDir)); + }); + afterEach(() => { if (repoDir) { rmSync(repoDir, { recursive: true, force: true }); } }); + + it('kdfOptions.algorithm overrides existing algorithm', async () => { + const oldPass = 'old-pass'; + const newPass = 'new-pass'; + await vault.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); + await storeEnvelope({ service, vault, slug: 'asset', data: randomBytes(128), passphrase: oldPass }); + + const oldState = await vault.readState(); + expect(oldState.metadata.encryption.kdf.algorithm).toBe('pbkdf2'); + + await rotateVaultPassphrase( + { service, vault }, + { oldPassphrase: oldPass, newPassphrase: newPass, kdfOptions: { algorithm: 'scrypt' } }, + ); + + const newState = await vault.readState(); + expect(newState.metadata.encryption.kdf.algorithm).toBe('scrypt'); + }); + + it('metadata updated with new KDF salt', async () => { + const oldPass = 'old-pass'; + const newPass = 'new-pass'; + await vault.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); + await storeEnvelope({ service, vault, slug: 'asset', data: randomBytes(128), passphrase: oldPass }); + + const oldState = await vault.readState(); + const oldSalt = oldState.metadata.encryption.kdf.salt; + + await rotateVaultPassphrase( + { service, vault }, + { oldPassphrase: oldPass, newPassphrase: newPass }, + ); + + const newState = await vault.readState(); + expect(newState.metadata.encryption.kdf.salt).not.toBe(oldSalt); + expect(newState.metadata.encryption.kdf.algorithm).toBe(oldState.metadata.encryption.kdf.algorithm); + }); +}); + +describe('rotateVaultPassphrase – retry success', () => { + let repoDir; + let service; + let vault; + + beforeEach(async () => { + repoDir = createRepo(); + ({ service, vault } = await createDeps(repoDir)); + }); + afterEach(() => { + vi.restoreAllMocks(); + if (repoDir) { rmSync(repoDir, { recursive: true, force: true }); } + }); + + it('retries on VAULT_CONFLICT and succeeds within maxRetries', async () => { + const oldPass = 'old-pass'; + const newPass = 'new-pass'; + await vault.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); + await storeEnvelope({ service, vault, slug: 'a', data: randomBytes(128), passphrase: oldPass }); + + let calls = 0; + const original = vault.writeCommit.bind(vault); + vi.spyOn(vault, 'writeCommit').mockImplementation(async (opts) => { + calls++; + if (calls === 1) { throw new CasError('conflict', 'VAULT_CONFLICT'); } + return original(opts); + }); + + const result = await rotateVaultPassphrase( + { service, vault }, + { oldPassphrase: oldPass, newPassphrase: newPass, maxRetries: 2, retryBaseMs: 1 }, + ); + expect(result.commitOid).toMatch(/^[0-9a-f]{40}$/); + expect(calls).toBe(2); + }); +}); + +describe('rotateVaultPassphrase – maxRetries exhausted', () => { + let repoDir; + let service; + let vault; + + beforeEach(async () => { + repoDir = createRepo(); + ({ service, vault } = await createDeps(repoDir)); + }); + afterEach(() => { + vi.restoreAllMocks(); + if (repoDir) { rmSync(repoDir, { recursive: true, force: true }); } + }); + + it('fails after exactly maxRetries attempts', async () => { + const oldPass = 'old-pass'; + await vault.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); + await storeEnvelope({ service, vault, slug: 'a', data: randomBytes(128), passphrase: oldPass }); + + let calls = 0; + vi.spyOn(vault, 'writeCommit').mockImplementation(async () => { + calls++; + throw new CasError('conflict', 'VAULT_CONFLICT'); + }); + + await expect( + rotateVaultPassphrase( + { service, vault }, + { oldPassphrase: oldPass, newPassphrase: 'new', maxRetries: 1, retryBaseMs: 1 }, + ), + ).rejects.toMatchObject({ code: 'VAULT_CONFLICT' }); + expect(calls).toBe(1); + }); +}); + +describe('rotateVaultPassphrase – default retry count', () => { + let repoDir; + let service; + let vault; + + beforeEach(async () => { + repoDir = createRepo(); + ({ service, vault } = await createDeps(repoDir)); + }); + afterEach(() => { + vi.restoreAllMocks(); + if (repoDir) { rmSync(repoDir, { recursive: true, force: true }); } + }); + + it('maxRetries defaults to 3 when not specified', async () => { + const oldPass = 'old-pass'; + await vault.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); + await storeEnvelope({ service, vault, slug: 'a', data: randomBytes(128), passphrase: oldPass }); + + let calls = 0; + vi.spyOn(vault, 'writeCommit').mockImplementation(async () => { + calls++; + throw new CasError('conflict', 'VAULT_CONFLICT'); + }); + + await expect( + rotateVaultPassphrase( + { service, vault }, + { oldPassphrase: oldPass, newPassphrase: 'new', retryBaseMs: 1 }, + ), + ).rejects.toMatchObject({ code: 'VAULT_CONFLICT' }); + expect(calls).toBe(3); + }); +}); diff --git a/test/unit/facade/ContentAddressableStore.rotation.test.js b/test/unit/facade/ContentAddressableStore.rotation.test.js index 7222dca..4c5ac4c 100644 --- a/test/unit/facade/ContentAddressableStore.rotation.test.js +++ b/test/unit/facade/ContentAddressableStore.rotation.test.js @@ -27,29 +27,10 @@ async function* bufferSource(buf) { yield buf; } -async function storeEnvelope({ cas, slug, data, passphrase }) { - const metadata = await cas.getVaultMetadata(); - const { key } = await cas.deriveKey({ - passphrase, - salt: Buffer.from(metadata.encryption.kdf.salt, 'base64'), - algorithm: metadata.encryption.kdf.algorithm, - iterations: metadata.encryption.kdf.iterations, - }); - const manifest = await cas.store({ - source: bufferSource(data), - slug, - filename: `${slug}.bin`, - recipients: [{ label: 'vault', key }], - }); - const treeOid = await cas.createTree({ manifest }); - await cas.addToVault({ slug, treeOid, force: true }); - return treeOid; -} - // --------------------------------------------------------------------------- -// Tests +// Wiring test — verifies facade delegates to rotateVaultPassphrase // --------------------------------------------------------------------------- -describe('ContentAddressableStore – rotateVaultPassphrase', () => { // eslint-disable-line max-lines-per-function +describe('ContentAddressableStore – rotateVaultPassphrase (wiring)', () => { let repoDir; let cas; @@ -62,136 +43,34 @@ describe('ContentAddressableStore – rotateVaultPassphrase', () => { // eslint- if (repoDir) { rmSync(repoDir, { recursive: true, force: true }); } }); - it('3 envelope entries → rotate → all restorable with new passphrase', async () => { - const oldPass = 'old-pass'; - const newPass = 'new-pass'; - await cas.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); - - const originals = {}; - for (const name of ['alpha', 'beta', 'gamma']) { - const data = randomBytes(256); - originals[name] = data; - await storeEnvelope({ cas, slug: name, data, passphrase: oldPass }); - } - - const { commitOid, rotatedSlugs, skippedSlugs } = await cas.rotateVaultPassphrase({ - oldPassphrase: oldPass, newPassphrase: newPass, - }); - - expect(commitOid).toMatch(/^[0-9a-f]{40}$/); - expect(rotatedSlugs.sort()).toEqual(['alpha', 'beta', 'gamma']); - expect(skippedSlugs).toEqual([]); - - // Verify all restorable with new passphrase - const newMeta = await cas.getVaultMetadata(); - const { key: newKey } = await cas.deriveKey({ - passphrase: newPass, - salt: Buffer.from(newMeta.encryption.kdf.salt, 'base64'), - algorithm: newMeta.encryption.kdf.algorithm, - iterations: newMeta.encryption.kdf.iterations, - }); - - for (const name of ['alpha', 'beta', 'gamma']) { - const treeOid = await cas.resolveVaultEntry({ slug: name }); - const manifest = await cas.readManifest({ treeOid }); - const { buffer } = await cas.restore({ manifest, encryptionKey: newKey }); - expect(buffer.equals(originals[name])).toBe(true); - } - }); - - it('mixed: 2 envelope + 1 non-envelope → 2 rotated, 1 skipped', async () => { + it('delegates to rotateVaultPassphrase and returns result', async () => { const oldPass = 'old-pass'; const newPass = 'new-pass'; await cas.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); - // 2 envelope entries - await storeEnvelope({ cas, slug: 'env1', data: randomBytes(128), passphrase: oldPass }); - await storeEnvelope({ cas, slug: 'env2', data: randomBytes(128), passphrase: oldPass }); - - // 1 non-envelope (direct key from vault passphrase) + // Store one envelope entry through the facade const metadata = await cas.getVaultMetadata(); - const { key: directKey } = await cas.deriveKey({ + const { key } = await cas.deriveKey({ passphrase: oldPass, salt: Buffer.from(metadata.encryption.kdf.salt, 'base64'), algorithm: metadata.encryption.kdf.algorithm, iterations: metadata.encryption.kdf.iterations, }); - const plainManifest = await cas.store({ + const manifest = await cas.store({ source: bufferSource(randomBytes(128)), - slug: 'direct', - filename: 'direct.bin', - encryptionKey: directKey, + slug: 'asset', + filename: 'asset.bin', + recipients: [{ label: 'vault', key }], }); - const plainTree = await cas.createTree({ manifest: plainManifest }); - await cas.addToVault({ slug: 'direct', treeOid: plainTree }); + const treeOid = await cas.createTree({ manifest }); + await cas.addToVault({ slug: 'asset', treeOid, force: true }); - const { rotatedSlugs, skippedSlugs } = await cas.rotateVaultPassphrase({ + const { commitOid, rotatedSlugs, skippedSlugs } = await cas.rotateVaultPassphrase({ oldPassphrase: oldPass, newPassphrase: newPass, }); - expect(rotatedSlugs.sort()).toEqual(['env1', 'env2']); - expect(skippedSlugs).toEqual(['direct']); - }); - - it('wrong old passphrase → error', async () => { - const oldPass = 'old-pass'; - await cas.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); - await storeEnvelope({ cas, slug: 'asset', data: randomBytes(128), passphrase: oldPass }); - - await expect( - cas.rotateVaultPassphrase({ oldPassphrase: 'wrong', newPassphrase: 'new' }), - ).rejects.toThrow(); - }); - - it('vault not encrypted → VAULT_METADATA_INVALID', async () => { - await cas.initVault(); - // Store unencrypted entry - const manifest = await cas.store({ - source: bufferSource(randomBytes(64)), - slug: 'plain', - filename: 'plain.bin', - }); - const tree = await cas.createTree({ manifest }); - await cas.addToVault({ slug: 'plain', treeOid: tree }); - - await expect( - cas.rotateVaultPassphrase({ oldPassphrase: 'any', newPassphrase: 'new' }), - ).rejects.toMatchObject({ code: 'VAULT_METADATA_INVALID' }); - }); - - it('kdfOptions.algorithm overrides existing algorithm', async () => { - const oldPass = 'old-pass'; - const newPass = 'new-pass'; - // Init with default algorithm (pbkdf2) - await cas.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); - await storeEnvelope({ cas, slug: 'asset', data: randomBytes(128), passphrase: oldPass }); - - const oldMeta = await cas.getVaultMetadata(); - expect(oldMeta.encryption.kdf.algorithm).toBe('pbkdf2'); - - await cas.rotateVaultPassphrase({ - oldPassphrase: oldPass, - newPassphrase: newPass, - kdfOptions: { algorithm: 'scrypt' }, - }); - - const newMeta = await cas.getVaultMetadata(); - expect(newMeta.encryption.kdf.algorithm).toBe('scrypt'); - }); - - it('metadata updated with new KDF salt', async () => { - const oldPass = 'old-pass'; - const newPass = 'new-pass'; - await cas.initVault({ passphrase: oldPass, kdfOptions: { iterations: 1 } }); - await storeEnvelope({ cas, slug: 'asset', data: randomBytes(128), passphrase: oldPass }); - - const oldMeta = await cas.getVaultMetadata(); - const oldSalt = oldMeta.encryption.kdf.salt; - - await cas.rotateVaultPassphrase({ oldPassphrase: oldPass, newPassphrase: newPass }); - - const newMeta = await cas.getVaultMetadata(); - expect(newMeta.encryption.kdf.salt).not.toBe(oldSalt); - expect(newMeta.encryption.kdf.algorithm).toBe(oldMeta.encryption.kdf.algorithm); + expect(commitOid).toMatch(/^[0-9a-f]{40}$/); + expect(rotatedSlugs).toEqual(['asset']); + expect(skippedSlugs).toEqual([]); }); }); diff --git a/test/unit/infrastructure/adapters/FileIOHelper.test.js b/test/unit/infrastructure/adapters/FileIOHelper.test.js new file mode 100644 index 0000000..cb02b0c --- /dev/null +++ b/test/unit/infrastructure/adapters/FileIOHelper.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { storeFile, restoreFile } from '../../../../src/infrastructure/adapters/FileIOHelper.js'; + +describe('FileIOHelper – storeFile', () => { + let tmpDir; + + beforeEach(() => { tmpDir = mkdtempSync(path.join(os.tmpdir(), 'fio-store-')); }); + afterEach(() => { if (tmpDir) { rmSync(tmpDir, { recursive: true, force: true }); } }); + + it('passes a readable stream and options to service.store()', async () => { + const filePath = path.join(tmpDir, 'input.bin'); + const data = Buffer.from('hello storeFile'); + writeFileSync(filePath, data); + + let capturedOpts; + const mockService = { + async store(opts) { + const chunks = []; + for await (const chunk of opts.source) { chunks.push(chunk); } + capturedOpts = { ...opts, source: Buffer.concat(chunks) }; + return { slug: opts.slug }; + }, + }; + + const result = await storeFile(mockService, { filePath, slug: 'test-slug' }); + expect(result).toEqual({ slug: 'test-slug' }); + expect(capturedOpts.source.equals(data)).toBe(true); + expect(capturedOpts.slug).toBe('test-slug'); + expect(capturedOpts.filename).toBe('input.bin'); + }); + + it('uses filename override when provided', async () => { + const filePath = path.join(tmpDir, 'input.bin'); + writeFileSync(filePath, 'data'); + + let capturedFilename; + const mockService = { + async store(opts) { + // eslint-disable-next-line no-unused-vars + for await (const _ of opts.source) { /* drain */ } + capturedFilename = opts.filename; + return {}; + }, + }; + + await storeFile(mockService, { filePath, slug: 's', filename: 'custom.dat' }); + expect(capturedFilename).toBe('custom.dat'); + }); +}); + +describe('FileIOHelper – restoreFile', () => { + let tmpDir; + + beforeEach(() => { tmpDir = mkdtempSync(path.join(os.tmpdir(), 'fio-restore-')); }); + afterEach(() => { if (tmpDir) { rmSync(tmpDir, { recursive: true, force: true }); } }); + + it('writes restored chunks to the output path and counts bytes', async () => { + const outputPath = path.join(tmpDir, 'output.bin'); + const chunk1 = Buffer.from('hello '); + const chunk2 = Buffer.from('world'); + + const mockService = { + restoreStream() { + return (async function* gen() { + yield chunk1; + yield chunk2; + })(); + }, + }; + + const { bytesWritten } = await restoreFile(mockService, { + manifest: {}, + outputPath, + }); + + expect(bytesWritten).toBe(11); + const written = readFileSync(outputPath); + expect(written.toString()).toBe('hello world'); + }); +}); diff --git a/test/unit/infrastructure/adapters/createCryptoAdapter.test.js b/test/unit/infrastructure/adapters/createCryptoAdapter.test.js new file mode 100644 index 0000000..10e6f7a --- /dev/null +++ b/test/unit/infrastructure/adapters/createCryptoAdapter.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import createCryptoAdapter from '../../../../src/infrastructure/adapters/createCryptoAdapter.js'; +import CryptoPort from '../../../../src/ports/CryptoPort.js'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; + +describe('createCryptoAdapter', () => { + const origBun = globalThis.Bun; + const origDeno = globalThis.Deno; + + afterEach(() => { + // Restore globals — wrapped in try/catch because Bun/Deno expose + // their namesake globals as read-only properties on globalThis. + try { if (origBun === undefined) { delete globalThis.Bun; } else { globalThis.Bun = origBun; } } catch { /* immutable on Bun */ } + try { if (origDeno === undefined) { delete globalThis.Deno; } else { globalThis.Deno = origDeno; } } catch { /* immutable on Deno */ } + }); + + it('returns a CryptoPort instance', async () => { + const adapter = await createCryptoAdapter(); + expect(adapter).toBeInstanceOf(CryptoPort); + }); + + it('returns NodeCryptoAdapter when neither Bun nor Deno globals exist', { skip: !!(origBun || origDeno) }, async () => { + const adapter = await createCryptoAdapter(); + expect(adapter).toBeInstanceOf(NodeCryptoAdapter); + }); + + it('returns BunCryptoAdapter when globalThis.Bun exists', async () => { + // Skip if we're not running on Bun — the dynamic import would fail + if (!origBun) { return; } + const adapter = await createCryptoAdapter(); + expect(adapter).toBeInstanceOf(CryptoPort); + expect(adapter.constructor.name).toBe('BunCryptoAdapter'); + }); + + it('returns WebCryptoAdapter when globalThis.Deno exists', async () => { + // Skip if we're not running on Deno — the dynamic import would fail + if (!origDeno) { return; } + const adapter = await createCryptoAdapter(); + expect(adapter).toBeInstanceOf(CryptoPort); + expect(adapter.constructor.name).toBe('WebCryptoAdapter'); + }); + + it('sha256 returns a Promise on all adapters', async () => { + const adapter = await createCryptoAdapter(); + const buf = Buffer.from('hello'); + expect(adapter.sha256(buf)).toBeInstanceOf(Promise); + }); +}); diff --git a/test/unit/infrastructure/chunkers/resolveChunker.test.js b/test/unit/infrastructure/chunkers/resolveChunker.test.js new file mode 100644 index 0000000..62457fd --- /dev/null +++ b/test/unit/infrastructure/chunkers/resolveChunker.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import resolveChunker from '../../../../src/infrastructure/chunkers/resolveChunker.js'; +import FixedChunker from '../../../../src/infrastructure/chunkers/FixedChunker.js'; +import CdcChunker from '../../../../src/infrastructure/chunkers/CdcChunker.js'; +import ChunkingPort from '../../../../src/ports/ChunkingPort.js'; + +describe('resolveChunker – defaults', () => { + it('returns undefined when called with no arguments', () => { + expect(resolveChunker()).toBeUndefined(); + }); + + it('returns undefined when called with empty object', () => { + expect(resolveChunker({})).toBeUndefined(); + }); +}); + +describe('resolveChunker – raw chunker', () => { + it('returns the chunker instance when chunker is provided', () => { + const custom = new CdcChunker(); + expect(resolveChunker({ chunker: custom })).toBe(custom); + }); + + it('chunker takes precedence over chunking config', () => { + const custom = new CdcChunker(); + const result = resolveChunker({ + chunker: custom, + chunking: { strategy: 'fixed', chunkSize: 1024 }, + }); + expect(result).toBe(custom); + }); +}); + +describe('resolveChunker – cdc strategy', () => { + it('chunking: { strategy: "cdc" } returns CdcChunker', () => { + const result = resolveChunker({ + chunking: { strategy: 'cdc', targetChunkSize: 262144, minChunkSize: 65536, maxChunkSize: 1048576 }, + }); + expect(result).toBeInstanceOf(CdcChunker); + expect(result).toBeInstanceOf(ChunkingPort); + expect(result.params).toEqual({ target: 262144, min: 65536, max: 1048576 }); + }); + + it('chunking: { strategy: "cdc" } with defaults works', () => { + const result = resolveChunker({ chunking: { strategy: 'cdc' } }); + expect(result).toBeInstanceOf(CdcChunker); + }); +}); + +describe('resolveChunker – fixed strategy', () => { + it('chunking: { strategy: "fixed", chunkSize } returns FixedChunker', () => { + const result = resolveChunker({ chunking: { strategy: 'fixed', chunkSize: 131072 } }); + expect(result).toBeInstanceOf(FixedChunker); + expect(result.params).toEqual({ chunkSize: 131072 }); + }); + + it('chunking: { strategy: "fixed" } without chunkSize returns undefined', () => { + expect(resolveChunker({ chunking: { strategy: 'fixed' } })).toBeUndefined(); + }); + + it('chunking: { strategy: "fixed", chunkSize: NaN } returns undefined', () => { + expect(resolveChunker({ chunking: { strategy: 'fixed', chunkSize: NaN } })).toBeUndefined(); + }); + + it('chunking: { strategy: "fixed", chunkSize: -1 } returns undefined', () => { + expect(resolveChunker({ chunking: { strategy: 'fixed', chunkSize: -1 } })).toBeUndefined(); + }); + + it('chunking: { strategy: "fixed", chunkSize: Infinity } returns undefined', () => { + expect(resolveChunker({ chunking: { strategy: 'fixed', chunkSize: Infinity } })).toBeUndefined(); + }); + + it('chunking: { strategy: "unknown" } returns undefined', () => { + expect(resolveChunker({ chunking: { strategy: 'unknown' } })).toBeUndefined(); + }); +});