Skip to content

Commit 4182cad

Browse files
committed
wip(spec,service-settings): ADR-0128 D1-D3 scope discriminant, delimiter-safe versioned AAD
Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a6d92f commit 4182cad

5 files changed

Lines changed: 332 additions & 81 deletions

File tree

‎packages/objectql/src/engine.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8679,7 +8679,10 @@ export class ObjectQL implements IObjectQLEngine {
86798679
}
86808680

86818681
const plain = typeof value === 'string' ? value : JSON.stringify(value);
8682+
// ADR-0128 D1: this producer's own scope, so the AAD names the
8683+
// object-secret-field vocabulary and no other producer's context opens it.
86828684
const handle: CryptoHandle = await this.cryptoProvider.encrypt(plain, {
8685+
scope: 'object_secret_field',
86838686
namespace: object,
86848687
key: field,
86858688
tenantId: context?.tenantId,
@@ -8992,7 +8995,11 @@ export class ObjectQL implements IObjectQLEngine {
89928995
version: secret.version,
89938996
ciphertext: secret.ciphertext,
89948997
};
8998+
// ADR-0128 D1: a `secret:` ref is the object-secret-field producer's
8999+
// holder, so the scope is that producer's. A row another producer sealed
9000+
// under a scoped derivation therefore does not open here.
89959001
return this.cryptoProvider.decrypt(handle, {
9002+
scope: 'object_secret_field',
89969003
namespace: secret.namespace,
89979004
key: secret.key,
89989005
tenantId: opts?.tenantId,

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@ export interface DatasourceSecretBinderDeps {
5252
* Namespace recorded on the secret row and used as the `CryptoContext`
5353
* namespace (default `'datasource'`). This is the datasource producer's
5454
* own vocabulary, **not** a settings namespace — the three producers of
55-
* `sys_secret` rows share one flat `(namespace, key)` space and the pair
56-
* does not attribute a row to a producer. See `CryptoContext` in
57-
* `@objectstack/spec`.
55+
* `sys_secret` rows draw `(namespace, key)` from uncoordinated
56+
* vocabularies and the pair does not attribute a row to a producer. The
57+
* binder's `CryptoContext.scope` (`'datasource_credential'`, on bind and
58+
* resolve alike) is what does. See `CryptoContext` in `@objectstack/spec`.
5859
*/
5960
namespace?: string;
6061
}
@@ -96,7 +97,11 @@ export function createDatasourceSecretBinder(deps: DatasourceSecretBinderDeps):
9697
async bind(input, hint) {
9798
const namespace = input.namespace ?? defaultNamespace;
9899
const key = input.key ?? hint.name;
99-
const handle: CryptoHandle = await cryptoProvider.encrypt(input.value, { namespace, key });
100+
const handle: CryptoHandle = await cryptoProvider.encrypt(input.value, {
101+
scope: 'datasource_credential',
102+
namespace,
103+
key,
104+
});
100105
await engine.insert('sys_secret', {
101106
id: handle.id,
102107
namespace,
@@ -129,8 +134,9 @@ export function createDatasourceSecretBinder(deps: DatasourceSecretBinderDeps):
129134
const rows = (Array.isArray(result) ? result : (result as { data?: unknown[] })?.data) ?? [];
130135
const row = rows[0] as SecretRow | undefined;
131136
if (!row?.ciphertext) return undefined;
132-
// Reconstruct the handle and decrypt under the same (namespace,key)
133-
// AAD the row was sealed with — a mismatch fails authentication.
137+
// Reconstruct the handle and decrypt under the same
138+
// (scope, namespace, key) AAD the row was sealed with — a mismatch,
139+
// including a row another producer sealed, fails authentication.
134140
return await cryptoProvider.decrypt(
135141
{
136142
id: row.id,
@@ -139,7 +145,7 @@ export function createDatasourceSecretBinder(deps: DatasourceSecretBinderDeps):
139145
version: row.version,
140146
ciphertext: row.ciphertext,
141147
},
142-
{ namespace: row.namespace, key: row.key },
148+
{ scope: 'datasource_credential', namespace: row.namespace, key: row.key },
143149
);
144150
} catch {
145151
// Missing row / unreadable engine / decrypt failure (e.g. rotated dev

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

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

3-
import type {
4-
CryptoContext,
5-
CryptoHandle,
6-
ICryptoProvider,
3+
import {
4+
CRYPTO_CONTEXT_SCOPES,
5+
type CryptoContext,
6+
type CryptoContextScope,
7+
type CryptoHandle,
8+
type ICryptoProvider,
79
} from '@objectstack/spec/contracts';
810
import { createHash, createHmac, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
911
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -66,13 +68,57 @@ import { dirname, join } from 'node:path';
6668
* id — `sec_` + 32 hex chars (122 bits of entropy)
6769
* kmsKeyId — `local:v<version>`
6870
* alg — `aes-256-gcm`
69-
* version — bumps on rotateKey()
70-
* ciphertext— base64(iv (12) || authTag (16) || cipher)
71+
* version — bumps on rotateKey() (a rotation counter, not the AAD
72+
* derivation — that is the ciphertext's marker below)
73+
* ciphertext— `v2:` + base64(iv (12) || authTag (16) || cipher) for every
74+
* new seal; a handle sealed before derivations were versioned
75+
* is bare base64(iv || authTag || cipher) — version 1.
7176
*
72-
* ## AAD binding
73-
* The CryptoContext (namespace + key) is folded into AES-GCM AAD so a
74-
* ciphertext rewrapped from a different (ns, key) tuple fails decryption —
75-
* guards against operators accidentally copying rows between namespaces.
77+
* ## AAD binding (ADR-0128 D1–D3)
78+
* The ciphertext's marker records which AAD derivation sealed it, and
79+
* `decrypt` dispatches on that record — it never tries a second derivation
80+
* after the first fails, and it never guesses a producer (D3).
81+
*
82+
* **Version 2 — every new seal.** The AAD is
83+
*
84+
* 0xFF || "objectstack/crypto-context-aad/v2" || lp(scope) || lp(namespace) || lp(key)
85+
*
86+
* where `lp(x)` is the 4-byte big-endian length of UTF-8(x) followed by those
87+
* bytes. Three properties, each load-bearing:
88+
*
89+
* - **Producer-discriminated (D1).** `scope` names the producer vocabulary
90+
* (`CRYPTO_CONTEXT_SCOPES`), so a ciphertext sealed by one producer does
91+
* not authenticate under another producer's context, however the two
92+
* `(namespace, key)` pairs are spelled. A scope outside the closed set is
93+
* refused with {@link CryptoContextScopeError} before any key is used.
94+
* - **Delimiter-safe (D2).** Every component is length-prefixed, so distinct
95+
* triples always produce distinct bytes — no separator character exists
96+
* for a component to smuggle. `aadForVersion2` is exported only so that
97+
* property is pinned by a byte vector and by a collision vector.
98+
* - **Disjoint from version 1.** The lead byte 0xFF never occurs in UTF-8,
99+
* and a version-1 AAD is the UTF-8 of a string, so no version-1 AAD equals
100+
* any version-2 AAD: a ciphertext presented under the other version's
101+
* marker never authenticates. The label names the derivation, so a future
102+
* version 3 takes a new label and is disjoint from this one too.
103+
*
104+
* **Version 1 — read only.** AAD = UTF-8(namespace + `|` + key), the
105+
* derivation every pre-versioning ciphertext carries. It opens such a handle
106+
* so existing ciphertext stays readable until it is re-wrapped
107+
* (`rotateKey` opens with the recorded derivation and seals with version 2);
108+
* ⛔ it never seals. It carries the older, weaker guarantee described on
109+
* `CryptoContext` until then.
110+
*
111+
* **Why every pre-versioning handle reads as version 1 without reading a
112+
* row.** Both seal paths (node:crypto and the WebContainer one) emitted
113+
* `Buffer#toString('base64')`, whose alphabet is `A–Z a–z 0–9 + / =`, so no
114+
* such ciphertext contains `:`. A ciphertext without `:` is version 1; `v2:`
115+
* is version 2; any other marker is refused with
116+
* {@link UnknownCiphertextVersionError} (fail closed).
117+
*
118+
* Tenant binding is intentionally omitted from both derivations: the handle
119+
* is dereferenced from a row its producer has already scoped to its tenant,
120+
* and adding the tenant here would force every decrypt path to re-read that
121+
* scope.
76122
*
77123
* ## Keyed digest
78124
* `keyedDigest(plain)` is `hmac-sha256:` + hex(HMAC-SHA-256(macKey, plain)),
@@ -154,6 +200,129 @@ export class KeyedDigestKeyUnavailableError extends Error {
154200
}
155201
}
156202

203+
/** The AAD derivations this provider knows (see "AAD binding" above). */
204+
type AadDerivation = 1 | 2;
205+
206+
/** Separates a ciphertext's derivation marker from its base64 body. */
207+
const CIPHERTEXT_MARKER_SEPARATOR = ':';
208+
209+
/** Marker of a version-2 seal — the only derivation this provider seals with. */
210+
const CIPHERTEXT_V2_MARKER = 'v2';
211+
212+
/**
213+
* Lead byte of every version-2 AAD. 0xFF never occurs in UTF-8, which is what
214+
* keeps version 2 disjoint from every version-1 AAD (the UTF-8 of a string).
215+
*/
216+
const AAD_V2_LEAD = Buffer.from([0xff]);
217+
218+
/**
219+
* Names the version-2 derivation inside the AAD itself. Versioned: a new
220+
* derivation takes a new label (and a new marker), never an edit of this one —
221+
* editing it would orphan every version-2 ciphertext ever sealed.
222+
*/
223+
const AAD_V2_LABEL = Buffer.from('objectstack/crypto-context-aad/v2', 'utf8');
224+
225+
/**
226+
* Refusal of a {@link CryptoContext} whose `scope` is not a member of the
227+
* closed producer set. The type already requires it; this is the same rule for
228+
* a caller the compiler never saw (plain JavaScript, a cast, a stale build). A
229+
* missing scope is never defaulted: defaulting would put the caller's
230+
* ciphertext into another producer's AAD space.
231+
*/
232+
export class CryptoContextScopeError extends Error {
233+
constructor(readonly received: unknown) {
234+
super(
235+
`[LocalCryptoProvider] Refusing to use a CryptoContext without a valid scope ` +
236+
`(received ${describeScope(received)}). The scope names which producer's vocabulary ` +
237+
`(namespace, key) is drawn from, and it is bound into the ciphertext, so it is never ` +
238+
`defaulted. Fix: pass the calling producer's own member of CRYPTO_CONTEXT_SCOPES ` +
239+
`(${CRYPTO_CONTEXT_SCOPES.join(', ')}) on encrypt, decrypt and rotateKey alike.`,
240+
);
241+
this.name = 'CryptoContextScopeError';
242+
}
243+
}
244+
245+
/**
246+
* Refusal to open a ciphertext whose marker records an AAD derivation this
247+
* provider does not know. Fail closed: the recorded derivation is the only one
248+
* ever tried, so an unknown one is a refusal, never a cue to guess.
249+
*/
250+
export class UnknownCiphertextVersionError extends Error {
251+
constructor(readonly marker: string) {
252+
super(
253+
`[LocalCryptoProvider] Refusing to decrypt: the ciphertext records AAD derivation ` +
254+
`${describeMarker(marker)}, which this provider does not know. A ciphertext is opened only ` +
255+
`with the derivation it records, never by trying another. Fix: open it with the release ` +
256+
`that sealed it, or set the value again so it is sealed under a derivation this release knows.`,
257+
);
258+
this.name = 'UnknownCiphertextVersionError';
259+
}
260+
}
261+
262+
/** A refusal message names a received scope only when it is short and printable. */
263+
function describeScope(received: unknown): string {
264+
if (received === undefined) return 'no scope';
265+
if (typeof received === 'string' && /^[a-z0-9_]{1,40}$/.test(received)) return `'${received}'`;
266+
return `a ${typeof received} that is not a member`;
267+
}
268+
269+
/** A refusal message echoes a marker only when it is short and printable. */
270+
function describeMarker(marker: string): string {
271+
return /^[A-Za-z0-9_-]{1,16}$/.test(marker) ? `'${marker}'` : 'an unrecognised marker';
272+
}
273+
274+
/** The scope, proven a member of the closed set — or a refusal. */
275+
function requireScope(ctx: CryptoContext): CryptoContextScope {
276+
const scope = (ctx as { scope?: unknown } | undefined)?.scope;
277+
if (typeof scope === 'string' && (CRYPTO_CONTEXT_SCOPES as readonly string[]).includes(scope)) {
278+
return scope as CryptoContextScope;
279+
}
280+
throw new CryptoContextScopeError(scope);
281+
}
282+
283+
/** `lp(x)`: the 4-byte big-endian length of UTF-8(x), then those bytes. */
284+
function lengthPrefixed(value: string): Buffer {
285+
const bytes = Buffer.from(value, 'utf8');
286+
const length = Buffer.alloc(4);
287+
length.writeUInt32BE(bytes.length, 0);
288+
return Buffer.concat([length, bytes]);
289+
}
290+
291+
/**
292+
* The version-2 AAD of `ctx` (see "AAD binding" above): producer-discriminated
293+
* and length-prefixed. Exported for its pins only; callers never build AAD.
294+
*/
295+
export function aadForVersion2(ctx: CryptoContext): Buffer {
296+
return Buffer.concat([
297+
AAD_V2_LEAD,
298+
AAD_V2_LABEL,
299+
lengthPrefixed(requireScope(ctx)),
300+
lengthPrefixed(ctx.namespace),
301+
lengthPrefixed(ctx.key),
302+
]);
303+
}
304+
305+
/**
306+
* The version-1 AAD: the derivation every pre-versioning ciphertext was sealed
307+
* with. Read-only — it opens such a ciphertext and is never used to seal.
308+
*/
309+
function aadForVersion1(ctx: CryptoContext): Buffer {
310+
return Buffer.from([ctx.namespace, ctx.key].join('|'), 'utf8');
311+
}
312+
313+
/**
314+
* Split a stored ciphertext into the derivation it records and its base64 body.
315+
* No `:` ⇒ version 1 (standard base64 has none); `v2:` ⇒ version 2; anything
316+
* else is refused.
317+
*/
318+
function readCiphertext(ciphertext: string): { derivation: AadDerivation; body: string } {
319+
const at = ciphertext.indexOf(CIPHERTEXT_MARKER_SEPARATOR);
320+
if (at === -1) return { derivation: 1, body: ciphertext };
321+
const marker = ciphertext.slice(0, at);
322+
if (marker === CIPHERTEXT_V2_MARKER) return { derivation: 2, body: ciphertext.slice(at + 1) };
323+
throw new UnknownCiphertextVersionError(marker);
324+
}
325+
157326
type EnvMap = Record<string, string | undefined>;
158327

159328
/** Where the provider resolved its data key from (for diagnostics). */
@@ -505,8 +674,9 @@ export class LocalCryptoProvider implements ICryptoProvider {
505674
}
506675

507676
async encrypt(plain: string, ctx: CryptoContext): Promise<CryptoHandle> {
677+
// Every seal is version 2 — the only derivation this provider seals with.
678+
const aad = aadForVersion2(ctx);
508679
const iv = randomBytes(12);
509-
const aad = Buffer.from(this.aadOf(ctx), 'utf8');
510680
const plainBytes = Buffer.from(plain, 'utf8');
511681

512682
let blob: string;
@@ -530,16 +700,21 @@ export class LocalCryptoProvider implements ICryptoProvider {
530700
kmsKeyId: 'local:v1',
531701
alg: 'aes-256-gcm',
532702
version: 1,
533-
ciphertext: blob,
703+
ciphertext: CIPHERTEXT_V2_MARKER + CIPHERTEXT_MARKER_SEPARATOR + blob,
534704
};
535705
}
536706

537707
async decrypt(handle: CryptoHandle, ctx: CryptoContext): Promise<string> {
538-
const buf = Buffer.from(handle.ciphertext, 'base64');
708+
// The scope is required on every call, including a version-1 open that
709+
// does not bind it: the contract does not loosen by derivation.
710+
requireScope(ctx);
711+
// Dispatch on the derivation the ciphertext RECORDS — exactly one is tried.
712+
const { derivation, body } = readCiphertext(handle.ciphertext);
713+
const aad = derivation === 2 ? aadForVersion2(ctx) : aadForVersion1(ctx);
714+
const buf = Buffer.from(body, 'base64');
539715
const iv = buf.subarray(0, 12);
540716
const tag = buf.subarray(12, 28);
541717
const data = buf.subarray(28);
542-
const aad = Buffer.from(this.aadOf(ctx), 'utf8');
543718

544719
if (this.useNoble) {
545720
const gcm = await loadNobleGcm();
@@ -556,6 +731,11 @@ export class LocalCryptoProvider implements ICryptoProvider {
556731
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
557732
}
558733

734+
/**
735+
* Opens `handle` with the derivation it records and seals the plaintext
736+
* again with version 2 — so a version-1 handle comes back version 2. This is
737+
* the seam the at-rest re-wrap of pre-versioning ciphertext uses.
738+
*/
559739
async rotateKey(handle: CryptoHandle, ctx: CryptoContext): Promise<CryptoHandle> {
560740
const plain = await this.decrypt(handle, ctx);
561741
const next = await this.encrypt(plain, ctx);
@@ -583,15 +763,6 @@ export class LocalCryptoProvider implements ICryptoProvider {
583763
const tag = cipher.getAuthTag();
584764
return Buffer.concat([iv, tag, enc]).toString('base64');
585765
}
586-
587-
private aadOf(ctx: CryptoContext): string {
588-
// Bind ciphertext to (namespace,key) so a row cannot be moved across
589-
// specifiers. Tenant binding is intentionally omitted because the
590-
// handle is dereferenced from a `sys_setting` row already scoped to
591-
// its tenant — adding tenant here would force the decrypt path to
592-
// re-read that scope.
593-
return [ctx.namespace, ctx.key].join('|');
594-
}
595766
}
596767

597768
/**

‎packages/services/service-settings/src/settings-service.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1679,7 +1679,9 @@ export class SettingsService {
16791679
// handle id in sys_setting.value_enc. Otherwise fall back to
16801680
// the legacy inline crypto adapter path for back-compat.
16811681
if (this.cryptoProvider && this.secretStore) {
1682+
// ADR-0128 D1: the settings producer's own scope.
16821683
const handle = await this.cryptoProvider.encrypt(plain, {
1684+
scope: 'settings',
16831685
namespace,
16841686
key,
16851687
tenantId: ctx.tenantId,
@@ -2592,7 +2594,7 @@ export class SettingsService {
25922594
version: secret.version,
25932595
ciphertext: secret.ciphertext,
25942596
},
2595-
{ namespace: row.namespace, key: row.key },
2597+
{ scope: 'settings', namespace: row.namespace, key: row.key },
25962598
);
25972599
} else {
25982600
plain = await this.crypto.decrypt(row.value_enc, {

0 commit comments

Comments
 (0)