feat(cli,service-settings): os secret rewrap, the at-rest re-wrap of version-1 sys_secret ciphertext under each holder's producer scope (ADR-0128 §4.2, stage 2) - #21469
Conversation
…der's own reading of a stored ciphertext's AAD derivation (ADR-0128 §4.2) The at-rest re-wrap must tell a version-1 row from one already sealed under the current derivation without opening it, and must not restate the marker grammar that decrypt dispatches on. The reading is the provider's own readCiphertext, published from the package root. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…ret ciphertext (ADR-0128 §4.2) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…fail-closed and verify-before-write (ADR-0128 §4.2) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…ined in the re-wrap pins Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…ADR-0128 §4.2) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
… its key before the boot The settings service registers sys_setting, the settings family's holder, so the re-wrap boots it as os secret orphans does. That service's own provider may mint a key in a development posture, so the re-wrap resolves its provider first, in the strict posture that never mints. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…wn provider, so no provider in the run mints a key; join the bootSchemaStack families Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…for the re-wrap Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 32 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 78cceb50b14774215fa5a62bec35c4079566bd4b && git checkout 78cceb50b14774215fa5a62bec35c4079566bd4b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aa4632235ba571ef800b95e6bc18d00a30aa1d57 219457c0298cbd7a8a88a42839df952da7554b67 && git checkout -B drift-repro aa4632235ba571ef800b95e6bc18d00a30aa1d57 && git merge --no-ff 219457c0298cbd7a8a88a42839df952da7554b67
node scripts/docs-audit/affected-docs.mjs --json aa4632235ba571ef800b95e6bc18d00a30aa1d57
|
Contract reviewServed-tier: Scope read: card #21326 (body and every comment: triage ① Derived judgmentsADR-0128 §4.2, resumable: right. All state is in the rows. Safe against a live deployment: right, and real on every driver the command can reach. The only write is one Fails closed: right; no partial write is possible. A row whose stored fields do not form a handle, or that does not open under its attributed context, is Verify before write: right. The dry run: right. It is the default; Per-row scope attribution (D3): right, in this order. The ciphertext census: right. Every non-test Key handling: right; the command never mints in any posture, and no key is a refusal. The provider is built with The new published export Output: right, with one class named. The report is classes and counts: Every accept-set and public-surface change the diff implies, each judged:
② Semver levelThe levels are right; the The changeset The PR body's second line and the changeset both read The gates' verdicts do not rescue it: ③ Boundary flagsThe dev's deviations, each answered:
The dev's out-of-scope findings, each answered or escalated:
Further flags from this review:
Implemented-by: VERDICT: FAIL Failed item: ② the Generated by Claude Code |
The diff grows two published surfaces: a root export on @objectstack/service-settings (ciphertextDerivationStatus and its type) and the os secret rewrap command on @objectstack/cli. The levels (minor, minor) already satisfy a widening declaration. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Scope read: a re-review of PR #21469 after record ① Derived judgmentsThe delta touches nothing ① judged, so the previous record's ① holds at this head by reference. ② Semver levelRight at this head; the one failed item of
③ Boundary flags
Implemented-by: VERDICT: PASS The one failed item of Generated by Claude Code |
Part of #21326
Clause-②: yes (widening)
Stage 2 of the staged card: the at-rest re-wrap of version-1
sys_secretciphertext, ADR-0128 §4.2, throughrotateKey. Retiring version-1 opening is a later stage and is not here; #21326 stays open for it.⛔ Security family: this body names classes and positions only.
What lands
os secret rewrap, a new subcommand besideos secret orphans, with the same boot, connection, guards and output conventions. It is a dry run by default (a read-only boot that writes nothing), and--applywrites. The other flags are--declared-datasources/--no-declared-datasources,-y, --yes,--jsonand--database-url. Nothing on any boot or upgrade path invokes it, and it has no HTTP surface, so no refusal answers a request and no ADR-0112 ledger row is owed (A8).ciphertextDerivationStatuson@objectstack/service-settings:LocalCryptoProvider's own reading of which AAD derivation sealed a stored ciphertext (current,supersededorunknown). It is read off the marker without opening anything, and it is the samereadCiphertextthatdecryptdispatches on. The re-wrap classifies rows with it, so the CLI never restates the marker grammar.packages/cli/src/utils/sys-secret-rewrap.tsholds the planner and executor (pure planning, injected ports), with its pins, the command's guards test and its concrete-driver contract test.content/docs/deployment/cli.mdxgainsos secret rewrapnext toos secret orphans. The page is hand-written, not generated.bootSchemaStackfamily ledgers:schema-migrate.one-shot-family.integration.test.tsandjson-stdout-purity.e2e.test.ts.minor) and one ADR-0128 anchor for the planner.A1: where version-1 ciphertext is stored (census at
1fd56645af)sys_secret.ciphertextis the only store of ciphertext the provider seals. All three producers write it:SettingsService.set(), the engine's secret-field path and the datasource binder'sbind(). There are no other non-testencryptcall sites. Version 1 means no:marker.sys_setting.value_encinline values are not provider-sealed. They are sealed by the separateCryptoAdapterinterface: noCryptoContext, noICryptoProviderAAD, and its only in-tree implementation is the base64NoopCryptoAdapter. They are not version-1 ciphertext of this provider, sorotateKeycannot reach them and they are out of this class. ⛔ No second sealing path was built.sys_two_factor.backup_codesis not provider-sealed either. It is sealed by better-auth's own symmetric encryption under the auth secret.A2: per-row scope attribution
rotateKey(handle, ctx)seals under the caller's scope, and a version-1 ciphertext binds no scope, so opening one proves nothing about its producer. The scope therefore comes from the holder, through the classification the orphan sweep already uses: the cross-producer reference union. Each union reference already carries its holder family, so the classifier needed no extension. The planner only groups the union's references by handle. ⛔ No second holder walk.SCOPE_OF_HOLDER_FAMILYis aRecordover the closed family set (settings →settings, object-field →object_secret_field, datasource →datasource_credential), so a fourth family stops compiling until it has a scope.The order of the decision:
left_conflicting_scope).left_union_incomplete): a family that was not read may hold it too.--applythen refuses and names the family.left_orphan).A3: the properties, and the pin for each
currentis skipped as done, and there is no run log.--applyis alldonewith the table unchanged.updateManykeyed onidAND the exact ciphertext the run read. All five drivers serve that as a single filtered statement and answer the changed count (measured in source: SQL and TursoUPDATE … WHERE, MongoupdateMany, memory with noawaitbetween filter and write). A count of 0 iswrite_conflict, and the row is not overwritten. A driver withoutupdateManyis refused before any row is opened.write_conflict, the producer's value is kept, and the other rows land. The real SQL driver: a stale ciphertext changes nothing (0), and the read one changes the row (1).refused_unreadable, its bytes unchanged, and the other rows re-wrapped. An unknown marker is never opened.current, carry a usable version, and open under the SAME context to the SAME plaintext, before the write.refused_verify_failed, never written. Positive control: the honest provider re-wraps the same row.--applyproduces. Nothing is written.updateManyis never called.--applyover the same store lands exactly the dry run's counts. The one-shot family pin shows the dry run leaves the database byte-identical, boot included.After
--apply, each re-wrapped row is opened through its producer's own read path: the engine'sresolveSecret, the binder'sresolve, and the settings context. No other producer's context opens it.The key. The run resolves
LocalCryptoProviderbefore the boot, from a key that already exists, in the strict posture with the auto-key opt-in withheld. It hands that provider to the settings service the boot composes, so no provider in the run mints a key. With no key, it hands that service one that refuses every call, and the run refuses before opening a row. Pinned with the real boot in a development posture with no key:crypto_key_unavailable, and no key file appears.A5: output shape
Classes and counts only. ⛔ No plaintext, no ciphertext, no key material, and no row id. The
--jsondocument is{ mode, report }, wherereportholds:modeandkeySource(a source name, never a value);families: per holder family,status,referenceCount, andreasonon a gap;refusal;counts:total,rewrap,done,left,refused,notWritten;byClass: one count for each ofrewrap,done,left_orphan,left_conflicting_scope,left_union_incomplete,refused_unreadable,refused_unknown_derivation,refused_verify_failed,write_conflict,write_failed;rewrapByScope;notes.Refusals are one JSON document with an
errorkey:union_incomplete,driver_cannot_compare_and_set,crypto_key_unavailable,confirmation_required,declared_datasources_unreadable,boot_failed,no_engine,no_sys_secret_driverorscan_failed. A pin asserts that the report holds none of the plaintexts, ciphertexts, row ids or holder coordinates of its fixture.A7: out of this stage, and untouched
These are untouched: version-1 opening and the contract paragraph about it,
packages/spec/src/contracts/crypto-provider.ts(the re-wrap needed no contract change),packages/objectql/src/engine.ts,docs/adr/**, and anything in cloud.Gates and tests (local, at
6a7c27c2f2)Derived gates.
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 107 commands. Each exit code was written to disk before it was read, and all 107 exited 0.--ranreconciles: "✓ dispatch-gates --ran: 107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED (a DERIVED zero — all 107 recorded an exit code and none of them is 3)."The gates named in the dispatch:
check-changeset-no-major.mjs --base origin/main: "✓ This diff introduces nomajorbump." The level axis was driven offline, with this body as the event payload: "✓ LEVEL AXIS: this PR declares clause-②yes (widening), and no package whosepackages/**/src/**it moves is gradedpatch." (re-run at219457c029, after the review's fix round)check-adr-0087-registration.mjs --base origin/main: "✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)."check-empty-changeset.mjs --base origin/main: "✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)."pnpm check:changeset-gate-self-tests: exit 0.pnpm check:doc-authoring: "✓ doc authoring guard: sibling-package prose ids hold the baseline — 204 pinned site(s) across 62 file(s) …"pnpm check:nul-bytes: "check-nul-bytes: OK (scanned 9800 text file(s) … no raw ASCII control bytes)."Other gate readings:
check:cli-command-ids: 65 command modules, all of them oclif commands.check:test-source-alias: OK, the unaliased set unchanged.check:engine-double-contract: "OK — 921 pinned".check:adr-anchors: OK, 60 anchored files.check:type-check-debt: OK.check:cross-package-test-inputs: OK.Tests:
@objectstack/service-settings:testpassed 33 files / 605 tests, andtypecheckexited 0.@objectstack/clitypecheck(tsc --noEmitpluscheck:test-typecheck): exit 0.@objectstack/cliunit tier: 246 of 248 files passed on the first run. The other two, thepublished-subpath-*.pinfiles, refused on a missing clidist/(a prerequisite, not a verdict). Once the cli was built, both passed (29 / 29).@objectstack/cliintegration tier, run on the touched files and the secret family (sys-secret-rewrap,rewrap.guards,rewrap.driver-contract,orphans.guards,orphans.driver-contract,sys-secret-orphan-sweep,secret-reference-union): 7 files / 87 tests.--applyruns no seed write, and the family table holds.json-stdout-purity.e2e(nightly tier) was not run here. Its three assertions for the new member were measured by a direct invocation at6a7c27c2f2: stdout is one JSON document (thescan_failedrefusal), no logger record is on stdout, and the three boot diagnostics are on stderr. In that run no database file was created, and the run's key home stayed empty.Lint was narrowed, and the narrowing is declared:
eslint.config.mjs, is**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}.eslint --no-inline-config --format jsonover the 10 changed TS files reported 10 files, 0 errors and 0 warnings.parserOptions.project), so this diff cannot move the verdict on any untouched file.Ablations
Both legs ran from the committed state
6a7c27c2f2, throughscripts/ablation-replace.mjsin WRAP mode, inside the verify lock. Each was restored, and the restore was proven by blob. The expected direction, recorded before running, was RED for both legs.attributeRewrapScopereturned a guessedsettingsscope instead ofleft_orphan.75d607ac5dc0→3c509ef4edb1.left_orphancount ("expected +0 to be 1"). The count pins that include the orphan failed with it: the full apply, resumable, live-safe, fail-closed and report pins.75d607ac5dc0), andgit diff HEADis empty.resealHoldscheck (a 3-line anchor) was deleted.75d607ac5dc0→4fd879b7d1a1.refused_verify_failed("expected +0 to be 1").There is no build leg. The pins import the subject as
./sys-secret-rewrap.js(relative source), so nodist/is on the resolution path. After both legs, the union above is green at6a7c27c2f2.Acceptance notes
os secret orphansmay mint a key file. It composes the settings service with that service's default provider, and in a development posture with no key, that boot mints a key file in the key home. This was measured with this command before it handed the settings service its own provider: a key file appeared in this container's key home during that probe. The report says it writes nothing, which is true of the database. → noted, not filed (承接者:无).Generated by Claude Code