Skip to content

Commit ba57588

Browse files
fix(service-settings): the settings audit trail records secret-valued settings with the keyed digest (#21809)
Fixes #21792 Clause-②: no ## What changes The settings audit trail now records secret-valued settings (encrypted keys) with the platform's keyed digest (`ICryptoProvider.keyedDigest`), never the unkeyed `digest`. This follows the maintainer ruling recorded on the card. Non-secret settings are unchanged. - **`packages/services/service-settings/src/settings-service.ts`**: one private helper, `secretAuditDigest`, supplies the fingerprint for a secret-valued write on both write branches (the `sys_secret` path and the legacy inline-adapter path). Both ledgers take it: the `sys_audit_log` `config_change` row (`valueDigest`, now spelled with an `hmac-sha256:` digest inside the existing encrypted marker) and the `sys_setting_audit` row (`newHash`). - **No keyed digest available.** This happens when no crypto provider is wired (a host that builds the service with only a `CryptoAdapter`) or when the provider refuses. The rows then record the write with no fingerprint: `valueDigest` is the bare encrypted marker and `newHash` is null. A `warn` is logged once per key. The write itself is never refused, matching the existing rule that a ledger never fails a settings save. The unkeyed digest is never used as a fallback. - **`packages/spec/src/contracts/crypto-provider.ts`**: the contract text is revised as the ruling asks. `digest` is the audit fingerprint of non-secret values only. `keyedDigest` requirement 3 now says a secret's audit fingerprint comes from it and never from `digest`, and that with no keyed digest the trail records none. Comment-only: no type, export or schema change. - **Changeset**: `@objectstack/service-settings` patch and `@objectstack/spec` patch (the doc text ships in the `.d.ts`). ## Consumers that compare these digests No non-test code reads or compares `valueDigest` / `new_hash` / `old_hash`: `git grep` over `packages/**` returned only the writers, the object definition and comments. Equal-value detection still works for a reader, because the keyed digest is stable for equal input under one key. Three existing tests pinned the old unkeyed spelling for a secret. They were updated, not deleted: two now expect the keyed shape, and one (the legacy adapter, no provider) now expects no fingerprint. ## Tests (at `f34f69594c`; test code is unchanged since `fc5c86e1c6`) - New `settings-audit-secret-digest.test.ts` (7 cases). For a secret, both ledgers carry `provider.keyedDigest(value)`, which is neither `provider.digest(value)` nor the plain SHA-256 of the value. The fingerprint is stable for equal values and distinct for different ones. A reset records null. The legacy branch with a provider records the keyed digest. The legacy branch with no provider records no fingerprint, warns once, and the write still lands. A provider that refuses still lets the write land. For a non-secret value, both ledgers keep the adapter digest of the canonical JSON, unchanged. - `pnpm --filter @objectstack/service-settings test`: 34 files, 612 tests passed. `typecheck` (`tsc --noEmit`, `include: ["src"]`, so it covers the tests) passed. - **Ablation, two legs, done with `scripts/ablation-replace.mjs`**. The tests import `src`, so no dist rebuild was involved. Leg 1 put back the unkeyed `digest` on the `sys_secret` branch: 2 of 7 failed (the keyed-digest case and the refusing-provider case). Leg 2 put back the adapter digest on the legacy branch: 2 of 7 failed (both legacy cases). Each leg restored to the HEAD blob (`a148f503e350`) with `git diff HEAD` empty. - **Derived gate families** (`dispatch-gates.mjs --commands`, 87): 85 exit 0. `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it needs a whole-repo build). `check:type-check-debt` was killed twice by the foreground cap. Both are NOT MEASURED and left to CI. `--ran` reconciliation: 87 accounted, 0 unrun. - **Lint, narrowed to the 5 touched `.ts` files**: `eslint --no-inline-config --format json` reported 5 files, 0 errors, 0 warnings. The config's TS block matches `**/*.{ts,…}`, and type-aware linting is not enabled (no `parserOptions.project`), so this diff cannot change any verdict on an untouched file. ## Acceptance notes - **Optional `oldHash` (not done).** The `sys_setting_audit` writer still hard-codes `oldHash: null`. Recording the previous fingerprint of a secret would mean decrypting the prior ciphertext on every write, so it is not the small rider the dispatch allowed. It is left for a follow-up. - The `sys_setting_audit` object's `old_hash` / `new_hash` field descriptions in `platform-objects` still say "SHA-256 of the … value". That was already inexact for the default adapter, and it is outside this card's file surface. Carrier: none named. - Audit rows written before this release keep their old fingerprint. Rotating the data key changes every later secret fingerprint. --- _Generated by [Claude Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 18c7dfd commit ba57588

7 files changed

Lines changed: 301 additions & 21 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
The settings audit trail records a secret-valued setting (an encrypted key) with the crypto provider's keyed digest, never an unkeyed one (#21792).
7+
8+
Clause-②: no
9+
10+
- **Both ledgers.** The `sys_audit_log` `config_change` row (`valueDigest`, spelled `<encrypted:hmac-sha256:…>`) and the `sys_setting_audit` row (`new_hash`) now carry `ICryptoProvider.keyedDigest(value)` for a secret-valued setting. Before, they carried the unkeyed `digest` (`sha256:…`). This covers the `sys_secret` path and the legacy inline-adapter path.
11+
- **Change detection still works.** The keyed digest is stable for equal values under one key, so the trail still shows whether a secret changed and whether it went back to an earlier value. Rotating the data key changes every later fingerprint. Rows written before this release keep their old `sha256:` value.
12+
- **No keyed digest, no fingerprint.** When no crypto provider is wired (a host that builds `SettingsService` with only a `CryptoAdapter`), or the provider refuses a keyed digest, the audit rows record the write with no value fingerprint: `valueDigest` is `<encrypted>` and `new_hash` is null. The service logs this once per key at `warn`. The settings write itself is never refused for it. The adapter's own `digest` is no longer used for secrets.
13+
- **Non-secret settings are unchanged.** They keep the adapter's `digest` of the canonical JSON.
14+
- **Contract text (`@objectstack/spec`).** The `ICryptoProvider` docs for `digest` and `keyedDigest` now state the rule: a secret's audit fingerprint comes from `keyedDigest`, never from `digest`, and with no keyed digest the trail records none. No type, export or schema changes.
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The fingerprint the settings audit trail records for a write.
5+
*
6+
* Contract (`ICryptoProvider` in `@objectstack/spec/contracts`): a
7+
* secret-valued setting (an `encryptedKeys` member) is recorded on BOTH
8+
* ledgers — the generic `sys_audit_log` row (`SettingsAuditSink.valueDigest`)
9+
* and the per-key `sys_setting_audit` row (`SettingsAuditWriter.newHash`) —
10+
* with the provider's KEYED digest, never an unkeyed one; with no keyed digest
11+
* available it records no fingerprint at all. Non-secret settings keep the
12+
* adapter's unkeyed `digest` of the canonical JSON, unchanged.
13+
*/
14+
15+
import { createHash, randomBytes } from 'node:crypto';
16+
import { describe, expect, it, vi } from 'vitest';
17+
import type { ICryptoProvider } from '@objectstack/spec/contracts';
18+
import { SettingsService } from './settings-service.js';
19+
import type { CryptoAdapter } from './crypto-adapter.js';
20+
import { NoopCryptoAdapter } from './crypto-adapter.js';
21+
import { LocalCryptoProvider } from './local-crypto-provider.js';
22+
import { mailSettingsManifest } from './manifests/mail.manifest.js';
23+
import type { SettingsSecretStore } from './settings-service.types.js';
24+
25+
const KEYED_SHAPE = /^hmac-sha256:[0-9a-f]{64}$/;
26+
const sha256 = (s: string) => 'sha256:' + createHash('sha256').update(s, 'utf8').digest('hex');
27+
28+
function memorySecretStore(): SettingsSecretStore {
29+
const rows = new Map<string, any>();
30+
return {
31+
async insert(row) { rows.set(row.id, row); return { id: row.id }; },
32+
async get(id) { return rows.get(id) ?? null; },
33+
async update(id, patch) { rows.set(id, { ...rows.get(id), ...patch }); },
34+
};
35+
}
36+
37+
/** A confidential stand-in for an injected KMS adapter (legacy inline path). */
38+
class ConfidentialTestAdapter implements CryptoAdapter {
39+
readonly confidential = true;
40+
async encrypt(plaintext: string): Promise<string> {
41+
return 'kms:' + Buffer.from(plaintext, 'utf8').toString('base64');
42+
}
43+
async decrypt(ciphertext: string): Promise<string> {
44+
return Buffer.from(ciphertext.replace(/^kms:/, ''), 'base64').toString('utf8');
45+
}
46+
digest(plaintext: string): string {
47+
return sha256(plaintext);
48+
}
49+
}
50+
51+
function boot(opts: {
52+
cryptoProvider?: ICryptoProvider;
53+
secretStore?: SettingsSecretStore;
54+
crypto?: CryptoAdapter;
55+
logger?: { error: (m: string) => void; warn?: (m: string) => void };
56+
}) {
57+
const ledger: any[] = [];
58+
const settingAudit: any[] = [];
59+
const svc = new SettingsService({
60+
env: {},
61+
...opts,
62+
audit: { record: (e) => { ledger.push(e); } },
63+
auditWriter: { write: (e) => { settingAudit.push(e); } },
64+
});
65+
svc.registerManifest(mailSettingsManifest);
66+
const forKey = (k: string) => ({
67+
ledger: ledger.filter((e) => e.key === k),
68+
settingAudit: settingAudit.filter((e) => e.key === k),
69+
});
70+
return { svc, forKey };
71+
}
72+
73+
const writeSecret = (svc: SettingsService, value: string) =>
74+
svc.setMany('mail', { provider: 'resend', api_key: value, from_email: 'ops@example.com' });
75+
76+
describe('settings audit trail — secret-valued settings', () => {
77+
it('records the provider keyed digest on both ledgers, not the unkeyed hash of the value', async () => {
78+
const provider = new LocalCryptoProvider({ key: randomBytes(32) });
79+
const { svc, forKey } = boot({ cryptoProvider: provider, secretStore: memorySecretStore() });
80+
81+
await writeSecret(svc, 'pw-123456');
82+
83+
const { ledger, settingAudit } = forKey('api_key');
84+
const keyed = await provider.keyedDigest('pw-123456');
85+
expect(keyed).toMatch(KEYED_SHAPE);
86+
87+
expect(settingAudit).toHaveLength(1);
88+
expect(settingAudit[0]).toMatchObject({ encrypted: true, newHash: keyed });
89+
expect(settingAudit[0].newHash).not.toBe(provider.digest('pw-123456'));
90+
expect(settingAudit[0].newHash).not.toBe(sha256('pw-123456'));
91+
92+
expect(ledger).toHaveLength(1);
93+
expect(ledger[0]).toMatchObject({ encrypted: true, valueDigest: '<encrypted:' + keyed + '>' });
94+
expect(JSON.stringify([ledger, settingAudit])).not.toContain(sha256('pw-123456'));
95+
expect(JSON.stringify([ledger, settingAudit])).not.toContain('pw-123456');
96+
});
97+
98+
it('is stable for equal values and distinct for different ones', async () => {
99+
const provider = new LocalCryptoProvider({ key: randomBytes(32) });
100+
const { svc, forKey } = boot({ cryptoProvider: provider, secretStore: memorySecretStore() });
101+
102+
await writeSecret(svc, 'same-value');
103+
await writeSecret(svc, 'other-value');
104+
await writeSecret(svc, 'same-value');
105+
106+
const hashes = forKey('api_key').settingAudit.map((e) => e.newHash);
107+
expect(hashes).toHaveLength(3);
108+
expect(hashes[0]).toBe(hashes[2]);
109+
expect(hashes[1]).not.toBe(hashes[0]);
110+
const digests = forKey('api_key').ledger.map((e) => e.valueDigest);
111+
expect(digests[0]).toBe(digests[2]);
112+
expect(digests[1]).not.toBe(digests[0]);
113+
});
114+
115+
it('records no fingerprint on a reset', async () => {
116+
const provider = new LocalCryptoProvider({ key: randomBytes(32) });
117+
const { svc, forKey } = boot({ cryptoProvider: provider, secretStore: memorySecretStore() });
118+
119+
await writeSecret(svc, 'pw-1');
120+
await svc.set('mail', 'api_key', null);
121+
122+
const reset = forKey('api_key').settingAudit[1];
123+
expect(reset).toMatchObject({ action: 'reset', newHash: null });
124+
});
125+
126+
it('legacy inline-adapter path with a provider wired records the keyed digest, not the adapter digest', async () => {
127+
const provider = new LocalCryptoProvider({ key: randomBytes(32) });
128+
// No secret store: the write takes the inline `crypto.encrypt` branch.
129+
const { svc, forKey } = boot({ cryptoProvider: provider, crypto: new ConfidentialTestAdapter() });
130+
131+
await writeSecret(svc, 'pw-legacy');
132+
133+
const { ledger, settingAudit } = forKey('api_key');
134+
const keyed = await provider.keyedDigest('pw-legacy');
135+
expect(settingAudit[0].newHash).toBe(keyed);
136+
expect(ledger[0].valueDigest).toBe('<encrypted:' + keyed + '>');
137+
expect(JSON.stringify([ledger, settingAudit])).not.toContain(sha256('pw-legacy'));
138+
});
139+
140+
it('legacy inline-adapter path with no provider records no fingerprint and reports it once', async () => {
141+
const warn = vi.fn();
142+
const { svc, forKey } = boot({ crypto: new ConfidentialTestAdapter(), logger: { error: vi.fn(), warn } });
143+
144+
await writeSecret(svc, 'pw-a');
145+
await writeSecret(svc, 'pw-b');
146+
147+
const { ledger, settingAudit } = forKey('api_key');
148+
expect(settingAudit.map((e) => e.newHash)).toEqual([null, null]);
149+
expect(settingAudit.map((e) => e.action)).toEqual(['set', 'set']);
150+
expect(ledger.map((e) => e.valueDigest)).toEqual(['<encrypted>', '<encrypted>']);
151+
expect(JSON.stringify([ledger, settingAudit])).not.toContain(sha256('pw-a'));
152+
// The write itself still landed.
153+
expect((await svc.get<string>('mail', 'api_key')).value).toBe('pw-b');
154+
expect(warn.mock.calls.filter(([m]) => String(m).includes('mail.api_key'))).toHaveLength(1);
155+
});
156+
157+
it('a provider that refuses a keyed digest leaves the write intact and records no fingerprint', async () => {
158+
const real = new LocalCryptoProvider({ key: randomBytes(32) });
159+
const refusing: ICryptoProvider = {
160+
encrypt: real.encrypt.bind(real),
161+
decrypt: real.decrypt.bind(real),
162+
rotateKey: real.rotateKey.bind(real),
163+
digest: real.digest.bind(real),
164+
keyedDigest: async () => { throw new Error('no key material'); },
165+
};
166+
const { svc, forKey } = boot({
167+
cryptoProvider: refusing,
168+
secretStore: memorySecretStore(),
169+
logger: { error: vi.fn(), warn: vi.fn() },
170+
});
171+
172+
await writeSecret(svc, 'pw-x');
173+
174+
expect(forKey('api_key').settingAudit[0].newHash).toBeNull();
175+
expect(forKey('api_key').ledger[0].valueDigest).toBe('<encrypted>');
176+
expect((await svc.get<string>('mail', 'api_key')).value).toBe('pw-x');
177+
});
178+
});
179+
180+
describe('settings audit trail — non-secret settings', () => {
181+
it('keep the unkeyed adapter digest of the canonical JSON, unchanged', async () => {
182+
const provider = new LocalCryptoProvider({ key: randomBytes(32) });
183+
const { svc, forKey } = boot({ cryptoProvider: provider, secretStore: memorySecretStore() });
184+
185+
await writeSecret(svc, 'pw-1');
186+
187+
const expected = new NoopCryptoAdapter().digest(JSON.stringify('ops@example.com'));
188+
const { ledger, settingAudit } = forKey('from_email');
189+
expect(settingAudit[0]).toMatchObject({ encrypted: false, newHash: expected });
190+
expect(ledger[0]).toMatchObject({ encrypted: false, valueDigest: expected });
191+
});
192+
});

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ describe('SettingsService — global scope', () => {
184184
});
185185

186186
describe('SettingsService — audit sink', () => {
187-
it('records masked digest for encrypted values', async () => {
187+
it('records an encrypted value without the adapter digest when no keyed digest is available', async () => {
188188
const events: any[] = [];
189189
const svc = new SettingsService({
190190
env: {},
@@ -203,7 +203,9 @@ describe('SettingsService — audit sink', () => {
203203
const apiKeyEvent = events.find((e) => e.key === 'api_key');
204204
expect(apiKeyEvent).toBeTruthy();
205205
expect(apiKeyEvent.encrypted).toBe(true);
206-
expect(apiKeyEvent.valueDigest).toMatch(/^<encrypted:fnv32:/);
206+
// No crypto provider is wired, so there is no keyed digest; the adapter's
207+
// unkeyed digest is never recorded for a secret (crypto-provider contract).
208+
expect(apiKeyEvent.valueDigest).toBe('<encrypted>');
207209
});
208210
});
209211

@@ -2005,7 +2007,7 @@ describe('SettingsService — Phase 3 sys_secret + crypto provider + audit', ()
20052007
action: 'set',
20062008
encrypted: true,
20072009
});
2008-
expect(auditRows[0].newHash).toMatch(/^sha256:/);
2010+
expect(auditRows[0].newHash).toMatch(/^hmac-sha256:[0-9a-f]{64}$/);
20092011
expect(auditRows[0].newHash).not.toContain('super-secret-key');
20102012
});
20112013

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

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,11 @@ export class SettingsService {
554554
* otherwise repeat one operator-actionable line per attempt.
555555
*/
556556
private readonly reportedCryptoRefusals = new Set<string>();
557+
/**
558+
* `<namespace>.<key>` pairs whose missing secret audit fingerprint has
559+
* already been reported. See {@link secretAuditDigest}.
560+
*/
561+
private readonly reportedUnkeyedAuditDigests = new Set<string>();
557562
/**
558563
* Namespaces whose pre-bind READ has already been reported (#10250). Deduped
559564
* for the same reason the two sets above are, and keyed by NAMESPACE rather
@@ -1575,6 +1580,55 @@ export class SettingsService {
15751580
throw err;
15761581
}
15771582

1583+
/**
1584+
* The audit fingerprint of a secret-valued setting: the crypto provider's
1585+
* KEYED digest (`ICryptoProvider.keyedDigest`), never an unkeyed one.
1586+
*
1587+
* Both ledgers (`sys_audit_log` via {@link SettingsAuditSink} and
1588+
* `sys_setting_audit` via {@link SettingsAuditWriter}) are readable by
1589+
* people who must not be able to learn a secret. An unkeyed content hash
1590+
* lets any such reader confirm a guessed value offline — and for the short,
1591+
* low-entropy secrets settings carry (passwords, tokens of a known format),
1592+
* guessing is the attack. Under the provider's server-held key the
1593+
* fingerprint still answers "did this value change, and back to what it was
1594+
* before?" (stable per key for equal input) without answering "is it X?".
1595+
*
1596+
* When no keyed digest can be had — no provider is wired (the legacy
1597+
* inline-adapter path on a host that supplies none), or the provider
1598+
* rejects — the ledgers record NO fingerprint (`null`), reported once per
1599+
* key. Falling back to an unkeyed digest would be the exposure this method
1600+
* exists to close; failing the write would let a ledger veto a settings
1601+
* save, which neither audit seam is allowed to do.
1602+
*/
1603+
private async secretAuditDigest(
1604+
namespace: string,
1605+
key: string,
1606+
plain: string,
1607+
): Promise<string | null> {
1608+
const provider = this.cryptoProvider;
1609+
let reason: string;
1610+
if (provider && typeof provider.keyedDigest === 'function') {
1611+
try {
1612+
return await provider.keyedDigest(plain);
1613+
} catch (err: any) {
1614+
reason = `the crypto provider refused a keyed digest (${err?.message ?? err})`;
1615+
}
1616+
} else {
1617+
reason = 'no crypto provider with a keyed digest is wired';
1618+
}
1619+
const dedupeAt = `${namespace}.${key}`;
1620+
if (!this.reportedUnkeyedAuditDigests.has(dedupeAt)) {
1621+
this.reportedUnkeyedAuditDigests.add(dedupeAt);
1622+
const message =
1623+
`[SettingsService] ${namespace}.${key}: the audit trail records this secret-valued ` +
1624+
`setting's write without a value fingerprint because ${reason}. Wire an ICryptoProvider ` +
1625+
`(SettingsServiceOptions.cryptoProvider) to record its keyed digest.`;
1626+
if (this.logger?.warn) this.logger.warn(message);
1627+
else console.warn(message);
1628+
}
1629+
return null;
1630+
}
1631+
15781632
/** Persist a single key. Throws SettingsLockedError when env-locked. */
15791633
async set(
15801634
namespace: string,
@@ -1669,7 +1723,9 @@ export class SettingsService {
16691723

16701724
let storedValue: unknown | null = null;
16711725
let storedEnc: string | null = null;
1672-
let digest = '';
1726+
// The fingerprint both ledgers record. `null` only for a secret no
1727+
// keyed digest could be computed for — see `secretAuditDigest`.
1728+
let digest: string | null = null;
16731729

16741730
if (!isNull) {
16751731
if (isEncrypted) {
@@ -1696,7 +1752,7 @@ export class SettingsService {
16961752
ciphertext: handle.ciphertext,
16971753
});
16981754
storedEnc = handle.id;
1699-
digest = this.cryptoProvider.digest(plain);
1755+
digest = await this.secretAuditDigest(namespace, key, plain);
17001756
} else {
17011757
// #8026 — the legacy inline-adapter path persists only through an
17021758
// adapter that declares real confidentiality. The base64 default
@@ -1706,7 +1762,9 @@ export class SettingsService {
17061762
// branch and fall open.
17071763
this.assertEncryptionAvailable(namespace, key);
17081764
storedEnc = await this.crypto.encrypt(plain, { namespace, key });
1709-
digest = this.crypto.digest(plain);
1765+
// Not `this.crypto.digest` — an adapter's digest is not keyed by
1766+
// contract, and a secret is never fingerprinted with an unkeyed one.
1767+
digest = await this.secretAuditDigest(namespace, key, plain);
17101768
}
17111769
} else {
17121770
storedValue = rawValue;
@@ -1750,7 +1808,9 @@ export class SettingsService {
17501808
// an audit row invisible to RLS readers — see `SettingsAuditSink`.
17511809
tenantId: ctx.tenantId,
17521810
action: isNull ? 'reset' : 'set',
1753-
valueDigest: isEncrypted ? '<encrypted:' + digest + '>' : digest,
1811+
valueDigest: isEncrypted
1812+
? digest === null ? '<encrypted>' : '<encrypted:' + digest + '>'
1813+
: digest ?? '',
17541814
encrypted: isEncrypted,
17551815
requestId: ctx.requestId,
17561816
});
@@ -2431,7 +2491,7 @@ export class SettingsService {
24312491
*
24322492
* Nothing else can reference the handle: ids are minted per `encrypt()` call,
24332493
* `sys_setting.value_enc` is the only column that holds one, and the audit
2434-
* trail records digests (`sha256:…`) rather than handles — so it stays
2494+
* trail records digests (`hmac-sha256:…`) rather than handles — so it stays
24352495
* readable after the ciphertext is gone.
24362496
*
24372497
* **Best-effort, and deliberately after the repoint.** The write has already

‎packages/services/service-settings/src/sys-secret-orphan-report.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,9 +445,10 @@ describe('#8103 reachability fact 3 — the audit trail records digests, not han
445445
expect(serialised).not.toContain('tok-1');
446446
expect(serialised).not.toContain('tok-2');
447447

448-
// What IS recorded is a content digest.
448+
// What IS recorded is a digest of the content — the provider's keyed one,
449+
// since the value is a secret.
449450
const encryptedEntry = auditEntries.find((e) => e.encrypted === true)!;
450-
expect(String(encryptedEntry.newHash)).toMatch(/^sha256:[0-9a-f]{64}$/);
451+
expect(String(encryptedEntry.newHash)).toMatch(/^hmac-sha256:[0-9a-f]{64}$/);
451452

452453
// The other edge of the same fact: because no handle is ever recorded, the
453454
// audit trail cannot tell an operator which handles once existed — a row

‎packages/services/service-settings/src/sys-secret-orphan-report.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
* enumerable — it is every `secret`-typed field on every registered object,
3737
* including tenant-authored ones.
3838
* - ✅ The audit trail records digests, not handles (`old_hash` / `new_hash`
39-
* are content digests; `SettingsService` passes the provider's `digest()`),
39+
* are content digests; `SettingsService` passes the provider's
40+
* `keyedDigest()` for secret-valued keys, `digest()` otherwise),
4041
* so audit stays readable after a ciphertext is destroyed — and, the other
4142
* way round, audit can never be used to reconstruct which handles existed.
4243
*

0 commit comments

Comments
 (0)