Skip to content

Commit d66cfb3

Browse files
committed
fix(service-settings): a secret overwrite's audit row records the previous value's keyed digest as old_hash
The secret arm of the settings audit write passed old_hash null on every write, though the column declares null only when the key was previously unset. old_hash on a secret write is now the same keyed digest new_hash uses, taken over the previous value: the upsert's probe names the previous row, its handle is read and decrypted before the reaper destroys it, and the plaintext never leaves that frame. No keyed digest, an unset or reset previous value, or a handle that no longer resolves records null and never fails the write. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5nYPKQq7oxF4u1xFKB
1 parent 1080f07 commit d66cfb3

3 files changed

Lines changed: 295 additions & 47 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
---
4+
5+
fix(service-settings): a secret overwrite's audit row records the previous value's keyed digest as `old_hash` (#26534)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
Ruling-ref: #26534 triage `6436617436`; the server seat 2 ruling, arm (a)
10+
11+
A secret-valued setting's overwrite used to record `old_hash` null on `sys_setting_audit` / `sys_platform_setting_audit`, though the column declares null only when the key was previously unset. It now records the same keyed digest (`hmac-sha256:…`) that `new_hash` records, taken over the previous value, so an overwrite's `old_hash` equals the previous row's `new_hash` and a rewrite of the same secret reads `old_hash == new_hash`. It stays null when the key was unset or reset, when no keyed digest is available, or when the previous value can no longer be read, and it is never an unkeyed hash of the secret. Only a secret overwrite pays for it: one `sys_secret` read and one decrypt of the previous value, before the rotated-away secret is reaped.

‎packages/services/service-settings/src/settings-audit-old-hash.test.ts‎

Lines changed: 186 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
* expected digests are computed here with `node:crypto`, independently of the
1717
* implementation.
1818
*
19-
* A secret-valued key is the CONTROL: its keyed fingerprint path
20-
* (`hmac-sha256:…`, `secretAuditDigest`) is untouched, so its rows keep a null
21-
* `old_hash` and a keyed `new_hash`.
19+
* [#26534] A secret-valued key records the SAME keyed digest on both columns
20+
* (`hmac-sha256:…`, `secretAuditDigest`): `old_hash` is the keyed digest of
21+
* the previous value, read back and decrypted inside the write, so it equals
22+
* the previous row's `new_hash`. No row ever carries an unkeyed digest of a
23+
* secret.
2224
*/
2325

2426
import { createHash } from 'node:crypto';
@@ -182,19 +184,27 @@ class MockHttp implements IHttpServer {
182184
}
183185

184186
/** The pieces the running server binds: real engine, real objects, the plugin's audit writer, real routes. */
185-
async function boot() {
187+
async function boot(opts: { cryptoProvider?: LocalCryptoProvider } = {}) {
186188
const engine = new ObjectQL();
187189
const { driver, rowsOf } = makeMemoryDriver();
188190
engine.registerDriver(driver, true);
189191
await engine.init();
190192
for (const o of [SysSetting, SysPlatformSetting, SysSecret, SysSettingAudit, SysPlatformSettingAudit]) {
191193
engine.registry.registerObject(o as any, OWNER_PACKAGE);
192194
}
195+
// The plugin's own `sys_secret` store, with its reads recorded: which
196+
// handles a write dereferences is what the cost cases measure.
197+
const store = (new SettingsServicePlugin() as any).buildSecretStore(engine);
198+
const secretReads: string[] = [];
199+
const secretStore = {
200+
...store,
201+
get: async (id: string) => { secretReads.push(id); return store.get(id); },
202+
};
193203
const svc = new SettingsService({
194204
env: {},
195205
engine: withEngineRegistry(wrapEngineAsSettingsEngine(engine as any), engine),
196-
cryptoProvider: new LocalCryptoProvider(),
197-
secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine),
206+
cryptoProvider: opts.cryptoProvider ?? new LocalCryptoProvider(),
207+
secretStore,
198208
auditWriter: buildSettingAuditWriter(engine as any),
199209
});
200210
for (const m of [TENANT_MANIFEST, GLOBAL_MANIFEST, USER_MANIFEST]) svc.registerManifest(m);
@@ -238,7 +248,19 @@ async function boot() {
238248
const auditOf = (object: 'sys_setting_audit' | 'sys_platform_setting_audit', key: string) =>
239249
[...rowsOf(object).values()].filter((r) => r.key === key);
240250

241-
return { put, auditOf, asUser: (id: string) => { userId = id; } };
251+
/** The `sys_secret` handle the global row of `key` names now. */
252+
const handleOf = (key: string) =>
253+
[...rowsOf('sys_platform_setting').values()].find((r) => r.key === key)?.value_enc as string | undefined;
254+
255+
return {
256+
put,
257+
auditOf,
258+
asUser: (id: string) => { userId = id; },
259+
svc,
260+
rowsOf,
261+
secretReads,
262+
handleOf,
263+
};
242264
}
243265

244266
describe('#26465 — old_hash / new_hash on the tenant rung (sys_setting_audit)', () => {
@@ -330,8 +352,36 @@ describe('#26465 — the global rung (sys_platform_setting_audit) records the sa
330352
[sha256Of('smtp2.example.com'), sha256Of('smtp2.example.com')],
331353
]);
332354
});
355+
});
356+
357+
/** The unkeyed digests a leak of `value` would carry, for the "never in any row" check. */
358+
const unkeyedOf = (value: string) => [
359+
sha256Of(value).slice('sha256:'.length),
360+
createHash('sha256').update(value, 'utf8').digest('hex'),
361+
];
362+
const expectNoUnkeyedDigest = (rows: Record<string, unknown>[], values: string[]) => {
363+
for (const row of rows) {
364+
for (const v of values) {
365+
expect(JSON.stringify(row)).not.toContain(v);
366+
for (const d of unkeyedOf(v)) expect(JSON.stringify(row)).not.toContain(d);
367+
}
368+
}
369+
};
370+
371+
describe('#26534 — a secret value records the keyed digest of its previous value as old_hash', () => {
372+
it('first write: old_hash null, keyed new_hash', async () => {
373+
const { put, auditOf } = await boot();
374+
375+
await put('hash_global', { api_key: 'pw-alpha' });
376+
377+
const rows = auditOf('sys_platform_setting_audit', 'api_key');
378+
expect(rows).toHaveLength(1);
379+
expect(rows[0]).toMatchObject({ action: 'set', encrypted: true, old_hash: null });
380+
expect(rows[0].new_hash).toMatch(KEYED_SHAPE);
381+
expectNoUnkeyedDigest(rows, ['pw-alpha']);
382+
});
333383

334-
it('CONTROL — a secret value keeps its keyed path: keyed new_hash, null old_hash, no unkeyed digest anywhere', async () => {
384+
it('overwrite: old_hash is the previous row\'s keyed new_hash, and no unkeyed digest is anywhere in a row', async () => {
335385
const { put, auditOf } = await boot();
336386

337387
await put('hash_global', { api_key: 'pw-alpha' });
@@ -341,11 +391,135 @@ describe('#26465 — the global rung (sys_platform_setting_audit) records the sa
341391
expect(rows).toHaveLength(2);
342392
for (const row of rows) {
343393
expect(row.encrypted).toBe(true);
344-
expect(row.old_hash).toBeNull();
345394
expect(row.new_hash).toMatch(KEYED_SHAPE);
346395
}
347-
expect(rows[1].new_hash).not.toBe(rows[0].new_hash);
348-
const unkeyed = [sha256Of('pw-alpha'), sha256Of('pw-beta'), 'sha256:' + createHash('sha256').update('pw-alpha').digest('hex')];
349-
for (const row of rows) for (const d of unkeyed) expect(JSON.stringify(row)).not.toContain(d.slice('sha256:'.length));
396+
expect(rows[1].old_hash).toMatch(KEYED_SHAPE);
397+
expect(rows[1].old_hash).toBe(rows[0].new_hash);
398+
expect(rows[1].new_hash).not.toBe(rows[1].old_hash);
399+
expectNoUnkeyedDigest(rows, ['pw-alpha', 'pw-beta']);
400+
});
401+
402+
it('same-value rewrite: old_hash equals new_hash, so a rotation that changed nothing is visible', async () => {
403+
const { put, auditOf } = await boot();
404+
405+
await put('hash_global', { api_key: 'pw-alpha' });
406+
await put('hash_global', { api_key: 'pw-alpha' });
407+
408+
const rows = auditOf('sys_platform_setting_audit', 'api_key');
409+
expect(rows).toHaveLength(2);
410+
expect(rows[1].old_hash).toMatch(KEYED_SHAPE);
411+
expect(rows[1].old_hash).toBe(rows[1].new_hash);
412+
expect(rows[1].old_hash).toBe(rows[0].new_hash);
413+
expectNoUnkeyedDigest(rows, ['pw-alpha']);
414+
});
415+
416+
it('set → reset → set: the reset carries the previous keyed digest and no new one; the next set starts from unset', async () => {
417+
const { put, auditOf } = await boot();
418+
419+
await put('hash_global', { api_key: 'pw-alpha' });
420+
await put('hash_global', { api_key: null });
421+
await put('hash_global', { api_key: 'pw-beta' });
422+
423+
const rows = auditOf('sys_platform_setting_audit', 'api_key');
424+
expect(rows.map((r) => r.action)).toEqual(['set', 'reset', 'set']);
425+
expect(rows[1]).toMatchObject({ old_hash: rows[0].new_hash, new_hash: null });
426+
expect(rows[1].old_hash).toMatch(KEYED_SHAPE);
427+
expect(rows[2].old_hash).toBeNull();
428+
expect(rows[2].new_hash).toMatch(KEYED_SHAPE);
429+
expectNoUnkeyedDigest(rows, ['pw-alpha', 'pw-beta']);
430+
});
431+
432+
it('cost: only a secret overwrite pays one more read of the previous handle, and that secret is still reaped after it', async () => {
433+
/** The reads of the previous handle one overwrite makes, on a fresh boot. */
434+
const overwriteReads = async (provider?: LocalCryptoProvider) => {
435+
const { put, auditOf, rowsOf, secretReads, handleOf } = await boot({ cryptoProvider: provider });
436+
// A non-secret write never touches sys_secret.
437+
await put('hash_global', { host: 'smtp.example.com' });
438+
expect(secretReads).toEqual([]);
439+
// A first write has no previous handle to read: every read is of the
440+
// handle it just wrote (the response's re-resolve).
441+
await put('hash_global', { api_key: 'pw-alpha' });
442+
const first = handleOf('api_key')!;
443+
expect(first).toMatch(/^sec_/);
444+
expect(secretReads.every((id) => id === first)).toBe(true);
445+
secretReads.length = 0;
446+
await put('hash_global', { api_key: 'pw-beta' });
447+
// The rotated-away secret is reaped AFTER the old_hash read (#8030).
448+
expect(rowsOf('sys_secret').has(first)).toBe(false);
449+
expect(rowsOf('sys_secret').has(handleOf('api_key')!)).toBe(true);
450+
return {
451+
reads: secretReads.filter((id) => id === first).length,
452+
oldHash: auditOf('sys_platform_setting_audit', 'api_key')[1].old_hash,
453+
};
454+
};
455+
// The baseline: with no keyed digest the write skips the old_hash read,
456+
// and what is left is the read the write path already made before this
457+
// rule (`validatePatch` resolves the key's current value).
458+
const unkeyed = new LocalCryptoProvider();
459+
(unkeyed as any).keyedDigest = undefined;
460+
const baseline = await overwriteReads(unkeyed);
461+
const keyed = await overwriteReads();
462+
463+
expect(baseline.oldHash).toBeNull();
464+
expect(keyed.oldHash).toMatch(KEYED_SHAPE);
465+
expect(keyed.reads - baseline.reads).toBe(1);
466+
});
467+
468+
it('no keyed digest available: old_hash null like new_hash, and the write lands', async () => {
469+
const provider = new LocalCryptoProvider();
470+
(provider as any).keyedDigest = undefined;
471+
const { put, auditOf, svc } = await boot({ cryptoProvider: provider });
472+
473+
await put('hash_global', { api_key: 'pw-alpha' });
474+
await put('hash_global', { api_key: 'pw-beta' });
475+
476+
const rows = auditOf('sys_platform_setting_audit', 'api_key');
477+
expect(rows.map((r) => [r.old_hash, r.new_hash])).toEqual([[null, null], [null, null]]);
478+
expect((await svc.get('hash_global', 'api_key')).value).toBe('pw-beta');
479+
expectNoUnkeyedDigest(rows, ['pw-alpha', 'pw-beta']);
480+
});
481+
482+
it('a previous handle that no longer resolves records old_hash null and the write still lands', async () => {
483+
const { put, auditOf, rowsOf, handleOf, svc } = await boot();
484+
485+
await put('hash_global', { api_key: 'pw-alpha' });
486+
rowsOf('sys_secret').delete(handleOf('api_key')!);
487+
await put('hash_global', { api_key: 'pw-beta' });
488+
489+
const rows = auditOf('sys_platform_setting_audit', 'api_key');
490+
expect(rows).toHaveLength(2);
491+
expect(rows[1].old_hash).toBeNull();
492+
expect(rows[1].new_hash).toMatch(KEYED_SHAPE);
493+
expect((await svc.get('hash_global', 'api_key')).value).toBe('pw-beta');
494+
});
495+
496+
it('a key whose manifest changes between non-secret and secret never records an unkeyed digest of a secret', async () => {
497+
// Not reachable through PUT: `encrypted` is the manifest's, and only a
498+
// re-registration (a deploy that edits the manifest) changes it.
499+
const { put, auditOf, svc } = await boot();
500+
const asSecret = (secret: boolean): SettingsManifest => ({
501+
...GLOBAL_MANIFEST,
502+
specifiers: [
503+
secret
504+
? { type: 'password', key: 'host', label: 'Host', required: false, encrypted: true }
505+
: { type: 'text', key: 'host', label: 'Host', required: false },
506+
],
507+
});
508+
509+
await put('hash_global', { host: 'plain-value' });
510+
svc.registerManifest(asSecret(true));
511+
await put('hash_global', { host: 'secret-value' });
512+
svc.registerManifest(asSecret(false));
513+
await put('hash_global', { host: 'plain-again' });
514+
515+
const rows = auditOf('sys_platform_setting_audit', 'host');
516+
expect(rows.map((r) => r.encrypted)).toEqual([false, true, false]);
517+
// non-secret → secret: the previous row held no secret, so no keyed digest of it.
518+
expect(rows[1].old_hash).toBeNull();
519+
expect(rows[1].new_hash).toMatch(KEYED_SHAPE);
520+
// secret → non-secret: the previous value was a secret, so no unkeyed digest of it.
521+
expect(rows[2].old_hash).toBeNull();
522+
expect(rows[2].new_hash).toBe(sha256Of('plain-again'));
523+
expectNoUnkeyedDigest(rows, ['secret-value']);
350524
});
351525
});

0 commit comments

Comments
 (0)