Skip to content

Commit a643078

Browse files
authored
Merge pull request #15 from git-stunts/chore/followups
2 parents a9897fc + 090f7a9 commit a643078

8 files changed

Lines changed: 165 additions & 45 deletions

File tree

CHANGELOG.md

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

8+
## [Unreleased]
9+
10+
### Changed
11+
- **Deterministic fuzz test** — envelope fuzz round-trip test now uses a seeded xorshift32 PRNG instead of `Math.random()`, making failures reproducible across runs.
12+
- **DRY chunk verification** — extracted `_readAndVerifyChunk()` in `CasService`; both the buffered and streaming restore paths now delegate to the same single-chunk verification method.
13+
- **DRY KDF metadata** — extracted `buildKdfMetadata()` helper (`src/domain/helpers/buildKdfMetadata.js`); `VaultService` and `ContentAddressableStore` both call it instead of duplicating the KDF object construction.
14+
815
## [5.2.2] — JSDoc total coverage (2026-02-28)
916

1017
### Added

ROADMAP.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,51 @@ All tasks completed (13.1–13.6). See [COMPLETED_TASKS.md](./COMPLETED_TASKS.md
605605

606606
---
607607

608+
## M15 — Prism (code hygiene)
609+
610+
Consistency and DRY fixes surfaced by architecture audit. No new features, no API changes.
611+
612+
### 14.1 — Consistent async `sha256()` across CryptoPort adapters
613+
614+
**Problem:** `NodeCryptoAdapter.sha256()` returns `string` (sync), while `BunCryptoAdapter` and `WebCryptoAdapter` return `Promise<string>`. Callers must defensively `await` every call. This is a Liskov Substitution violation — adapters are not interchangeable without the caller knowing which one it got.
615+
616+
**Fix:** Make `NodeCryptoAdapter.sha256()` return `Promise<string>` (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.
617+
618+
**Files:**
619+
- `src/infrastructure/adapters/NodeCryptoAdapter.js` — wrap return in `Promise.resolve()`
620+
- `src/ports/CryptoPort.js` — update JSDoc to document `Promise<string>` as the contract
621+
622+
**Risk:** None. All callers already `await`. Changing sync→async is backward compatible for awaiting code.
623+
624+
**Tests:** Existing crypto adapter tests already assert on the resolved value. Add an explicit test: `expect(adapter.sha256(buf)).toBeInstanceOf(Promise)`.
625+
626+
### 14.2 — Extract `KeyResolver` from CasService
627+
628+
**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."
629+
630+
**Fix:** Extract a `KeyResolver` class into `src/domain/services/KeyResolver.js`. It receives a `CryptoPort` via constructor injection. CasService delegates to it.
631+
632+
**API:**
633+
```js
634+
class KeyResolver {
635+
constructor(crypto) { this.crypto = crypto; }
636+
async resolveForDecryption(manifest, encryptionKey, passphrase) { ... }
637+
async resolveForStore(encryptionKey, passphrase, kdfOptions) { ... }
638+
async resolveRecipients(recipients) { ... }
639+
async wrapDek(dek, kek) { ... }
640+
async unwrapDek(recipientEntry, kek) { ... }
641+
}
642+
```
643+
644+
**Files:**
645+
- New: `src/domain/services/KeyResolver.js`
646+
- Modified: `src/domain/services/CasService.js` — delegate key resolution
647+
- New: `test/unit/domain/services/KeyResolver.test.js`
648+
649+
**Risk:** Low — internal refactor, no public API change. CasService methods remain unchanged.
650+
651+
---
652+
608653
## Backlog (unscheduled)
609654

610655
Ideas for future milestones. Not committed, not prioritized — just captured.

index.js

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import EventEmitterObserver from './src/infrastructure/adapters/EventEmitterObse
2525
import StatsCollector from './src/infrastructure/adapters/StatsCollector.js';
2626
import FixedChunker from './src/infrastructure/chunkers/FixedChunker.js';
2727
import CdcChunker from './src/infrastructure/chunkers/CdcChunker.js';
28+
import buildKdfMetadata from './src/domain/helpers/buildKdfMetadata.js';
2829

2930
export {
3031
CasService,
@@ -627,15 +628,7 @@ export default class ContentAddressableStore {
627628
...metadata,
628629
encryption: {
629630
cipher: metadata.encryption.cipher,
630-
kdf: {
631-
algorithm: newParams.algorithm,
632-
salt: newSalt.toString('base64'),
633-
...('iterations' in newParams && { iterations: newParams.iterations }),
634-
...('cost' in newParams && { cost: newParams.cost }),
635-
...('blockSize' in newParams && { blockSize: newParams.blockSize }),
636-
...('parallelization' in newParams && { parallelization: newParams.parallelization }),
637-
keyLength: newParams.keyLength,
638-
},
631+
kdf: buildKdfMetadata(newSalt, newParams),
639632
},
640633
};
641634
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Builds the KDF metadata object for vault/manifest encryption metadata.
3+
*
4+
* @param {Buffer} salt - KDF salt.
5+
* @param {{ algorithm: string, keyLength: number, iterations?: number, cost?: number, blockSize?: number, parallelization?: number }} params - KDF parameters.
6+
* @returns {{ algorithm: string, salt: string, keyLength: number, iterations?: number, cost?: number, blockSize?: number, parallelization?: number }}
7+
*/
8+
export default function buildKdfMetadata(salt, params) {
9+
return {
10+
algorithm: params.algorithm,
11+
salt: salt.toString('base64'),
12+
...('iterations' in params && { iterations: params.iterations }),
13+
...('cost' in params && { cost: params.cost }),
14+
...('blockSize' in params && { blockSize: params.blockSize }),
15+
...('parallelization' in params && { parallelization: params.parallelization }),
16+
keyLength: params.keyLength,
17+
};
18+
}

src/domain/services/CasService.js

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,28 @@ export default class CasService {
519519
return await this.persistence.writeTree(treeEntries);
520520
}
521521

522+
/**
523+
* Reads a single chunk blob from Git and verifies its SHA-256 digest.
524+
* @private
525+
* @param {{ index: number, size: number, digest: string, blob: string }} chunk - Chunk metadata.
526+
* @returns {Promise<Buffer>} Verified chunk buffer.
527+
* @throws {CasError} INTEGRITY_ERROR if the chunk digest does not match.
528+
*/
529+
async _readAndVerifyChunk(chunk) {
530+
const blob = await this.persistence.readBlob(chunk.blob);
531+
const digest = await this._sha256(blob);
532+
if (digest !== chunk.digest) {
533+
const err = new CasError(
534+
`Chunk ${chunk.index} integrity check failed`,
535+
'INTEGRITY_ERROR',
536+
{ chunkIndex: chunk.index, expected: chunk.digest, actual: digest },
537+
);
538+
this.observability.metric('error', { code: err.code, message: err.message });
539+
throw err;
540+
}
541+
return blob;
542+
}
543+
522544
/**
523545
* Reads chunk blobs from Git and verifies their SHA-256 digests.
524546
* @private
@@ -529,17 +551,7 @@ export default class CasService {
529551
async _readAndVerifyChunks(chunks) {
530552
const buffers = [];
531553
for (const chunk of chunks) {
532-
const blob = await this.persistence.readBlob(chunk.blob);
533-
const digest = await this._sha256(blob);
534-
if (digest !== chunk.digest) {
535-
const err = new CasError(
536-
`Chunk ${chunk.index} integrity check failed`,
537-
'INTEGRITY_ERROR',
538-
{ chunkIndex: chunk.index, expected: chunk.digest, actual: digest },
539-
);
540-
this.observability.metric('error', { code: err.code, message: err.message });
541-
throw err;
542-
}
554+
const blob = await this._readAndVerifyChunk(chunk);
543555
buffers.push(blob);
544556
this.observability.metric('chunk', { action: 'restored', index: chunk.index, size: blob.length, digest: chunk.digest });
545557
}
@@ -659,20 +671,7 @@ export default class CasService {
659671
const readAhead = this.concurrency;
660672
let totalSize = 0;
661673

662-
const readAndVerify = async (chunk) => {
663-
const blob = await this.persistence.readBlob(chunk.blob);
664-
const digest = await this._sha256(blob);
665-
if (digest !== chunk.digest) {
666-
const err = new CasError(
667-
`Chunk ${chunk.index} integrity check failed`,
668-
'INTEGRITY_ERROR',
669-
{ chunkIndex: chunk.index, expected: chunk.digest, actual: digest },
670-
);
671-
this.observability.metric('error', { code: err.code, message: err.message });
672-
throw err;
673-
}
674-
return blob;
675-
};
674+
const readAndVerify = (chunk) => this._readAndVerifyChunk(chunk);
676675

677676
const ahead = [];
678677
for (let i = 0; i < Math.min(readAhead, chunks.length); i++) {

src/domain/services/VaultService.js

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @fileoverview Domain service for vault (GC-safe ref-based asset index) operations.
33
*/
44
import CasError from '../errors/CasError.js';
5+
import buildKdfMetadata from '../helpers/buildKdfMetadata.js';
56

67
const VAULT_REF = 'refs/cas/vault';
78
const MAX_CAS_RETRIES = 3;
@@ -327,15 +328,7 @@ export default class VaultService {
327328
static #buildEncryptionMeta(salt, params) {
328329
return {
329330
cipher: 'aes-256-gcm',
330-
kdf: {
331-
algorithm: params.algorithm,
332-
salt: salt.toString('base64'),
333-
...('iterations' in params && { iterations: params.iterations }),
334-
...('cost' in params && { cost: params.cost }),
335-
...('blockSize' in params && { blockSize: params.blockSize }),
336-
...('parallelization' in params && { parallelization: params.parallelization }),
337-
keyLength: params.keyLength,
338-
},
331+
kdf: buildKdfMetadata(salt, params),
339332
};
340333
}
341334

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect } from 'vitest';
2+
import buildKdfMetadata from '../../../../src/domain/helpers/buildKdfMetadata.js';
3+
4+
describe('buildKdfMetadata', () => {
5+
it('builds PBKDF2 metadata with iterations', () => {
6+
const salt = Buffer.from('test-salt');
7+
const params = { algorithm: 'pbkdf2', iterations: 100000, keyLength: 32 };
8+
9+
const result = buildKdfMetadata(salt, params);
10+
11+
expect(result).toEqual({
12+
algorithm: 'pbkdf2',
13+
salt: salt.toString('base64'),
14+
iterations: 100000,
15+
keyLength: 32,
16+
});
17+
});
18+
19+
it('builds scrypt metadata with cost/blockSize/parallelization', () => {
20+
const salt = Buffer.from('scrypt-salt');
21+
const params = { algorithm: 'scrypt', cost: 16384, blockSize: 8, parallelization: 1, keyLength: 32 };
22+
23+
const result = buildKdfMetadata(salt, params);
24+
25+
expect(result).toEqual({
26+
algorithm: 'scrypt',
27+
salt: salt.toString('base64'),
28+
cost: 16384,
29+
blockSize: 8,
30+
parallelization: 1,
31+
keyLength: 32,
32+
});
33+
});
34+
35+
it('omits optional fields when absent', () => {
36+
const salt = Buffer.from('minimal');
37+
const params = { algorithm: 'pbkdf2', keyLength: 32 };
38+
39+
const result = buildKdfMetadata(salt, params);
40+
41+
expect(result).toEqual({
42+
algorithm: 'pbkdf2',
43+
salt: salt.toString('base64'),
44+
keyLength: 32,
45+
});
46+
expect(result).not.toHaveProperty('iterations');
47+
expect(result).not.toHaveProperty('cost');
48+
expect(result).not.toHaveProperty('blockSize');
49+
expect(result).not.toHaveProperty('parallelization');
50+
});
51+
});

test/unit/domain/services/CasService.envelope.test.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ import CasError from '../../../../src/domain/errors/CasError.js';
88

99
const testCrypto = await getTestCryptoAdapter();
1010

11+
// ---------------------------------------------------------------------------
12+
// Deterministic PRNG (xorshift32) — keeps fuzz tests reproducible
13+
// ---------------------------------------------------------------------------
14+
function createSeededRng(seed = 42) {
15+
let s = seed >>> 0 || 1;
16+
return (max) => {
17+
s ^= s << 13;
18+
s ^= s >>> 17;
19+
s ^= s << 5;
20+
return (s >>> 0) % max;
21+
};
22+
}
23+
1124
// ---------------------------------------------------------------------------
1225
// Helpers
1326
// ---------------------------------------------------------------------------
@@ -310,8 +323,9 @@ describe('CasService – envelope encryption (fuzz round-trips)', () => {
310323
beforeEach(() => { ({ service } = setup()); });
311324

312325
it('50 random plaintexts × 3 random KEKs all round-trip', async () => {
326+
const rng = createSeededRng(12345);
313327
for (let i = 0; i < 50; i++) {
314-
const size = Math.floor(Math.random() * 4096);
328+
const size = rng(4096);
315329
const original = randomBytes(size);
316330
const keys = [randomBytes(32), randomBytes(32), randomBytes(32)];
317331

@@ -322,7 +336,7 @@ describe('CasService – envelope encryption (fuzz round-trips)', () => {
322336
recipients: keys.map((key, j) => ({ label: `k${j}`, key })),
323337
});
324338

325-
const idx = Math.floor(Math.random() * 3);
339+
const idx = rng(3);
326340
const { buffer } = await service.restore({ manifest, encryptionKey: keys[idx] });
327341
expect(buffer.equals(original)).toBe(true);
328342
}

0 commit comments

Comments
 (0)