Skip to content

Commit 222ecc2

Browse files
feat(spec): ICryptoProvider gains a required keyedDigest member; LocalCryptoProvider implements it (#21292)
Fixes #21263 Clause-②: yes (narrowing) `ICryptoProvider` gains one required member, `keyedDigest(plain: string)`, which resolves to a string. `LocalCryptoProvider` implements it. This is the contract half of the maintainer's option B ruling on #21207. Routing the provider to the exits that serve a content hash is #21207's exit two, and #21207 is not touched here. **Cross-lane surface (`domain:services`, declared in the claim):** `packages/services/service-settings/src/local-crypto-provider.ts` and its test file. ## The contract (`packages/spec/src/contracts/crypto-provider.ts`) The new docblock states three requirements on every implementation: 1. **Keyed.** The output MUST NOT be computable from the input without the provider's key. A provider that holds no key material MUST reject. It never resolves to an unkeyed value: not a plain hash, and not a MAC under an empty or publicly known key. 2. **Stable per key.** Under one key, equal input gives equal output in every process and on every node that holds the key. 3. **Not a substitute for `digest`.** `digest` keeps its own contract and the stability the audit trail relies on. `digest`'s docblock gains one paragraph. It says `digest` is not keyed by contract (plain SHA-256 satisfies it) and points to the new member. `digest`'s behaviour and wording are otherwise unchanged. ### Choices the card left open - **Name: `keyedDigest`.** It sits beside `digest` and puts the property that differs ("keyed") into every call site, so a reader choosing between the two sees the difference in the name. - **Asynchronous.** A managed-custody provider computes the MAC inside its KMS, where the key never leaves. A synchronous signature would force such a provider to hold the key in process. - **Output: `hmac-sha256:` followed by 64 lowercase hex characters.** That is 76 characters drawn from `[0-9a-z:-]`. - The served version token is documented as opaque (`SaveMetaItemResponse.version`: "echo it verbatim, never parse it"). - It travels as an HTTP header value, as a query-string value and inside JSON. Inbound, it is validated only as a string. - This shape passes all three carriers unescaped. - The prefix keeps it disjoint from the `sha256:` spelling of the unkeyed content hash, so a value that was served unkeyed is recognisable by its prefix alone. - Hex matches the existing `digest` spelling. - The shape is fixed in the contract rather than left to each provider, so a conformance pin can check any provider. ## Key material (`LocalCryptoProvider`) **Sources, measured at `4bf4e7e70a`.** `resolveDataKey` resolves exactly one 32-byte data key, from the first of these that applies: 1. an explicit `opts.key`; 2. `OS_SECRET_KEY`; 3. `OS_DEV_CRYPTO_KEY`, or its legacy alias; 4. the persisted key file (read in production; created in development, or in production only under `OS_CRYPTO_AUTOKEY`); 5. an ephemeral key (always in test mode, and in development only as a loudly warned last resort). `keyedDigest` uses whichever key resolved. There is no new secret and no new environment variable. **Derived, not reused.** The MAC key is `HMAC-SHA-256(dataKey, "objectstack/crypto-provider/keyed-digest/v1" || 0x01)`. That is RFC 5869 HKDF-Expand for one block, with the data key as the pseudorandom key (§3.3 allows skipping Extract when the key is already uniformly random). The argument, from the docblock written here: - **One key, one purpose.** The AES-GCM key never becomes a MAC key. - **Exposure.** `keyedDigest` output is handed to callers. The key behind it should not be the key that protects every stored secret. - **Versioned label.** Any future change of construction must take a new label, so it is deliberate. A pinned test vector makes a drift visible. Only `createHmac` is used, not `hkdfSync`. The WebContainer path, which already cannot run AES-GCM through `node:crypto`, is therefore not handed a second primitive it may lack (not measured there). **What "no key material" means for this provider, measured.** - Every environment and file source is length-checked: `parseKey` accepts exactly 32 bytes. - In production, construction refuses when no stable source exists. - The one unchecked route is an explicit `opts.key`. An empty buffer, or a 16-byte one, constructs today. - Such an instance now rejects `keyedDigest` with `KeyedDigestKeyUnavailableError`. It never returns an HMAC under an empty key, which anyone can compute. This is pinned. ## Census: every in-repo implementation and test double of `ICryptoProvider` Measured at `4bf4e7e70a` across `packages/**`, `examples/**` and `apps/**` with three searches: - the identifier `ICryptoProvider`; - files defining `rotateKey`; - every `setCryptoProvider(`, `cryptoProvider:` and `new LocalCryptoProvider` site. `examples/**` and `apps/**` have zero hits. | Site | Shape | Breaks at type level? | Action | |---|---|---|---| | `packages/services/service-settings/src/local-crypto-provider.ts` (`LocalCryptoProvider`, alias `InMemoryCryptoProvider`) | class implementing the interface | yes | implements the member | | `packages/objectql/src/secret-fields.test.ts` (`makeFakeCrypto`) | literal typed `ICryptoProvider` | yes (compiled by `check:test-typecheck`) | member added | | `packages/plugins/plugin-auth/src/sso-client-secret-at-rest.test.ts` (`makeFakeCrypto`) | function returning `ICryptoProvider` | yes (compiled by `check:test-typecheck`) | member added | | `packages/plugins/plugin-webhooks/src/webhook-headers-gate.test.ts` (`makeFakeCrypto`) | function returning `ICryptoProvider` | yes | member added | | `packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts` (`makeFakeCrypto`) | literal typed `ICryptoProvider` | yes | member added | | `packages/services/service-datasource/src/__tests__/datasource-secret-binder.test.ts` (`fakeCrypto`) | function returning `ICryptoProvider` | yes (reverse-verified below) | member added | | `packages/plugins/plugin-audit/src/audit-bound-previous.test.ts`, `audit-milestone-summary.test.ts` | literal typed `any` | no | none: no type break, and no path these tests drive calls the member | | `packages/plugins/plugin-security/src/insert-check-post-image.test.ts` | untyped literal passed `as never` | no | none, same reason | | `packages/objectql/src/engine-privileged-read-ambient-transaction.test.ts` | inline literal cast `as any` | no | none, same reason | | `LocalCryptoProvider` instances in `packages/cli`, `packages/verify` and the service-settings tests | class instances | no | inherit the member | The five added test-double members are deterministic stand-ins, in the same style as each file's `digest` stand-in. None imitates the keyed prefix. Not part of this interface: service-settings' `CryptoAdapter` and its doubles. That is a different interface, and it is untouched. ## Semver level (landing precondition ②) The level is **`minor`** for `@objectstack/spec` and `@objectstack/service-settings`. The changeset also carries a **BREAKING** banner for implementers and an ADR-0087 disposition. - A required member breaks every implementation that lacks it at compile time. The header of `scripts/check-changeset-no-major.mjs` lists "a required member on a published interface" among the changes that grade `major` once GA ends the launch window. - The repository is pre-GA: there is no `.changeset/pre.json`, and the lockstep group is at 17.5.0. ADR-0087's amendment of 2026-09-13 (the level half) says a pre-GA break ships `minor`, with the BREAKING banner and its disposition as the carriers. - Disposition: `not-required (no-migration-prescription)`. The interface has no metadata surface, so `objectstack migrate meta` has nothing to rewrite. `runtime-interface-only` and `type-surface-only` are both closed to a symbol declared under `packages/spec/src/contracts/` (ADR-0087 D7 step 2; D8 predicates 2 and 3). - `service-settings` widens its public class with one method, which also grades `minor`. - Gate readings at `574553aad4`: - `check-changeset-no-major`: exit 0. Its level axis, driven offline by an event whose body carries `Clause-②: yes`, is green. - `check-adr-0087-registration`: exit 0, with 1 declared-breaking changeset carrying its disposition. - **The arm.** The dev copied the claim's bare `yes` onto line 2. Measured: the member narrows the set of objects that satisfy the interface, so under the closed arm pair the exact spelling would be `Clause-②: yes (narrowing)`. No gate verdict differs between the two spellings here. The BREAKING banner already declares the break to the ADR-0087 gate, and both spellings require at least `minor`. The seat has since set the arm to `Clause-②: yes (narrowing)` here and in its claim revision, per the at-tier record `5944464743` ②. The changeset line stays bare, because no push follows and the banner already carries the break. ## Out-of-repo providers (landing precondition ①) **Met** (seat note). The dev could not read the cloud repository. The director seat's census `5945420994` on #21263 read cloud, objectui and hotcrm, and found zero out-of-repo `ICryptoProvider` implementations. Every cloud host constructs the framework's `LocalCryptoProvider` through a `link:` dependency, so no cloud card is owed. The maintainer's ruling `5945612493` (A) lands this PR alone. ## A recorded decision this PR meets: ADR-0128 §4 ADR-0128 (Accepted) defers a producer-discriminated AAD on `CryptoContext` (D1). Its §4 lists the triggers that fund it, and one trigger reads: "`ICryptoProvider` is opened for another breaking change. ... A queued breaking change to this interface should pull D1 in with it." This PR is such a breaking change. D1 is **not** bundled here. Per that same §4, D1 needs a versioned handle and an at-rest rewrap migration, which is outside this card's surface and outside the dispatch's release constraint. Whether to land this alone and schedule D1, or to hold this PR for D1, is raised to the seat as a landing question. It is not decided here. ## Tests Each cell names the commit it was read at. After `a71560b345`, only the changeset and `local-crypto-provider.ts` moved, so readings of other packages at that commit still describe the final head. Each suite ran with `--filter` naming that package alone. Each package's dependency closure was built first, with the suffix (upstream) form `'NAME^...'`. No downstream (prefix-form) consumer sweep was run: the published face that moved is one interface member, and the census above enumerates every in-repo implementer and double. Each package carrying one is in this table. | Package | `test` | `typecheck` | |---|---|---| | `@objectstack/spec` | `574553aad4`: 597 files, 17474 passed, 1 todo | `a71560b345`: exit 0 (test layer at its ledger: 52 files, 246 errors, 135 pinned signatures) | | `@objectstack/service-settings` | `574553aad4`: 33 files, 591 passed | `574553aad4` tree: exit 0 | | `@objectstack/objectql` | `aedbc1bf9b`: 360 files, 7082 passed | `a71560b345`: exit 0 (test layer at its ledger) | | `@objectstack/plugin-auth` | `a71560b345`: 116 files, 2484 passed | `a71560b345`: exit 0 (test layer at its ledger) | | `@objectstack/plugin-webhooks` | `a71560b345`: 13 files, 160 passed | `a71560b345`: exit 0 | | `@objectstack/service-datasource` | `a71560b345`: 35 files, 707 passed | `a71560b345`: exit 0 | | `@objectstack/plugin-security`, `@objectstack/plugin-audit` | not run (no file changed) | `a71560b345`: exit 0, confirming their `any`/`never` doubles need nothing | **New pins** (`local-crypto-provider.test.ts`, 7 cases): - the output differs from the unkeyed SHA-256 of the same input, and from `digest`; - it is equal for equal input under one key, within an instance, across instances, and across the hex and base64 spellings of one key; - it differs under a different key; - the MAC key is derived, not the data key itself; - a pinned vector computed outside the implementation (Python `hmac`); - every key source yields the same digest for the same key bytes; - an instance without usable key material rejects with the typed refusal. The positive control takes the same construction route. **Ablations** (one-shot, through `scripts/ablation-replace.mjs`: the anchor hit once, the blob changed, and the restore was proven blob-equal to HEAD with an empty `git diff HEAD`; nothing kept in tree): | Mutation | Pins that went red | |---|---| | keyed output replaced by an unkeyed SHA-256 | 3 red, 21 green: keyed-vs-unkeyed, different-key, pinned vector | | refusal replaced by an HMAC under the raw key | 1 red: the no-key-material refusal | | MAC key replaced by the data key itself | 2 red: derived-key, pinned vector | **Reverse verification** (proves the typecheck read the rebuilt declarations): deleting the member from the service-datasource double turned `typecheck` red with `TS2741: Property 'keyedDigest' is missing ... but required in type 'ICryptoProvider'`. The file was restored blob-equal to HEAD. **Gates.** The derived union was re-derived on the final head with `scripts/pm/dispatch-gates.mjs` (no paths) and run at `574553aad4`: 90 of 90 exit 0, with each exit code captured before any pipe. `--ran` reconciliation reads "90 run, 0 NOT-MEASURED (a DERIVED zero)". `pnpm --filter @objectstack/spec check:generated` also ran: all 15 generated artifacts are up to date. - One gate went red on the first run and was fixed in code: `check-tenant-audit-census` could not type the second link of a chained `update` call in the MAC-key derivation. The derivation now uses one `update` over a prepared buffer. The bytes are the same, and the pinned vector still holds. - Three gates refused on the first run for prerequisites (`check-engine-split-ratio` on the shallow clone; `check:dual-build-cjs-loads` and `check:i18n` on missing builds). All three measured exit 0 in the final run after history was deepened and the builds existed. ## Acceptance notes - `KeyedDigestKeyUnavailableError` is exported from `local-crypto-provider.ts` but not from the package index. Nothing outside the package distinguishes it today. A caller that serves the digest should treat any rejection as "serve nothing". - An explicit `opts.key` is still not length-checked at construction (this predates this PR). A wrong-length key constructs and fails only when used: `encrypt` throws Node's invalid-key-length error, and `keyedDigest` now rejects with the typed refusal. It is reachable only by embedders and tests, because every environment and file source is checked. Noted, not filed. Carrier: none. - `createHmac` availability on WebContainer is not measured. - `origin/main` advanced 5 commits past this branch's base. None of them touches a file in this diff or any generated artifact, so the branch was not merged; CI's merge ref covers the rest. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bada8d3 commit 222ecc2

9 files changed

Lines changed: 270 additions & 3 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-settings': minor
4+
---
5+
6+
feat(spec): `ICryptoProvider` gains a required `keyedDigest(plain): Promise<string>` member, and `LocalCryptoProvider` implements it (#21263)
7+
8+
Clause-②: yes
9+
10+
**BREAKING** for `ICryptoProvider` implementers: the new member is required, so a
11+
provider that does not declare it stops compiling (`TS2420` on a class, `TS2741`
12+
on an object literal), and the compiler names the missing member. Code that only
13+
calls a provider is unaffected.
14+
15+
`keyedDigest` is a digest of `plain` under the provider's server-held key, for a
16+
value that is handed to a caller but must not let that caller check a guess about
17+
the input offline. The contract requires three things of every implementation:
18+
19+
- **Keyed.** The output cannot be computed without the provider's key. A provider
20+
that holds no key material rejects; it never returns an unkeyed value.
21+
- **Stable per key.** Under one key, equal input gives equal output in every
22+
process and on every node that holds the key. Replacing the key changes every
23+
output.
24+
- **Not a substitute for `digest`.** `digest` keeps its contract and the stability
25+
the audit trail relies on.
26+
27+
The output is `hmac-sha256:` followed by the 64 lowercase hex characters of an
28+
HMAC-SHA-256: 76 characters from `[0-9a-z:-]`, which travel unchanged in an HTTP
29+
header, a query string and JSON, and never collide with the `sha256:` spelling of
30+
an unkeyed content hash.
31+
32+
`LocalCryptoProvider` computes it from the 32-byte data key it already resolves
33+
(`OS_SECRET_KEY`, `OS_DEV_CRYPTO_KEY`, the persisted key file, or the ephemeral
34+
test-mode key), through a MAC key derived from that data key, so the AES-GCM key
35+
is never used as a MAC key. There is no new secret or environment variable to
36+
configure. An instance constructed with an explicit key that is not 32 bytes holds
37+
no usable key material, and its `keyedDigest` rejects with
38+
`KeyedDigestKeyUnavailableError`.
39+
40+
<!-- adr-0087: not-required (no-migration-prescription) `ICryptoProvider` is a TypeScript contract with no metadata surface: no Zod schema, no authorable key, no export renamed or removed and no stored row changes shape, so `objectstack migrate meta` has nothing to rewrite. The affected party is a provider implementer, and the compiler names the missing member at their declaration. -->

‎packages/objectql/src/secret-fields.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function makeFakeCrypto() {
136136
return { ...handle, version: handle.version + 1 };
137137
},
138138
digest(plain: string): string { return `d:${plain.length}`; },
139+
async keyedDigest(plain: string): Promise<string> { return `k:${plain.length}`; },
139140
};
140141
return { provider, calls };
141142
}

‎packages/plugins/plugin-auth/src/sso-client-secret-at-rest.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ function makeFakeCrypto(): ICryptoProvider {
100100
return { ...handle, version: handle.version + 1 };
101101
},
102102
digest(plain: string): string { return `d:${plain.length}`; },
103+
async keyedDigest(plain: string): Promise<string> { return `k:${plain.length}`; },
103104
};
104105
}
105106

‎packages/plugins/plugin-webhooks/src/webhook-headers-gate.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ function makeFakeCrypto(): ICryptoProvider {
143143
return { ...handle, version: handle.version + 1 };
144144
},
145145
digest(plain: string): string { return `d:${plain.length}`; },
146+
async keyedDigest(plain: string): Promise<string> { return `k:${plain.length}`; },
146147
};
147148
}
148149

‎packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ function makeFakeCrypto() {
170170
return { ...handle, version: handle.version + 1 };
171171
},
172172
digest(plain: string): string { return `d:${plain.length}`; },
173+
async keyedDigest(plain: string): Promise<string> { return `k:${plain.length}`; },
173174
};
174175
return provider;
175176
}

‎packages/services/service-datasource/src/__tests__/datasource-secret-binder.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ function fakeCrypto(): ICryptoProvider {
3535
return handle;
3636
},
3737
digest: (plain: string) => 'sha256:' + plain,
38+
keyedDigest: async (plain: string) => `k:${plain.length}`,
3839
};
3940
}
4041

‎packages/services/service-settings/src/local-crypto-provider.test.ts‎

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4-
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
4+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
55
import { tmpdir } from 'node:os';
66
import { join } from 'node:path';
7-
import { randomBytes } from 'node:crypto';
7+
import { createHash, createHmac, randomBytes } from 'node:crypto';
88
import {
99
LocalCryptoProvider,
1010
InMemoryCryptoProvider,
11+
KeyedDigestKeyUnavailableError,
1112
} from './local-crypto-provider.js';
1213

1314
const ctx = { namespace: 'mail', key: 'api_key' };
@@ -154,6 +155,117 @@ describe('LocalCryptoProvider — crypto semantics', () => {
154155
});
155156
});
156157

158+
describe('LocalCryptoProvider — keyedDigest', () => {
159+
const KEYED_SHAPE = /^hmac-sha256:[0-9a-f]{64}$/;
160+
const input = 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
161+
const sha256Hex = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex');
162+
163+
let home: string;
164+
beforeEach(() => {
165+
home = mkdtempSync(join(tmpdir(), 'os-crypto-keyed-'));
166+
});
167+
afterEach(() => {
168+
rmSync(home, { recursive: true, force: true });
169+
});
170+
171+
it('is keyed: the output differs from the unkeyed SHA-256 of the same input', async () => {
172+
const p = new LocalCryptoProvider({ key: randomBytes(32) });
173+
const out = await p.keyedDigest(input);
174+
expect(out).toMatch(KEYED_SHAPE);
175+
expect(out).toHaveLength(76);
176+
const hexBody = out.slice('hmac-sha256:'.length);
177+
expect(hexBody).not.toBe(sha256Hex(input));
178+
expect(out).not.toBe(p.digest(input));
179+
expect(out).not.toContain(input);
180+
});
181+
182+
it('is equal for equal input under one key — within an instance and across instances', async () => {
183+
const hex = randomBytes(32).toString('hex');
184+
const env = { NODE_ENV: 'production', OS_SECRET_KEY: hex, OS_HOME: home };
185+
const a = new LocalCryptoProvider({ env });
186+
// A second instance resolving the same key stands in for another process or node.
187+
const b = new LocalCryptoProvider({ env });
188+
expect(await a.keyedDigest(input)).toBe(await a.keyedDigest(input));
189+
expect(await b.keyedDigest(input)).toBe(await a.keyedDigest(input));
190+
// The same key spelled base64 is the same key.
191+
const c = new LocalCryptoProvider({
192+
env: { NODE_ENV: 'production', OS_SECRET_KEY: Buffer.from(hex, 'hex').toString('base64'), OS_HOME: home },
193+
});
194+
expect(await c.keyedDigest(input)).toBe(await a.keyedDigest(input));
195+
// Equal under one key is not constant under one key.
196+
expect(await a.keyedDigest(input + ' ')).not.toBe(await a.keyedDigest(input));
197+
});
198+
199+
it('differs under a different key', async () => {
200+
const a = new LocalCryptoProvider({ key: randomBytes(32) });
201+
const b = new LocalCryptoProvider({ key: randomBytes(32) });
202+
expect(await a.keyedDigest(input)).not.toBe(await b.keyedDigest(input));
203+
});
204+
205+
it('keys its MAC with a key derived from the data key, never with the data key itself', async () => {
206+
const key = randomBytes(32);
207+
const out = await new LocalCryptoProvider({ key }).keyedDigest(input);
208+
const underDataKey = createHmac('sha256', key).update(input, 'utf8').digest('hex');
209+
expect(out).toMatch(KEYED_SHAPE);
210+
expect(out.slice('hmac-sha256:'.length)).not.toBe(underDataKey);
211+
});
212+
213+
it('answers a pinned vector, so builds on two nodes of one rolling deploy agree', async () => {
214+
// Vector computed outside this implementation (Python `hmac`): the MAC key is
215+
// HMAC-SHA-256(dataKey, label || 0x01), the output HMAC-SHA-256(macKey, input).
216+
const p = new LocalCryptoProvider({ key: Buffer.from(Array.from({ length: 32 }, (_, i) => i)) });
217+
expect(await p.keyedDigest('hello')).toBe(
218+
'hmac-sha256:de27f4140f1e5db4cdabc24d91dbf5eef67eeaaacd5fefa0aa491f283bdcded6',
219+
);
220+
expect(await p.keyedDigest(input)).toBe(
221+
'hmac-sha256:ac3e935526f452d6e230db8667e46422fdfe53800f70c13a70006576fae8a21b',
222+
);
223+
});
224+
225+
it('is computed from whichever source resolved the data key', async () => {
226+
const key = randomBytes(32);
227+
const expected = await new LocalCryptoProvider({ key }).keyedDigest(input);
228+
229+
const fromSecret = new LocalCryptoProvider({
230+
env: { NODE_ENV: 'production', OS_SECRET_KEY: key.toString('hex'), OS_HOME: home },
231+
});
232+
expect(fromSecret.keySource).toBe('env:OS_SECRET_KEY');
233+
expect(await fromSecret.keyedDigest(input)).toBe(expected);
234+
235+
const fromDevKey = new LocalCryptoProvider({
236+
env: { NODE_ENV: 'development', OS_DEV_CRYPTO_KEY: key.toString('hex'), HOME: home },
237+
});
238+
expect(fromDevKey.keySource).toBe('env:OS_DEV_CRYPTO_KEY');
239+
expect(await fromDevKey.keyedDigest(input)).toBe(expected);
240+
241+
mkdirSync(join(home, '.objectstack'), { recursive: true });
242+
writeFileSync(join(home, '.objectstack', 'dev-crypto-key'), key.toString('base64'), { mode: 0o600 });
243+
const fromFile = new LocalCryptoProvider({ env: { NODE_ENV: 'production', HOME: home } });
244+
expect(fromFile.keySource).toBe('file');
245+
expect(await fromFile.keyedDigest(input)).toBe(expected);
246+
247+
const ephemeral = new LocalCryptoProvider({ env: { NODE_ENV: 'test', HOME: home } });
248+
expect(ephemeral.keySource).toBe('ephemeral');
249+
expect(await ephemeral.keyedDigest(input)).toMatch(KEYED_SHAPE);
250+
});
251+
252+
it('a provider without key material refuses — it never falls back to an unkeyed digest', async () => {
253+
// The explicit-key route is the one source that is not length-checked, so it
254+
// is where an instance without usable key material can exist at all. The
255+
// positive control takes the same route with a real key.
256+
const control = new LocalCryptoProvider({ key: randomBytes(32) });
257+
expect(await control.keyedDigest(input)).toMatch(KEYED_SHAPE);
258+
259+
for (const key of [Buffer.alloc(0), randomBytes(16)]) {
260+
const p = new LocalCryptoProvider({ key });
261+
expect(p.keySource).toBe('explicit');
262+
const refusal = p.keyedDigest(input);
263+
await expect(refusal).rejects.toBeInstanceOf(KeyedDigestKeyUnavailableError);
264+
await expect(refusal).rejects.toMatchObject({ keyLength: key.length });
265+
}
266+
});
267+
});
268+
157269
describe('InMemoryCryptoProvider backward-compat alias', () => {
158270
it('is the same class as LocalCryptoProvider', () => {
159271
expect(InMemoryCryptoProvider).toBe(LocalCryptoProvider);

‎packages/services/service-settings/src/local-crypto-provider.ts‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
CryptoHandle,
66
ICryptoProvider,
77
} from '@objectstack/spec/contracts';
8-
import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
8+
import { createHash, createHmac, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
99
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1010
import { homedir } from 'node:os';
1111
import { dirname, join } from 'node:path';
@@ -74,6 +74,32 @@ import { dirname, join } from 'node:path';
7474
* ciphertext rewrapped from a different (ns, key) tuple fails decryption —
7575
* guards against operators accidentally copying rows between namespaces.
7676
*
77+
* ## Keyed digest
78+
* `keyedDigest(plain)` is `hmac-sha256:` + hex(HMAC-SHA-256(macKey, plain)),
79+
* where `macKey` is DERIVED from the same 32-byte data key the AES path uses —
80+
* whichever source resolved it above — so it needs no secret of its own:
81+
*
82+
* macKey = HMAC-SHA-256(dataKey, KEYED_DIGEST_KDF_INFO || 0x01)
83+
*
84+
* That is RFC 5869 HKDF-Expand for one 32-byte block, with the data key as the
85+
* pseudorandom key (§3.3 lets a uniformly random key skip the Extract step).
86+
* The data key is never used as the MAC key directly: one key, one purpose. The
87+
* AES-GCM key stays a pure encryption key, the MAC key is a separate value
88+
* nobody can turn back into it, and the versioned label makes a future change
89+
* of construction a deliberate, visible one rather than a silent drift. The
90+
* derivation is deterministic, so every process and node that resolves the
91+
* same data key computes the same digest.
92+
*
93+
* Only `createHmac` is used (no `hkdfSync`), so the WebContainer runtime that
94+
* cannot run AES-GCM through `node:crypto` is not handed a second primitive it
95+
* may lack.
96+
*
97+
* A provider whose data key is not 32 bytes — reachable only through an
98+
* explicit `opts.key`, because every env and file source is length-checked —
99+
* holds no usable key material. `keyedDigest` rejects with
100+
* {@link KeyedDigestKeyUnavailableError} there; ⛔ it never falls back to an
101+
* unkeyed hash, nor to an HMAC under an empty key, which anyone can compute.
102+
*
77103
* ## WebContainer (StackBlitz) note
78104
* `node:crypto.createCipheriv('aes-256-gcm', …)` is not implemented in
79105
* WebContainer. When we detect that runtime, we swap to a pure-JS AES-GCM
@@ -94,6 +120,40 @@ const DEV_KEY_LEGACY_ENV = 'OBJECTSTACK_DEV_CRYPTO_KEY';
94120
*/
95121
const AUTOKEY_ENV = 'OS_CRYPTO_AUTOKEY';
96122

123+
/** The data key's only legal length: AES-256 needs exactly 32 bytes. */
124+
const DATA_KEY_BYTES = 32;
125+
126+
/**
127+
* HKDF-Expand `info` label for the keyed-digest MAC key. Versioned: changing
128+
* it changes every keyed digest this provider has ever handed out, so a new
129+
* construction takes a new label, never an edit of this one.
130+
*/
131+
const KEYED_DIGEST_KDF_INFO = 'objectstack/crypto-provider/keyed-digest/v1';
132+
133+
/** HKDF-Expand's first-block input: `info || 0x01` (RFC 5869 §2.3, T(1)). */
134+
const KEYED_DIGEST_KDF_INPUT = Buffer.concat([Buffer.from(KEYED_DIGEST_KDF_INFO, 'utf8'), Buffer.from([0x01])]);
135+
136+
/** Output prefix the `ICryptoProvider.keyedDigest` contract fixes. */
137+
const KEYED_DIGEST_PREFIX = 'hmac-sha256:';
138+
139+
/**
140+
* Rejection of {@link LocalCryptoProvider.keyedDigest} when the provider holds
141+
* no usable key material. The refusal is the guarantee: a keyed digest
142+
* computed without a key would be an unkeyed digest wearing a keyed name.
143+
*/
144+
export class KeyedDigestKeyUnavailableError extends Error {
145+
constructor(readonly keyLength: number) {
146+
super(
147+
`[LocalCryptoProvider] Refusing to compute a keyed digest: the provider holds no usable key ` +
148+
`material (a ${keyLength}-byte data key; exactly ${DATA_KEY_BYTES} bytes are required). ` +
149+
`A digest computed without a key can be recomputed by anyone holding the input. ` +
150+
`Fix: construct the provider with a ${DATA_KEY_BYTES}-byte key, or let it resolve ` +
151+
`${SECRET_KEY_ENV} from the environment.`,
152+
);
153+
this.name = 'KeyedDigestKeyUnavailableError';
154+
}
155+
}
156+
97157
type EnvMap = Record<string, string | undefined>;
98158

99159
/** Where the provider resolved its data key from (for diagnostics). */
@@ -423,6 +483,12 @@ const loadNobleGcm = (): Promise<GcmFactory | undefined> => {
423483

424484
export class LocalCryptoProvider implements ICryptoProvider {
425485
private readonly key: Buffer;
486+
/**
487+
* The keyed-digest MAC key, derived from {@link key} (see "Keyed digest"
488+
* above). `undefined` exactly when the data key is not usable key material,
489+
* which is what makes `keyedDigest` refuse.
490+
*/
491+
private readonly macKey: Buffer | undefined;
426492
private readonly useNoble: boolean;
427493
/** Where the active data key came from. Exposed for diagnostics/tests. */
428494
readonly keySource: KeySource;
@@ -431,6 +497,10 @@ export class LocalCryptoProvider implements ICryptoProvider {
431497
const resolved = resolveDataKey(opts);
432498
this.key = resolved.key;
433499
this.keySource = resolved.source;
500+
this.macKey =
501+
resolved.key.length === DATA_KEY_BYTES
502+
? createHmac('sha256', resolved.key).update(KEYED_DIGEST_KDF_INPUT).digest()
503+
: undefined;
434504
this.useNoble = isWebContainerRuntime();
435505
}
436506

@@ -501,6 +571,11 @@ export class LocalCryptoProvider implements ICryptoProvider {
501571
return 'sha256:' + createHash('sha256').update(plain, 'utf8').digest('hex');
502572
}
503573

574+
async keyedDigest(plain: string): Promise<string> {
575+
if (!this.macKey) throw new KeyedDigestKeyUnavailableError(this.key.length);
576+
return KEYED_DIGEST_PREFIX + createHmac('sha256', this.macKey).update(plain, 'utf8').digest('hex');
577+
}
578+
504579
private encryptNode(plainBytes: Buffer, iv: Buffer, aad: Buffer): string {
505580
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
506581
cipher.setAAD(aad);

‎packages/spec/src/contracts/crypto-provider.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,41 @@ export interface ICryptoProvider {
157157
* reveal the plaintext (use HMAC or SHA-256 of canonical JSON).
158158
* Same hash for same input enables operators to detect duplicate
159159
* writes without exposing secrets.
160+
*
161+
* Not keyed by contract: plain SHA-256 satisfies it, so anyone holding a
162+
* candidate input can recompute it. A value that must not be computable
163+
* without the provider's key comes from {@link ICryptoProvider.keyedDigest}.
160164
*/
161165
digest(plain: string): string;
166+
167+
/**
168+
* Keyed digest of `plain` under the provider's server-held key — the
169+
* primitive for a value that is handed to a caller yet must not let that
170+
* caller confirm a guess about the input offline.
171+
*
172+
* Each of the following is a requirement on every implementation:
173+
*
174+
* 1. **Keyed.** The output MUST NOT be computable from `plain` without
175+
* the provider's key. An implementation that holds no key material
176+
* MUST reject — ⛔ never resolve to an unkeyed value (a plain hash of
177+
* `plain`, or a MAC under an empty or publicly known key).
178+
* 2. **Stable per key.** Under one key, equal `plain` yields an equal
179+
* output in every process and on every node holding that key, so a
180+
* value one node hands out compares equal when a caller echoes it to
181+
* another. Replacing the key changes every output.
182+
* 3. **Not a substitute for {@link ICryptoProvider.digest}.** `digest`
183+
* keeps its own contract and the stability the audit trail relies on;
184+
* nothing that records or compares audit digests moves to this method,
185+
* and this method is not an audit fingerprint.
186+
*
187+
* Output: `hmac-sha256:` followed by the 64 lowercase hex characters of an
188+
* HMAC-SHA-256 — 76 characters drawn from `[0-9a-z:-]`. That one token
189+
* travels unchanged in an HTTP header value, a query-string value and a
190+
* JSON string, and its prefix keeps it disjoint from the `sha256:`
191+
* spelling of an unkeyed content hash.
192+
*
193+
* Asynchronous because a managed-custody provider computes the MAC inside
194+
* its KMS, where the key never leaves.
195+
*/
196+
keyedDigest(plain: string): Promise<string>;
162197
}

0 commit comments

Comments
 (0)