Repository navigation
Commit 0557c2f
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)
Part of #21326
Clause-②: yes (widening)
Stage 2 of the staged card: the at-rest re-wrap of version-1
`sys_secret` ciphertext, ADR-0128 §4.2, through `rotateKey`. 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 beside `os 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 `--apply`
writes. The other flags are `--declared-datasources` /
`--no-declared-datasources`, `-y, --yes`, `--json` and `--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).
- **`ciphertextDerivationStatus`** on `@objectstack/service-settings`:
`LocalCryptoProvider`'s own reading of which AAD derivation sealed a
stored ciphertext (`current`, `superseded` or `unknown`). It is read off
the marker without opening anything, and it is the same `readCiphertext`
that `decrypt` dispatches on. The re-wrap classifies rows with it, so
the CLI never restates the marker grammar.
- `packages/cli/src/utils/sys-secret-rewrap.ts` holds the planner and
executor (pure planning, injected ports), with its pins, the command's
guards test and its concrete-driver contract test.
- The CLI reference page `content/docs/deployment/cli.mdx` gains `os
secret rewrap` next to `os secret orphans`. The page is hand-written,
not generated.
- The new command is registered in both `bootSchemaStack` family
ledgers: `schema-migrate.one-shot-family.integration.test.ts` and
`json-stdout-purity.e2e.test.ts`.
- One changeset (cli and service-settings at `minor`) and one ADR-0128
anchor for the planner.
## A1: where version-1 ciphertext is stored (census at `1fd56645af`)
- **`sys_secret.ciphertext` is 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's `bind()`. There
are no other non-test `encrypt` call sites. Version 1 means no `:`
marker.
- **`sys_setting.value_enc` inline values are not provider-sealed.**
They are sealed by the separate `CryptoAdapter` interface: no
`CryptoContext`, no `ICryptoProvider` AAD, and its only in-tree
implementation is the base64 `NoopCryptoAdapter`. They are not version-1
ciphertext of this provider, so `rotateKey` cannot reach them and they
are out of this class. ⛔ No second sealing path was built.
- **`sys_two_factor.backup_codes` is 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_FAMILY` is a `Record` over 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:
1. A row whose holders belong to different producers is left
(`left_conflicting_scope`).
2. While the union is incomplete, every version-1 row is left
(`left_union_incomplete`): a family that was not read may hold it too.
`--apply` then refuses and names the family.
3. A row with no holder is left (`left_orphan`).
4. Otherwise the row is attempted under its one scope. Several holders
of one scope are one attribution, and the row is re-wrapped once.
## A3: the properties, and the pin for each
| Property | How it is met | Pin |
|---|---|---|
| Resumable and idempotent | All progress lives in the rows. A row
reading `current` is skipped as done, and there is no run log. | A run
is killed at its second write. A re-run finishes the rest, and a third
run writes nothing. The real SQLite boot shows a second `--apply` is all
`done` with the table unchanged. |
| Safe against a live deployment | Each row is ONE `updateMany` keyed on
`id` AND 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 Turso `UPDATE … WHERE`, Mongo `updateMany`, memory with
no `await` between filter and write). A count of 0 is `write_conflict`,
and the row is not overwritten. A driver without `updateMany` is refused
before any row is opened. | A producer write lands between the read and
the write: `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). |
| Fails closed | A row that does not open, or that has an unknown
marker, or whose stored fields do not form a handle, is refused and not
written. The run finishes the rest and exits 1. Every write is one
statement. | A row sealed under another key: `refused_unreadable`, its
bytes unchanged, and the other rows re-wrapped. An unknown marker is
never opened. |
| Verify before write | The re-seal must keep the id, read `current`,
carry a usable version, and open under the SAME context to the SAME
plaintext, before the write. | A re-seal that opens to a different
value, and one that does not open: both `refused_verify_failed`, never
written. Positive control: the honest provider re-wraps the same row. |
| Dry run | It is the default and boots read-only. Every attributed row
is opened, re-sealed and verified in memory, so its counts are the ones
`--apply` produces. Nothing is written. | The store is unchanged, and
`updateMany` is never called. `--apply` over 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's `resolveSecret`, the binder's `resolve`, and
the settings context. No other producer's context opens it.
**The key.** The run resolves `LocalCryptoProvider` before 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 `--json` document is `{ mode, report }`, where
`report` holds:
- `mode` and `keySource` (a source name, never a value);
- `families`: per holder family, `status`, `referenceCount`, and
`reason` on a gap;
- `refusal`;
- `counts`: `total`, `rewrap`, `done`, `left`, `refused`, `notWritten`;
- `byClass`: one count for each of `rewrap`, `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 `error` key: `union_incomplete`,
`driver_cannot_compare_and_set`, `crypto_key_unavailable`,
`confirmation_required`, `declared_datasources_unreadable`,
`boot_failed`, `no_engine`, `no_sys_secret_driver` or `scan_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 --commands` derived 107 commands. Each exit
code was written to disk before it was read, and all 107 exited 0.
`--ran` reconciles: "✓ 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 no `major` bump." The level axis was driven offline, with
this body as the event payload: "✓ LEVEL AXIS: this PR declares clause-②
`yes (widening)`, and no package whose `packages/**/src/**` it moves is
graded `patch`." (re-run at `219457c029`, 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`: `test` passed 33 files / 605 tests,
and `typecheck` exited 0.
- `@objectstack/cli` `typecheck` (`tsc --noEmit` plus
`check:test-typecheck`): exit 0.
- `@objectstack/cli` unit tier: 246 of 248 files passed on the first
run. The other two, the `published-subpath-*.pin` files, refused on a
missing cli `dist/` (a prerequisite, not a verdict). Once the cli was
built, both passed (29 / 29).
- `@objectstack/cli` integration 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.
- The one-shot family pin, filtered to the new member and the family
table: the dry run leaves the database byte-identical, no missing SQLite
file is created, `--apply` runs 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 at
`6a7c27c2f2`: stdout is one JSON document (the `scan_failed` refusal),
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:
- The population, read from `eslint.config.mjs`, is
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`.
- `eslint --no-inline-config --format json` over the 10 changed TS files
reported 10 files, 0 errors and 0 warnings.
- The config never enables type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
untouched file.
## Ablations
Both legs ran from the committed state `6a7c27c2f2`, through
`scripts/ablation-replace.mjs` in 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.
1. **The scope-attribution refusal removed.** The no-holder branch of
`attributeRewrapScope` returned a guessed `settings` scope instead of
`left_orphan`.
- On disk: anchor x1 → x0, replacement x0 → x1, blob `75d607ac5dc0` →
`3c509ef4edb1`.
- Result: exit 1, "6 failed | 8 passed (14)". The orphan pin failed at
its `left_orphan` count ("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.
- Restore: blob == HEAD (`75d607ac5dc0`), and `git diff HEAD` is empty.
2. **Verify-before-write removed.** The `resealHolds` check (a 3-line
anchor) was deleted.
- On disk: blob `75d607ac5dc0` → `4fd879b7d1a1`.
- Result: exit 1, "1 failed | 13 passed (14)". Exactly the verify pin
failed, at `refused_verify_failed` ("expected +0 to be 1").
- Restore: proven the same way.
There is no build leg. The pins import the subject as
`./sys-secret-rewrap.js` (relative source), so no `dist/` is on the
resolution path. After both legs, the union above is green at
`6a7c27c2f2`.
## Acceptance notes
- **The rows the re-wrap leaves keep the older binding.** These are
orphans, rows with conflicting holders, and every row while a family is
a gap. The later stage that retires version-1 opening owes a decision
about them, because they would stop opening. The re-wrap counts them by
class, so that stage starts from a measured number. → noted for the next
stage of #21326, not filed.
- **Pre-existing: `os secret orphans` may 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 (承接者:无).
- ADR-0128's dated implementation note (Tier H) stays with its separate
card. #21326 remains open.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0b82391 commit 0557c2f
13 files changed
Lines changed: 2298 additions & 1 deletion
File tree
- .changeset
- content/docs/deployment
- packages
- cli
- src
- commands/secret
- utils
- test
- services/service-settings/src
- scripts/adr-anchors
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
577 | 577 | | |
578 | 578 | | |
579 | 579 | | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
580 | 628 | | |
581 | 629 | | |
582 | 630 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
0 commit comments