Repository navigation
fix(service-settings): a secret overwrite's audit row records the previous value's keyed digest as old_hash - #26600
Conversation
…vious 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
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 002de79f81109b41e4fbbe150c795b8bd42e039d && git checkout 002de79f81109b41e4fbbe150c795b8bd42e039d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fd97351593a3d7c76d0def9da093cdbf3558222f d66cfb3989510da0161333bf93bfeef650ca7626 && git checkout -B drift-repro fd97351593a3d7c76d0def9da093cdbf3558222f && git merge --no-ff d66cfb3989510da0161333bf93bfeef650ca7626
node scripts/docs-audit/affected-docs.mjs --json fd97351593a3d7c76d0def9da093cdbf3558222f
|
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #26534
Clause-②: no
Metadata-protocol: none
维护者速读
encrypted: true)被覆盖时,审计行old_hash不再恒为 null,而是记上一次值的带密钥摘要(hmac-sha256:…),与new_hash同一算法;所以覆盖时old_hash等于上一行的new_hash,同值重写时old_hash == new_hash。sys_secret读和一次解密,且在旧密文被回收之前;读不到或解不开时记 null,写入照常成功;明文只在函数内用于算摘要,不记日志、不返回、不存储;从不写无密钥哈希。回滚即 revert 本 PR。packages/services/service-settings/src/settings-service.ts(setMany中upsertRow与reapRotatedSecret之间新增的oldHash,以及previousSecretAuditDigest);src/settings-audit-old-hash.test.ts(#26534一组用例)。pnpm --filter @objectstack/service-settings exec vitest run src/settings-audit-old-hash.test.ts,预期 14/14 通过,其中#26534八条。What changes
SettingsService.setManynow computes the audit'soldHashafterupsertRow(whose existence probe names the previous row) and beforereapRotatedSecret(which destroys the previous secret'ssys_secretrow):encrypted: truekey):old_hashispreviousSecretAuditDigest(...), the same keyed digestnew_hashrecords (secretAuditDigest, the provider'skeyedDigest), taken over the previous value. The previous plaintext is read through a new privatedecryptStored(row), factored out ofmaterialiseRowwith no behaviour change there, and is digested and dropped inside that frame.nullwhen the previous row held no secret (no row, a reset row, or a row written while the key was not secret-valued), when no keyed digest is available (checked before the read, so that write pays nothing), or when the previous value cannot be read back (handle gone, decrypt refused; the latter warns once on the logger with the key and the decrypt error, never the value). It never fails the write.previousAuditDigest(previous)(service-settings: sys_setting_audit.old_hash is never written (hard-coded null) and new_hash for a non-secret value is an FNV-32 digest, while both columns declare "SHA-256 of the … value" #26465).auditWriterwired, nothing is computed.Never a digest of ciphertext (it changes on every re-encryption), never an unkeyed hash of the plaintext. The secret reaper (#8030
previousEnc) is unchanged. Changeset:@objectstack/service-settingspatch.Why
sys_setting_audit.old_hashandsys_platform_setting_audit.old_hashdeclare "Null when previously unset", but the secret arm passedoldHash: isEncrypted ? null : ..., so every secret overwrite recorded null. Theencryptedcolumn exists to flag rotation events, and withoutold_hashan operator cannot tell a rotation that changed the secret from one that rewrote the same value. Rulings: triage6436617436and the server seat 2 ruling, arm (a).Tests
All through
PUT /api/settings/:namespaceon the #26465 harness (realObjectQL, real platform objects, the plugin's ownsys_secretstore and audit writer). The old secret CONTROL that pinnedold_hash: nullis replaced by a#26534block:old_hashnull, keyednew_hashold_hashkeyed and equal to the previous row'snew_hash; differs from the newnew_hashold_hash == new_hash ==previous row'snew_hashold_hash= previous keyed digest,new_hashnull; next set:old_hashnullsys_secret; a first write reads only its own handle; an overwrite reads the previous handle exactly one more time than a boot with no keyed digest, and that handle is still reaped afterwardsold_hashnull, keyednew_hash, write lands and reads backold_hashnull; no unkeyed digest of the secretEvery secret case keeps the "no plaintext and no unkeyed SHA-256 of the secret anywhere in the row" assertion.
Ablation (committed fix at
d66cfb39,scripts/ablation-replace.mjs):await this.previousSecretAuditDigest(namespace, key, previous)replaced bynull, anchor 1 → 0, blob148247002eaf→0994b6cb2558. Result: 4 failed, 10 passed (overwrite, same-value, reset, cost). Restored: blob == HEAD148247002eaf,git diff HEADempty.Package at
d66cfb39:pnpm --filter @objectstack/service-settings testTest Files 61 passed (61), Tests 1014 passed (1014);typecheck(tsc --noEmit,include: ["src"], tests included) exit 0.eslint --no-inline-configonpackages/services/service-settings: exit 0.Acceptance notes
validatePatch(it resolvesget()for every touched key), so a secret overwrite now makes two reads of the previous handle where it made one. That earlier read is the cascade's effective value, which can come from another rung or the environment, so it is not the row's previous value and is not reused.encryptedcomes from the manifest, and only a re-registration (registerManifestreplaces the namespace's entry; in practice a deploy that edits the manifest while stored rows persist) changes it. Both crossings recordold_hashnull: non-secret → secret has no previous secret to key-digest, and secret → non-secret must not write an unkeyed digest of the previous secret. Pinned by the last case.platform-core.settings-audit-secret-fingerprintsays nothing about a secret row'sold_hash, and no digest algorithm changed, so its revision is not bumped. Its "no unkeyed digest in any row" clause still holds: the newold_hashis keyed.Verification
Derived gate families (`dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, run at d66cfb3)
60 derived, 59 run with exit 0, 1 NOT MEASURED:
pnpm check:dual-build-cjs-loadsexit 3 (prerequisite not met: needs a full build; CI runs it).node scripts/check-plugin-teardown-shape.mjs --self-testfirst exited 3 on the shallow clone; aftergit fetch origin 621a4876(its pinned fixture) it ran: exit 0, 48 cases.dispatch-gates.mjs --ran: "60 derived famil(ies) accounted for — 59 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)".test:repowas not run locally (the diff touches no repo-project test); CI Test Core runs it.Human Minimum
packages/specholds no runtime logic. (Nopackages/specchange.)pnpm lintpasses. (Scoped to the touched package.)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01FEkQ5nYPKQq7oxF4u1xFKB