Skip to content

Commit 73c1a93

Browse files
committed
test(service-settings,objectql,service-datasource,cli): pin the scoped, versioned AAD and each producer's scope
Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4182cad commit 73c1a93

8 files changed

Lines changed: 316 additions & 23 deletions

File tree

‎packages/cli/src/utils/secret-reference-union.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ async function buildRuntime() {
214214

215215
// --- family 1: a settings handle, minted by the real provider -------------
216216
const settingsHandle = await crypto.encrypt('smtp-app-password', {
217+
scope: 'settings',
217218
namespace: 'smtp',
218219
key: 'password',
219220
});

‎packages/cli/src/utils/sys-secret-orphan-sweep.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ async function buildRuntime() {
217217
};
218218

219219
// --- family 1: a settings handle, minted by the real provider, IN FORCE ---
220-
const settingsHandle = await crypto.encrypt('smtp-app-password', { namespace: 'smtp', key: 'password' });
220+
const settingsHandle = await crypto.encrypt('smtp-app-password', { scope: 'settings', namespace: 'smtp', key: 'password' });
221221
seedSecret(settingsHandle, 'smtp', 'password');
222222
store.seed('sys_setting', {
223223
id: 'set_1', namespace: 'smtp', key: 'password', scope: 'tenant', user_id: null,
@@ -229,7 +229,7 @@ async function buildRuntime() {
229229
// deliberately not referenced by any sys_setting row. This is the one class
230230
// the ruling permits deleting, and it is what keeps every "deletable is
231231
// empty" assertion below falsifiable.
232-
const orphanHandle = await crypto.encrypt('rotated-away-token', { namespace: 'smtp', key: 'retired_token' });
232+
const orphanHandle = await crypto.encrypt('rotated-away-token', { scope: 'settings', namespace: 'smtp', key: 'retired_token' });
233233
seedSecret(orphanHandle, 'smtp', 'retired_token');
234234

235235
// --- family 2: the engine's own secret-field channel, LIVE ---------------
@@ -428,7 +428,7 @@ describe('the classes the ruling puts out of reach', () => {
428428
});
429429

430430
it('a LEGACY INLINE sibling is withheld — the #8063 guard in the opposite direction', async () => {
431-
const inlineHandle = await rt.crypto.encrypt('older-inline', { namespace: 'smtp', key: 'inline_legacy' });
431+
const inlineHandle = await rt.crypto.encrypt('older-inline', { scope: 'settings', namespace: 'smtp', key: 'inline_legacy' });
432432
rt.store.seed('sys_secret', {
433433
id: inlineHandle.id, namespace: 'smtp', key: 'inline_legacy',
434434
kms_key_id: inlineHandle.kmsKeyId, alg: inlineHandle.alg,
@@ -557,7 +557,7 @@ describe('the mandatory pre-delete export', () => {
557557
describe('the handle predicate comes from the producer', () => {
558558
it('agrees with a handle minted by the real LocalCryptoProvider', async () => {
559559
const crypto = new LocalCryptoProvider({ mode: 'test' });
560-
const handle = await crypto.encrypt('x', { namespace: 'smtp', key: 'password' });
560+
const handle = await crypto.encrypt('x', { scope: 'settings', namespace: 'smtp', key: 'password' });
561561
expect(isSecretHandle(handle.id)).toBe(true);
562562
// A legacy inline value is not a handle — the discriminator the guard rests on.
563563
expect(isSecretHandle('AQIDBAUGBwgJCg==')).toBe(false);

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,12 @@ function makeStubDriver() {
116116
function makeFakeCrypto() {
117117
let n = 0;
118118
const calls: { encrypt: number; decrypt: number } = { encrypt: 0, decrypt: 0 };
119+
/** Every context the engine handed the provider, per verb (ADR-0128 D1 pin). */
120+
const contexts: { encrypt: CryptoContext[]; decrypt: CryptoContext[] } = { encrypt: [], decrypt: [] };
119121
const provider: ICryptoProvider = {
120-
async encrypt(plain: string, _ctx: CryptoContext): Promise<CryptoHandle> {
122+
async encrypt(plain: string, ctx: CryptoContext): Promise<CryptoHandle> {
121123
calls.encrypt += 1;
124+
contexts.encrypt.push(ctx);
122125
n += 1;
123126
return {
124127
id: `sec_${n}`,
@@ -128,8 +131,9 @@ function makeFakeCrypto() {
128131
ciphertext: Buffer.from(plain, 'utf8').toString('base64'),
129132
};
130133
},
131-
async decrypt(handle: CryptoHandle, _ctx: CryptoContext): Promise<string> {
134+
async decrypt(handle: CryptoHandle, ctx: CryptoContext): Promise<string> {
132135
calls.decrypt += 1;
136+
contexts.decrypt.push(ctx);
133137
return Buffer.from(handle.ciphertext, 'base64').toString('utf8');
134138
},
135139
async rotateKey(handle: CryptoHandle): Promise<CryptoHandle> {
@@ -138,7 +142,7 @@ function makeFakeCrypto() {
138142
digest(plain: string): string { return `d:${plain.length}`; },
139143
async keyedDigest(plain: string): Promise<string> { return `k:${plain.length}`; },
140144
};
141-
return { provider, calls };
145+
return { provider, calls, contexts };
142146
}
143147

144148
const sysSecretObject = {
@@ -237,6 +241,21 @@ describe('objectql secret-field channel', () => {
237241
expect(ctx.crypto.calls.decrypt).toBe(1);
238242
});
239243

244+
it('seals and dereferences under its own scope, object_secret_field (ADR-0128 D1)', async () => {
245+
const created = await ctx.engine.insert('ext_datasource', { name: 'pg', db_password: 's3cr3t' });
246+
const stored = ctx.stores.get('ext_datasource')!.get(created.id) as any;
247+
await ctx.engine.resolveSecret(stored.db_password);
248+
await ctx.engine.resolveSecretField('ext_datasource', String(created.id), 'db_password');
249+
250+
expect(ctx.crypto.contexts.encrypt).toEqual([
251+
expect.objectContaining({ scope: 'object_secret_field', namespace: 'ext_datasource', key: 'db_password' }),
252+
]);
253+
expect(ctx.crypto.contexts.decrypt).toHaveLength(2);
254+
for (const opened of ctx.crypto.contexts.decrypt) {
255+
expect(opened).toMatchObject({ scope: 'object_secret_field', namespace: 'ext_datasource', key: 'db_password' });
256+
}
257+
});
258+
240259
// [#6231] `resolveSecret` reads `sys_secret` straight off the driver. That
241260
// call used to spell `{ object: 'sys_secret', where: { id } } as QueryAST`,
242261
// where the cast existed only to satisfy the AST's then-required `object`.

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

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,28 @@ import {
1010
} from '../datasource-secret-binder.js';
1111

1212
/**
13-
* Minimal AAD-binding crypto fake: ciphertext = base64(`${ns}|${key}::${plain}`).
14-
* decrypt() verifies the (namespace,key) AAD matches what encrypt() sealed —
15-
* mirroring InMemoryCryptoProvider's guarantee without pulling in node:crypto.
13+
* Minimal AAD-binding crypto fake: ciphertext = base64(JSON([aad, plain])), where
14+
* the AAD is the structured triple `[scope, namespace, key]`. decrypt() verifies
15+
* the triple matches what encrypt() sealed — mirroring LocalCryptoProvider's
16+
* producer-discriminated guarantee (ADR-0128 D1) without pulling in node:crypto.
1617
*/
17-
function fakeCrypto(): ICryptoProvider {
18+
function fakeCrypto(seen: CryptoContext[] = []): ICryptoProvider {
19+
const aadOf = (ctx: CryptoContext) => JSON.stringify([ctx.scope, ctx.namespace, ctx.key]);
1820
return {
1921
async encrypt(plain: string, ctx: CryptoContext): Promise<CryptoHandle> {
22+
seen.push(ctx);
2023
return {
2124
id: 'sec_' + ctx.key,
2225
kmsKeyId: 'local:test:v1',
2326
alg: 'aes-256-gcm',
2427
version: 1,
25-
ciphertext: Buffer.from(`${ctx.namespace}|${ctx.key}::${plain}`, 'utf8').toString('base64'),
28+
ciphertext: Buffer.from(JSON.stringify([aadOf(ctx), plain]), 'utf8').toString('base64'),
2629
};
2730
},
2831
async decrypt(handle: CryptoHandle, ctx: CryptoContext): Promise<string> {
29-
const raw = Buffer.from(handle.ciphertext, 'base64').toString('utf8');
30-
const [aad, plain] = raw.split('::');
31-
if (aad !== `${ctx.namespace}|${ctx.key}`) throw new Error('AAD mismatch');
32+
seen.push(ctx);
33+
const [aad, plain] = JSON.parse(Buffer.from(handle.ciphertext, 'base64').toString('utf8'));
34+
if (aad !== aadOf(ctx)) throw new Error('AAD mismatch');
3235
return plain;
3336
},
3437
async rotateKey(handle: CryptoHandle): Promise<CryptoHandle> {
@@ -77,6 +80,40 @@ describe('createDatasourceSecretBinder', () => {
7780
expect(await binder.resolve(ref)).toBe('super-secret-pw');
7881
});
7982

83+
it('binds and resolves under its own scope, datasource_credential (ADR-0128 D1)', async () => {
84+
const engine = fakeEngine();
85+
const seen: CryptoContext[] = [];
86+
const binder = createDatasourceSecretBinder({ engine, cryptoProvider: fakeCrypto(seen) });
87+
88+
const ref = await binder.bind({ value: 'pw' }, { name: 'reporting' });
89+
expect(await binder.resolve(ref)).toBe('pw');
90+
expect(seen).toEqual([
91+
{ scope: 'datasource_credential', namespace: 'datasource', key: 'reporting' },
92+
{ scope: 'datasource_credential', namespace: 'datasource', key: 'reporting' },
93+
]);
94+
});
95+
96+
it('does not resolve a row another producer sealed, even at the same (namespace, key)', async () => {
97+
const engine = fakeEngine();
98+
const crypto = fakeCrypto();
99+
const binder = createDatasourceSecretBinder({ engine, cryptoProvider: crypto });
100+
// A row at this binder's own coordinate, sealed under a different scope.
101+
const foreign = await crypto.encrypt('pw', { scope: 'settings', namespace: 'datasource', key: 'reporting' });
102+
await engine.insert('sys_secret', {
103+
id: foreign.id,
104+
namespace: 'datasource',
105+
key: 'reporting',
106+
kms_key_id: foreign.kmsKeyId,
107+
alg: foreign.alg,
108+
version: foreign.version,
109+
ciphertext: foreign.ciphertext,
110+
});
111+
expect(await binder.resolve(toCredentialsRef(foreign.id))).toBeUndefined();
112+
// Positive control: the binder's own seal at the same coordinate resolves.
113+
const own = await binder.bind({ value: 'pw' }, { name: 'reporting' });
114+
expect(await binder.resolve(own)).toBe('pw');
115+
});
116+
80117
it('resolve() returns undefined after unbind (row gone)', async () => {
81118
const engine = fakeEngine();
82119
const binder = createDatasourceSecretBinder({ engine, cryptoProvider: fakeCrypto() });

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

Lines changed: 190 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,22 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:
55
import { tmpdir } from 'node:os';
66
import { join } from 'node:path';
77
import { 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';
814
import {
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

1625
describe('LocalCryptoProvider — key resolution', () => {
1726
let home: string;
@@ -131,9 +140,9 @@ describe('LocalCryptoProvider — key resolution', () => {
131140
describe('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-Za-z0-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+
158345
describe('LocalCryptoProvider — keyedDigest', () => {
159346
const KEYED_SHAPE = /^hmac-sha256:[0-9a-f]{64}$/;
160347
const input = 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';

0 commit comments

Comments
 (0)