@@ -5,13 +5,22 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:
55import { tmpdir } from 'node:os' ;
66import { join } from 'node:path' ;
77import { createHash , createHmac , randomBytes } from 'node:crypto' ;
8+ import {
9+ CRYPTO_CONTEXT_SCOPES ,
10+ type CryptoContext ,
11+ type CryptoContextScope ,
12+ type CryptoHandle ,
13+ } from '@objectstack/spec/contracts' ;
814import {
915 LocalCryptoProvider ,
1016 InMemoryCryptoProvider ,
1117 KeyedDigestKeyUnavailableError ,
18+ CryptoContextScopeError ,
19+ UnknownCiphertextVersionError ,
20+ aadForVersion2 ,
1221} from './local-crypto-provider.js' ;
1322
14- const ctx = { namespace : 'mail' , key : 'api_key' } ;
23+ const ctx : CryptoContext = { scope : 'settings' , namespace : 'mail' , key : 'api_key' } ;
1524
1625describe ( 'LocalCryptoProvider — key resolution' , ( ) => {
1726 let home : string ;
@@ -131,9 +140,9 @@ describe('LocalCryptoProvider — key resolution', () => {
131140describe ( 'LocalCryptoProvider — crypto semantics' , ( ) => {
132141 it ( 'AAD binding rejects ciphertexts swapped across (namespace,key)' , async ( ) => {
133142 const p = new LocalCryptoProvider ( { key : randomBytes ( 32 ) } ) ;
134- const handle = await p . encrypt ( 'value' , { namespace : 'mail' , key : 'api_key' } ) ;
143+ const handle = await p . encrypt ( 'value' , { scope : 'settings' , namespace : 'mail' , key : 'api_key' } ) ;
135144 await expect (
136- p . decrypt ( handle , { namespace : 'mail' , key : 'smtp_password' } ) ,
145+ p . decrypt ( handle , { scope : 'settings' , namespace : 'mail' , key : 'smtp_password' } ) ,
137146 ) . rejects . toThrow ( ) ;
138147 } ) ;
139148
@@ -155,6 +164,184 @@ describe('LocalCryptoProvider — crypto semantics', () => {
155164 } ) ;
156165} ) ;
157166
167+ /**
168+ * ADR-0128 D1–D3 — the AAD is producer-discriminated (D1), delimiter-safe
169+ * (D2), built at the producer of the AAD with no consumer-side fallback (D3),
170+ * and every ciphertext records the derivation that sealed it.
171+ */
172+ describe ( 'LocalCryptoProvider — scoped, versioned AAD (ADR-0128)' , ( ) => {
173+ /** A fixed data key for the pinned vectors (bytes 0x00..0x1f). */
174+ const PINNED_KEY = Buffer . from ( '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f' , 'hex' ) ;
175+
176+ /**
177+ * Sealed by the provider as it stood BEFORE derivations were versioned
178+ * (`origin/main` at 3a6d92f78b), under `PINNED_KEY`, for
179+ * `('legacy_ns', 'legacy_key')`. It is the shape every handle already at
180+ * rest has: bare base64, no marker.
181+ */
182+ const LEGACY_HANDLE : CryptoHandle = {
183+ id : 'sec_0cab6d627ca4a3e44ee65398eb7f5a74' ,
184+ kmsKeyId : 'local:v1' ,
185+ alg : 'aes-256-gcm' ,
186+ version : 1 ,
187+ ciphertext : '3k1P9mqvLWg6LImhxsapht2tMnc7FTBzcM55GwNJxZVLooqJ8oPHsG8bb3DttWZrP0S/Ug==' ,
188+ } ;
189+ const LEGACY_PLAIN = 'sealed-before-versioning' ;
190+
191+ /** Sealed by this derivation under `PINNED_KEY` — pins it against drift. */
192+ const V2_HANDLE : CryptoHandle = {
193+ id : 'sec_54a8311e4159cef5d286af80f028e727' ,
194+ kmsKeyId : 'local:v1' ,
195+ alg : 'aes-256-gcm' ,
196+ version : 1 ,
197+ ciphertext : 'v2:SNo5hjab0WpiNk/LLEwVv7OslY4okZxLjKGFi7mbc9CnTqNT/pxIBtZ6rRdKj4iS8P0=' ,
198+ } ;
199+ const V2_CTX : CryptoContext = { scope : 'settings' , namespace : 'pinned_ns' , key : 'pinned_key' } ;
200+ const V2_PLAIN = 'sealed-under-version-2' ;
201+
202+ const at = ( scope : CryptoContextScope , namespace = 'same_ns' , key = 'same_key' ) : CryptoContext => ( {
203+ scope,
204+ namespace,
205+ key,
206+ } ) ;
207+
208+ it ( 'seals every new ciphertext under the version-2 marker' , async ( ) => {
209+ const p = new LocalCryptoProvider ( { key : randomBytes ( 32 ) } ) ;
210+ for ( const scope of CRYPTO_CONTEXT_SCOPES ) {
211+ const h = await p . encrypt ( 'x' , at ( scope ) ) ;
212+ expect ( h . ciphertext . startsWith ( 'v2:' ) ) . toBe ( true ) ;
213+ expect ( h . ciphertext . slice ( 3 ) ) . toMatch ( / ^ [ A - Z a - z 0 - 9 + / ] + = * $ / ) ;
214+ expect ( await p . decrypt ( h , at ( scope ) ) ) . toBe ( 'x' ) ;
215+ }
216+ } ) ;
217+
218+ it ( 'opens a handle sealed before versioning with the derivation it was sealed with' , async ( ) => {
219+ // The pre-versioning blob is bare base64, which has no `:` — so it reads
220+ // as version 1 without any row being consulted.
221+ expect ( LEGACY_HANDLE . ciphertext ) . not . toContain ( ':' ) ;
222+ const p = new LocalCryptoProvider ( { key : PINNED_KEY } ) ;
223+ expect (
224+ await p . decrypt ( LEGACY_HANDLE , { scope : 'settings' , namespace : 'legacy_ns' , key : 'legacy_key' } ) ,
225+ ) . toBe ( LEGACY_PLAIN ) ;
226+ // Version 1 still binds its (namespace, key): another coordinate fails.
227+ await expect (
228+ p . decrypt ( LEGACY_HANDLE , { scope : 'settings' , namespace : 'legacy_ns' , key : 'other_key' } ) ,
229+ ) . rejects . toThrow ( ) ;
230+ } ) ;
231+
232+ it ( 'opens a version-1 handle without binding the scope — the older guarantee, until re-wrapped' , async ( ) => {
233+ // Version 1 never had a scope, so it cannot bind one; this is the weaker
234+ // guarantee CryptoContext documents for pre-versioning ciphertext.
235+ const p = new LocalCryptoProvider ( { key : PINNED_KEY } ) ;
236+ for ( const scope of CRYPTO_CONTEXT_SCOPES ) {
237+ expect ( await p . decrypt ( LEGACY_HANDLE , at ( scope , 'legacy_ns' , 'legacy_key' ) ) ) . toBe ( LEGACY_PLAIN ) ;
238+ }
239+ } ) ;
240+
241+ it ( 'opens a pinned version-2 vector, so the derivation cannot drift under sealed data' , async ( ) => {
242+ const p = new LocalCryptoProvider ( { key : PINNED_KEY } ) ;
243+ expect ( await p . decrypt ( V2_HANDLE , V2_CTX ) ) . toBe ( V2_PLAIN ) ;
244+ } ) ;
245+
246+ it ( 'pins the version-2 AAD bytes: lead byte, label, then each component length-prefixed' , ( ) => {
247+ const aad = aadForVersion2 ( { scope : 'datasource_credential' , namespace : 'datasource' , key : 'reporting' } ) ;
248+ expect ( aad . toString ( 'hex' ) ) . toBe (
249+ 'ff' +
250+ Buffer . from ( 'objectstack/crypto-context-aad/v2' , 'utf8' ) . toString ( 'hex' ) +
251+ '00000015' + Buffer . from ( 'datasource_credential' , 'utf8' ) . toString ( 'hex' ) +
252+ '0000000a' + Buffer . from ( 'datasource' , 'utf8' ) . toString ( 'hex' ) +
253+ '00000009' + Buffer . from ( 'reporting' , 'utf8' ) . toString ( 'hex' ) ,
254+ ) ;
255+ expect ( aad . toString ( 'hex' ) ) . toBe (
256+ 'ff6f626a656374737461636b2f63727970746f2d636f6e746578742d6161642f76320000001564617461736f757263655f63726564656e7469616c0000000a64617461736f75726365000000097265706f7274696e67' ,
257+ ) ;
258+ } ) ;
259+
260+ it ( 'D1: a ciphertext sealed under one scope does not open under any other, for the same (namespace, key)' , async ( ) => {
261+ const p = new LocalCryptoProvider ( { key : randomBytes ( 32 ) } ) ;
262+ for ( const sealedAs of CRYPTO_CONTEXT_SCOPES ) {
263+ const h = await p . encrypt ( 'scoped' , at ( sealedAs ) ) ;
264+ for ( const openedAs of CRYPTO_CONTEXT_SCOPES ) {
265+ if ( openedAs === sealedAs ) {
266+ expect ( await p . decrypt ( h , at ( openedAs ) ) ) . toBe ( 'scoped' ) ;
267+ } else {
268+ await expect ( p . decrypt ( h , at ( openedAs ) ) ) . rejects . toThrow ( ) ;
269+ }
270+ }
271+ }
272+ } ) ;
273+
274+ it ( 'D2: two contexts whose unescaped join collides produce different AAD bytes and do not open each other' , async ( ) => {
275+ const left : CryptoContext = { scope : 'settings' , namespace : 'a|b' , key : 'c' } ;
276+ const right : CryptoContext = { scope : 'settings' , namespace : 'a' , key : 'b|c' } ;
277+ // The collision vector: an unescaped join cannot tell these apart.
278+ expect ( [ left . scope , left . namespace , left . key ] . join ( '|' ) ) . toBe (
279+ [ right . scope , right . namespace , right . key ] . join ( '|' ) ,
280+ ) ;
281+ expect ( aadForVersion2 ( left ) . equals ( aadForVersion2 ( right ) ) ) . toBe ( false ) ;
282+
283+ const p = new LocalCryptoProvider ( { key : randomBytes ( 32 ) } ) ;
284+ const sealedLeft = await p . encrypt ( 'left' , left ) ;
285+ const sealedRight = await p . encrypt ( 'right' , right ) ;
286+ await expect ( p . decrypt ( sealedLeft , right ) ) . rejects . toThrow ( ) ;
287+ await expect ( p . decrypt ( sealedRight , left ) ) . rejects . toThrow ( ) ;
288+ expect ( await p . decrypt ( sealedLeft , left ) ) . toBe ( 'left' ) ;
289+ expect ( await p . decrypt ( sealedRight , right ) ) . toBe ( 'right' ) ;
290+ } ) ;
291+
292+ it ( 'refuses a derivation it does not know — fail closed, nothing else is tried' , async ( ) => {
293+ const p = new LocalCryptoProvider ( { key : PINNED_KEY } ) ;
294+ const unknown = { ...V2_HANDLE , ciphertext : 'v3:' + V2_HANDLE . ciphertext . slice ( 3 ) } ;
295+ const refusal = p . decrypt ( unknown , V2_CTX ) ;
296+ await expect ( refusal ) . rejects . toBeInstanceOf ( UnknownCiphertextVersionError ) ;
297+ await expect ( refusal ) . rejects . toMatchObject ( { marker : 'v3' } ) ;
298+ // Positive control: the same body under its own marker opens.
299+ expect ( await p . decrypt ( V2_HANDLE , V2_CTX ) ) . toBe ( V2_PLAIN ) ;
300+ } ) ;
301+
302+ it ( 'a ciphertext presented under the other derivation never authenticates' , async ( ) => {
303+ const p = new LocalCryptoProvider ( { key : PINNED_KEY } ) ;
304+ // A version-2 body with its marker removed reads as version 1 and fails.
305+ const stripped = { ...V2_HANDLE , ciphertext : V2_HANDLE . ciphertext . slice ( 3 ) } ;
306+ await expect ( p . decrypt ( stripped , V2_CTX ) ) . rejects . toThrow ( ) ;
307+ // A version-1 body with a version-2 marker added fails.
308+ const relabelled = { ...LEGACY_HANDLE , ciphertext : 'v2:' + LEGACY_HANDLE . ciphertext } ;
309+ await expect (
310+ p . decrypt ( relabelled , { scope : 'settings' , namespace : 'legacy_ns' , key : 'legacy_key' } ) ,
311+ ) . rejects . toThrow ( ) ;
312+ } ) ;
313+
314+ it ( 'rotateKey re-wraps a version-1 handle under version 2, bound to the scope' , async ( ) => {
315+ const p = new LocalCryptoProvider ( { key : PINNED_KEY } ) ;
316+ const sealedFor = at ( 'settings' , 'legacy_ns' , 'legacy_key' ) ;
317+ const rotated = await p . rotateKey ( LEGACY_HANDLE , sealedFor ) ;
318+ expect ( rotated . id ) . toBe ( LEGACY_HANDLE . id ) ;
319+ expect ( rotated . version ) . toBe ( LEGACY_HANDLE . version + 1 ) ;
320+ expect ( rotated . ciphertext . startsWith ( 'v2:' ) ) . toBe ( true ) ;
321+ expect ( await p . decrypt ( rotated , sealedFor ) ) . toBe ( LEGACY_PLAIN ) ;
322+ await expect ( p . decrypt ( rotated , at ( 'object_secret_field' , 'legacy_ns' , 'legacy_key' ) ) ) . rejects . toThrow ( ) ;
323+ } ) ;
324+
325+ it ( 'refuses a context without a member of the closed scope set, on every entry point' , async ( ) => {
326+ const p = new LocalCryptoProvider ( { key : randomBytes ( 32 ) } ) ;
327+ const sealed = await p . encrypt ( 'x' , at ( 'settings' ) ) ;
328+ const invalid = [
329+ { namespace : 'same_ns' , key : 'same_key' } ,
330+ { scope : 'setting' , namespace : 'same_ns' , key : 'same_key' } ,
331+ { scope : '' , namespace : 'same_ns' , key : 'same_key' } ,
332+ ] as unknown as CryptoContext [ ] ;
333+ for ( const bad of invalid ) {
334+ await expect ( p . encrypt ( 'x' , bad ) ) . rejects . toBeInstanceOf ( CryptoContextScopeError ) ;
335+ await expect ( p . decrypt ( sealed , bad ) ) . rejects . toBeInstanceOf ( CryptoContextScopeError ) ;
336+ await expect ( p . decrypt ( LEGACY_HANDLE , bad ) ) . rejects . toBeInstanceOf ( CryptoContextScopeError ) ;
337+ await expect ( p . rotateKey ( sealed , bad ) ) . rejects . toBeInstanceOf ( CryptoContextScopeError ) ;
338+ }
339+ // Positive control: the same calls with a member succeed.
340+ expect ( await p . decrypt ( sealed , at ( 'settings' ) ) ) . toBe ( 'x' ) ;
341+ expect ( ( await p . rotateKey ( sealed , at ( 'settings' ) ) ) . ciphertext . startsWith ( 'v2:' ) ) . toBe ( true ) ;
342+ } ) ;
343+ } ) ;
344+
158345describe ( 'LocalCryptoProvider — keyedDigest' , ( ) => {
159346 const KEYED_SHAPE = / ^ h m a c - s h a 2 5 6 : [ 0 - 9 a - f ] { 64 } $ / ;
160347 const input = 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' ;
0 commit comments