Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/26465-settings-audit-old-hash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/service-settings': patch
---

fix(service-settings): the settings audit trail records the previous value's digest in `old_hash`, and a non-secret value's digest is the declared SHA-256

Clause-②: no
Metadata-protocol: none

`sys_setting_audit` and `sys_platform_setting_audit` declare `old_hash` as the SHA-256 of the previous value (null when it was unset) and `new_hash` as the SHA-256 of the new value (null on a reset). The write path passed `old_hash: null` on every write and recorded the adapter's FNV-1a 32 (`fnv32:…`) as the new hash, so an operator could not tell from the trail whether a write changed anything.

- **`old_hash`** now holds the digest of the value the row held before the write, read off the row the upsert already looks up: no extra read, and none at all for a first write. It is null for a first write, after a reset, and for a row that held a secret.
- **A non-secret value's digest** is now `sha256:` followed by the hex SHA-256 of its canonical JSON, on both `new_hash` and the `config_change` row's digest in `sys_audit_log`. It no longer goes through `CryptoAdapter.digest`, so an injected adapter cannot change the format. The prefix keeps it distinct from the `fnv32:` rows already stored, which are left as they are: compare hashes only between rows that carry the same prefix.
- **Secret values are unchanged.** They keep the crypto provider's keyed digest (`hmac-sha256:…`) as `new_hash`, or none, and `old_hash` stays null for them.
22 changes: 14 additions & 8 deletions docs/qa/platform-checklist/areas/platform-core.json
Original file line number Diff line number Diff line change
Expand Up @@ -3136,7 +3136,7 @@
"title": "The settings audit trail keeps no fingerprint of a secret-valued setting that a reader can match offline: both ledgers record the platform's keyed digest or none, never the value or an unkeyed hash, so a caller who can only read the audit rows learns that the secret changed and nothing about its value",
"since": "v17",
"status": "active",
"revision": 4,
"revision": 5,
"priority": "P1",
"surface": "api",
"personas": [
Expand All @@ -3159,10 +3159,10 @@
"generate a fresh random value V1 for this run (never a value used anywhere else). As admin, PUT /api/settings/mail { provider: 'resend', api_key: V1, from_email: 'ops@example.com' }",
"read the per-key ledger: GET /api/v1/data/sys_platform_setting_audit filtered to namespace=mail and key=api_key; capture the newest row (action set, encrypted true, new_hash). mail is a GLOBAL-scope namespace, and the per-key ledger of the global rung is the deployment-level sys_platform_setting_audit (ADR-0131 D7, packages/services/service-settings/src/settings-service-plugin.ts#settingAuditObjectFor); a tenant- or user-scope key's row lands in sys_setting_audit",
"read the generic ledger: GET /api/v1/data/sys_audit_log filtered to action=config_change; capture the newest row for mail.api_key (object_name 'sys_platform_setting' — mail is a GLOBAL-scope namespace, and a global-scope value's row names the tenant-less table it lives in, packages/services/service-settings/src/config-change-audit.ts#CONFIG_CHANGE_GLOBAL_OBJECT_NAME; a tenant-scope key's row names 'sys_setting'; new_value is a JSON string carrying namespace, key, scope and digest; metadata carries encrypted: true)",
"OFFLINE, with no server key: compute the unkeyed digests a leak would carry — the FNV-1a 32 of V1's canonical JSON (the quoted string), spelled 'fnv32:' + 8 lowercase hex, which is the unkeyed digest this settings service writes for a non-secret key (packages/services/service-settings/src/crypto-adapter.ts#digest), and, belt and braces, the SHA-256 hex of V1 (bare, and with a 'sha256:' prefix) — and search both captured rows, every field, for each of them and for V1 itself",
"OFFLINE, with no server key: compute the unkeyed digests a leak would carry — the SHA-256 of V1's canonical JSON (the quoted string), spelled 'sha256:' + 64 lowercase hex, which is the unkeyed digest this settings service writes for a non-secret key (packages/services/service-settings/src/crypto-adapter.ts#settingAuditDigest), and, belt and braces, the bare SHA-256 hex of V1 and of its canonical JSON, and the FNV-1a 32 of its canonical JSON ('fnv32:' + 8 lowercase hex, the non-secret digest rows written before #26465 carry) — and search both captured rows, every field, for each of them and for V1 itself",
"write V1 again, then a different value V2, then V1 a third time; re-read both ledgers and compare the fingerprints of the three writes",
"reset the key: PUT /api/settings/mail { provider: 'log', api_key: null } (a null value is the reset; 'log' needs no api_key), then read the reset rows",
"control: write the NON-secret key mail.from_email and compute OFFLINE the FNV-1a 32 of its canonical JSON (the quoted string), spelled 'fnv32:' + 8 lowercase hex. Its rows must carry exactly that unkeyed digest. This proves step 4's offline search — which computes the same algorithm — WOULD have found an unkeyed digest, and that the keyed rule is scoped to secret-valued keys"
"control: write the NON-secret key mail.from_email and compute OFFLINE the SHA-256 of its canonical JSON (the quoted string), spelled 'sha256:' + 64 lowercase hex. Its rows must carry exactly that unkeyed digest. This proves step 4's offline search — which computes the same algorithm — WOULD have found an unkeyed digest, and that the keyed rule is scoped to secret-valued keys"
],
"acceptance": [
{
Expand All @@ -3172,7 +3172,7 @@
"evidence": "both captured rows, quoted in full"
},
{
"clause": "a reader of the audit rows who holds no server key cannot confirm a guess: no fingerprint in either row can be reproduced from the value alone. No offline unkeyed digest of V1 — the FNV-1a 32 of its canonical JSON ('fnv32:…', the algorithm the service uses for non-secret keys), nor the SHA-256 of V1 with or without the sha256 prefix — matches anything in either row",
"clause": "a reader of the audit rows who holds no server key cannot confirm a guess: no fingerprint in either row can be reproduced from the value alone. No offline unkeyed digest of V1 — the SHA-256 of its canonical JSON ('sha256:…', the algorithm the service uses for non-secret keys), the bare SHA-256 of V1, nor the FNV-1a 32 of its canonical JSON ('fnv32:…') — matches anything in either row",
"oracle": "api",
"verify": "the step 4 offline search over both rows finds no hit, AND the step 7 control's offline digest DOES match the non-secret row. Without the control, a no-hit proves nothing about the search",
"evidence": "the offline digest values, the two secret rows, and the matching control row"
Expand All @@ -3190,9 +3190,9 @@
"evidence": "the two reset rows"
},
{
"clause": "non-secret settings are unchanged: a non-encrypted key keeps the unkeyed digest of its canonical JSON on both ledgers. The keyed rule applies to secret-valued keys only",
"clause": "non-secret settings record the unkeyed digest of their canonical JSON on both ledgers, the SHA-256 the per-key ledger's new_hash column declares. The keyed rule applies to secret-valued keys only",
"oracle": "api",
"verify": "mail.from_email's rows carry the FNV-1a 32 digest of the quoted JSON string ('fnv32:' + 8 lowercase hex), computed offline in step 7",
"verify": "mail.from_email's rows carry the SHA-256 digest of the quoted JSON string ('sha256:' + 64 lowercase hex), computed offline in step 7",
"evidence": "the control rows and the offline digest"
},
{
Expand All @@ -3203,7 +3203,7 @@
}
],
"negative": [
"an unkeyed digest of a secret-valued setting (the 'fnv32:' FNV-1a 32 of its canonical JSON, or a plain SHA-256 of the value, with or without a prefix) on EITHER ledger is a FAIL. Anyone who can read the audit rows could confirm a guessed value offline, and settings secrets are short, guessable values",
"an unkeyed digest of a secret-valued setting (the 'sha256:' SHA-256 of its canonical JSON, a plain SHA-256 of the value, or the 'fnv32:' FNV-1a 32 of its canonical JSON) on EITHER ledger is a FAIL. Anyone who can read the audit rows could confirm a guessed value offline, and settings secrets are short, guessable values",
"the plaintext of a secret-valued setting in any field of either ledger is a FAIL",
"a host with no keyed digest falling back to an unkeyed one is a FAIL; the rule is to record no fingerprint",
"a secret write REFUSED because its audit fingerprint could not be computed is a FAIL: the audit trail never vetoes a settings save",
Expand All @@ -3214,7 +3214,7 @@
],
"automated": {
"kind": "unit",
"ref": "packages/services/service-settings/src/settings-audit-secret-digest.test.ts — acceptance[5] and negative[2]–[3] ONLY: the no-keyed-digest arm a stock boot cannot reach (knownGaps[0]), through 'legacy inline-adapter path with no provider records no fingerprint and reports it once' and 'a provider that refuses a keyed digest leaves the write intact and records no fingerprint'. The pin drives SettingsService over stub audit sinks. So its other cases — 'records the provider keyed digest on both ledgers, not the unkeyed hash of the value', 'is stable for equal values and distinct for different ones', 'records no fingerprint on a reset', the provider-wired inline-adapter case, and the non-secret 'keep the unkeyed adapter digest of the canonical JSON, unchanged' — are service-layer evidence for acceptance[0]–[4] and negative[0]–[1], never their pass: those score the ledger rows read back over the API (steps[0]–[6])"
"ref": "packages/services/service-settings/src/settings-audit-secret-digest.test.ts — acceptance[5] and negative[2]–[3] ONLY: the no-keyed-digest arm a stock boot cannot reach (knownGaps[0]), through 'legacy inline-adapter path with no provider records no fingerprint and reports it once' and 'a provider that refuses a keyed digest leaves the write intact and records no fingerprint'. The pin drives SettingsService over stub audit sinks. So its other cases — 'records the provider keyed digest on both ledgers, not the unkeyed hash of the value', 'is stable for equal values and distinct for different ones', 'records no fingerprint on a reset', the provider-wired inline-adapter case, and the non-secret 'record the declared SHA-256 of the canonical JSON on both ledgers, not the adapter digest (#26465)' — are service-layer evidence for acceptance[0]–[4] and negative[0]–[1], never their pass: those score the ledger rows read back over the API (steps[0]–[6])"
},
"source": [
"packages/services/service-settings/src/settings-service.ts#secretAuditDigest (one helper supplies a secret write's fingerprint on both write branches, the sys_secret path and the legacy inline-adapter path: the provider's keyedDigest, or null with a once-per-key warn; never the adapter's unkeyed digest)",
Expand Down Expand Up @@ -3248,6 +3248,12 @@
"date": "2026-10-20",
"change": "automated.ref narrowed per clause — the sweep-wide rule-6 contradiction QA run #24840 (18.0 sweep round 4, Tier 1 at 307ce4c4) names at settings-audit acceptance[0]–[4], round 15 of wave anchor #24579. The ref listed seven cases whose names read as acceptance[0], [2], [3] and [4]. The pin drives SettingsService over stub audit sinks and never reads sys_platform_setting_audit or sys_audit_log over the API, which is what those clauses score. By RUNNER rule 6 (a pin scores only the layer it drives) it scores acceptance[5] and its negatives [2] and [3] only (QA run #24838 scored acceptance[0]–[4] live). Read at base b1676441ba. No clause changed",
"ref": "#24891"
},
{
"revision": 5,
"date": "2026-10-27",
"change": "steps[3] and [6], acceptance[1].clause, [4] (clause, verify), negative[0] and automated.ref re-pointed: a non-secret setting's audit digest is now the SHA-256 the sys_setting_audit / sys_platform_setting_audit columns declare, spelled 'sha256:' + 64 hex (crypto-adapter.ts settingAuditDigest), no longer the adapter's FNV-1a 32 ('fnv32:'). The step-7 control computes the algorithm the service now writes, so it can still prove the offline search would find an unkeyed digest. Not loosened: the secret search keeps the FNV-1a 32 as a second net, so a secret row carrying either unkeyed form still fails",
"ref": "#26465"
}
]
},
Expand Down
33 changes: 30 additions & 3 deletions packages/services/service-settings/src/crypto-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { createHash } from 'node:crypto';

/**
* Pluggable adapter for at-rest encryption of `Specifier.encrypted: true`
* values. The default {@link NoopCryptoAdapter} provides a transparent
Expand All @@ -14,8 +16,14 @@ export interface CryptoAdapter {
/** Returns the plaintext used by the resolver. */
decrypt(ciphertext: string, ctx: { namespace: string; key: string }): Promise<string>;
/**
* Stable, short, non-reversible digest used for audit-log entries so
* operators can correlate value changes without leaking secrets.
* Stable, short, non-reversible digest of a plaintext.
*
* Not read by the settings audit trail since #26465: `sys_setting_audit`
* and `sys_platform_setting_audit` declare SHA-256 for `old_hash` /
* `new_hash`, so the non-secret fingerprint is {@link settingAuditDigest}
* whatever adapter is injected, and a secret is fingerprinted only by the
* crypto provider's keyed digest. Kept on the interface so existing
* adapters still satisfy it.
*/
digest(plaintext: string): string;
/**
Expand Down Expand Up @@ -79,7 +87,8 @@ export class NoopCryptoAdapter implements CryptoAdapter {
return Buffer.from(ciphertext.slice(4), 'base64').toString('utf8');
}
digest(plaintext: string): string {
// FNV-1a 32-bit — short, stable, non-cryptographic. Audit-only.
// FNV-1a 32-bit — short, stable, non-cryptographic. The settings audit
// no longer records it (#26465, see `settingAuditDigest`).
let h = 0x811c9dc5;
for (let i = 0; i < plaintext.length; i++) {
h ^= plaintext.charCodeAt(i);
Expand All @@ -89,6 +98,24 @@ export class NoopCryptoAdapter implements CryptoAdapter {
}
}

/**
* The fingerprint the settings audit trail records for a NON-secret value
* (#26465): `sha256:` followed by the hex SHA-256 of the value's canonical
* JSON, which is the algorithm `old_hash` / `new_hash` declare on
* `sys_setting_audit` and `sys_platform_setting_audit`.
*
* Computed here rather than through {@link CryptoAdapter.digest}, so an
* injected adapter cannot change the column's format. The prefix keeps the
* value self-describing beside the rows written before it (`fnv32:` from the
* old adapter digest) and the keyed secret fingerprints (`hmac-sha256:`), so a
* reader comparing two rows can tell a different algorithm from a different
* value. Never used for a secret: an unkeyed hash of a short secret lets any
* reader of the audit rows confirm a guess offline.
*/
export function settingAuditDigest(canonicalJson: string): string {
return 'sha256:' + createHash('sha256').update(canonicalJson, 'utf8').digest('hex');
}

/**
* Is this adapter allowed to hold a declared-secret value at rest (#8026)?
*
Expand Down
Loading
Loading