Skip to content

Commit 82e0610

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21018-skill-generator-barrels
2 parents 84fc471 + 222ecc2 commit 82e0610

13 files changed

Lines changed: 950 additions & 72 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)