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' ;
810import { createHash , createHmac , randomBytes , createCipheriv , createDecipheriv } from 'node:crypto' ;
911import { 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 - z 0 - 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 - Z a - z 0 - 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+
157326type 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/**
0 commit comments