Repository navigation
feat(spec,service-settings): CryptoContext gains a required scope discriminant, bound into a delimiter-safe, versioned AAD (ADR-0128 D1-D3, stage 1) - #21453
Conversation
…ter-safe versioned AAD Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…d, versioned AAD and each producer's scope Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…scope, not a call count Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
… and the versioned-handle label on the contract The contract's third provider obligation is labelled for what it is: the versioned handle ADR-0128 section 4 calls for, with D3 (no fallback, the fix at the producer of the AAD) named on the no-second-try sentence. ADR-0128 anchors for the contract and for LocalCryptoProvider, so the scoped, length-prefixed, versioned derivation cannot be "simplified" back into a join without the gate naming the record. One changeset: spec and service-settings minor with the BREAKING banner (the keyedDigest precedent's level and header), objectql and service-datasource patch (each passes its own scope; no public surface moves). Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…TEXT_SCOPES and CryptoContextScope The two contract exports ADR-0128 D1 adds. check:generated named exactly these two stale artifacts (+2 / -0 each) against a dist built from this branch's source, and reads all 15 up to date after the two generators ran. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 141 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 60e941af91b824a832275af53e3c9e06275172d7 && git checkout 60e941af91b824a832275af53e3c9e06275172d7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 086ad0aa684f73d9703fa3dadd501f23d08533c6 0b398e88997002982d665248001adf697a7bfeee && git checkout -B drift-repro 086ad0aa684f73d9703fa3dadd501f23d08533c6 && git merge --no-ff 0b398e88997002982d665248001adf697a7bfeee
node scripts/docs-audit/affected-docs.mjs --json 086ad0aa684f73d9703fa3dadd501f23d08533c6
|
Contract reviewServed-tier: Scope read: card #21326 (body, triage ① Derived judgmentsD1, a required and closed scope discriminant: right. D2, a delimiter-safe encoding: right. The version-2 AAD is a D3, a producer-side fix with no consumer-side fallback: right. The fix lives in The versioned handle, with the derivation in the ciphertext marker and not in The producer census, exactly three with one scope each: right. Independently measured at the head over every non-test Every accept-set and public-surface change the diff implies, each judged:
What stage 2 (the at-rest rewrap) must still carry, which stays on the card ("Part of"):
② Semver levelChangeset The ③ Boundary flagsThe dev's six deviations, each answered:
The dev's five out-of-scope findings, each answered or escalated:
Further flags from this review:
Implemented-by: VERDICT: PASS Generated by Claude Code |
Part of #21326
Clause-②: yes
Stage 1 of the staged card: ADR-0128 D1–D3 and the versioned handle. Stage 2, the at-rest re-wrap through
rotateKey(ADR-0128 §4.2), is the next claim, and #21326 remains open for it.⛔ Security family: this body names classes and positions only.
This run resumed a lost one. The container restarted after the first run had pushed three commits (
4182cad1a1,73c1a931f5,ace768e05c; 13 files, +651 / −104) and before any PR, report or gate result existed. This run re-verified every dispatch item against that diff, mergedorigin/main(c2c21f357c), and finished what was missing. Nothing measured before the restart is cited here; every reading below is from this run.node:cryptoand a hand-built AAD).api-surface/andexport-origins/entries for the two new exports, ADR-0128 anchors for the contract and the provider, the changeset, a label correction on the contract's third obligation (the versioned handle is §4's, and "no second try" is D3's), and every gate and ablation leg below.The contract (
packages/spec/src/contracts/crypto-provider.ts)CryptoContext.scopeis required. Its type isCryptoContextScope, drawn from the closed setCRYPTO_CONTEXT_SCOPES:settings,object_secret_fieldanddatasource_credential, one member per producer. A new producer adds its own member and never borrows one.scopein (D1), encode the triple delimiter-safely (D2), and record in what it seals which derivation sealed it. It opens a ciphertext only with the recorded derivation, and an unknown one fails closed. It never tries a second derivation or scope after a failure (D3).(namespace, key)space is rewritten, as ADR-0128's Consequences requires when D1 ships. What the older binding still covers (ciphertext sealed before the scope existed) is stated in its own paragraph.Producer census (measured at
c2c21f357c)Every
encrypt,decryptandrotateKeycall site outside tests, over all*.ts,*.mts,*.jsand*.mjsin the tree:SettingsServicesettings-service.ts: the seal inset(), the open inmaterialiseRow()settingsengine.ts: the seal in the secret-field write path, the open inresolveSecret()(whichresolveSecretField()calls)object_secret_fielddatasource-secret-binder.ts:bind()andresolve()datasource_credentialNo fourth producer. The plugins that store secrets (SSO client secret, webhook secret and headers, flow credentials) all reach the provider through the engine's
resolveSecretField(), so they are the second producer.rotateKeyhas no in-tree caller.crypto-adapter.tsbuilds no AAD: itsCryptoAdapteris a separate interface over{ namespace, key }for inlinesys_setting.value_enc, its only in-tree implementation is the base64NoopCryptoAdapter, and it never meets aCryptoContext.The provider (
LocalCryptoProvider,packages/services/service-settings)Version 2, every new seal. The AAD is a lead byte
0xFF, the labelobjectstack/crypto-context-aad/v2, thenscope,namespaceandkey, each prefixed with its 4-byte big-endian UTF-8 length. The ciphertext isv2:followed by the base64 blob.0xFFnever occurs in UTF-8, and a version-1 AAD is the UTF-8 of a string. So a body presented under the other version's marker never authenticates (pinned in both directions).(namespace, key)derivation, and it never seals.UnknownCiphertextVersionError.scopeis missing or outside the set is refused withCryptoContextScopeErroronencrypt,decryptandrotateKey. That includes a version-1 open, which does not bind the scope.rotateKeyopens with the recorded derivation and seals with version 2. That is the seam stage 2 uses.Why an existing handle is unambiguously version 1 (no row is read)
CryptoHandle.versioncannot carry it. It is already a rotation counter (kmsKeyIdislocal:vfollowed by it), so a rotated handle sealed before this change already reads version 2 or more. The docblock now says so.sys_secret.ciphertextandCryptoHandle.ciphertextare documented as provider-defined and round-tripped verbatim. So the marker lives there, with no schema column and no producer having to carry it.:. Every earlier seal was bare standard base64, whose alphabet has no:. This was measured over the full history of both file names the provider has had: 10 commits from4f6bae0c60(Phase 3sys_secretcrypto) to222ecc27f9. Each version's seal path readsciphertext: bloboverBuffer#toString('base64'), on both the node:crypto path and the WebContainer path.:means version 1,v2:means version 2, and anything else is refused.Pins
local-crypto-provider.test.ts(ADR-0128 block):v2:;rotateKeylifts version 1 to version 2;settings-service.test.tsreads every call's scope and checks that no other scope opens the stored ciphertext;secret-fields.test.ts(objectql) pinsobject_secret_fieldon the seal and on both opens;datasource-secret-binder.test.tspinsdatasource_credentialon bind and resolve, and refuses a row sealed under another scope at the binder's own coordinate (with a positive control).Verification (this run, at
0b398e8899; shared container, every build and test under the verify lock)turbo run buildover the dependency closures of service-settings, service-datasource and objectql (21 packages in scope, spec included): 20 of 20 tasks succeeded.@objectstack/spec(--project local): 600 files, 17684 passed, 1 todo.@objectstack/service-settings: 33 files, 603 passed.@objectstack/service-datasource: 35 files, 709 passed.@objectstack/objectql(--project local, 3 shards): 2353 + 2287 + 2739 passed, and 1 failed in shard 1. The failure was a 5-second clocked test that dynamically imports the package barrel. It timed out under shared-box load (load average about 23), timed out again when run alone at the default 5 s (5.05 s), and passed with a longer timeout (3.26 s). This diff adds no import to objectql.secret-fields.test.ts, which carries the new pin, also passed on its own.@objectstack/cli, integration tier, the two touched files: 46 passed.tsc --noEmit && check:test-typecheck) all exit 0.--listFilesconfirms that service-settings' program includes all 33 of its test files and service-datasource's all 35.check:generated(spec): all 15 artifacts are up to date afterapi-surface/andexport-origins/were regenerated.dispatch-gates --ran: 106 derived, 106 run, 0 NOT-MEASURED. Five families at first refused for a missing prerequisite: one needed deeper history, and four needed the whole-workspace dist. Each passed once its prerequisite was in place.changeset-fixed,published-list-mirrors,meta-url-spelling,spec-changes,authz-resolver,console-injection,error-code-casing,filter-alias-parity,i18n-stale-fill,published-readme-exports, and thedts-referencesself-test.Clause-②: yes, it passes, grading@objectstack/specand@objectstack/service-settingsat minor.D2 ablation (both legs from the committed state)
scripts/ablation-replace.mjs. The length-prefixed body ofaadForVersion2is replaced by an unescaped join of the same three components. The scope stays in, so only D2 is ablated. The mutation landed: the anchor count went from 1 to 0, the replacement count from 0 to 1, and the blob from77423577310bto0a2fa7a2a5d6.77423577310b, equal to HEAD's, andgit diff HEADis empty.dist/lies on its resolution path.Release
.changeset/21326-crypto-context-scope-discriminant.md):@objectstack/specminor and@objectstack/service-settingsminor with the BREAKING banner, the level and header of thekeyedDigestprecedent (PR feat(spec): ICryptoProvider gains a required keyedDigest member; LocalCryptoProvider implements it #21292).@objectstack/objectqland@objectstack/service-datasourceare patch: each passes its own scope, and no public surface moves.not-required (no-migration-prescription), as in PR feat(spec): ICryptoProvider gains a required keyedDigest member; LocalCryptoProvider implements it #21292.v2:, which an earlier release cannot open. A rollback past this release needs those values to be set again.Acceptance notes
(namespace, key)binding, and the contract states that. Whether version-1 opening is retired once the re-wrap completes belongs with stage 2 (carrier: the next claim on spec+service-settings: build ADR-0128 D1–D3, a producer-discriminated, delimiter-safe AAD on CryptoContext, with a versioned handle and an at-rest rewrap #21326).error.code: the settings read path logs and returns null, the binder returns undefined, and the engine throws to its privileged in-process callers. So they register no ledger code, the same asKeyedDigestKeyUnavailableErrorin PR feat(spec): ICryptoProvider gains a required keyedDigest member; LocalCryptoProvider implements it #21292. If contract review reads that ADR line as requiring a ledger code whatever the reachability, that is a one-row follow-up.docs/adr/**edit). A separate Tier H card carries it.system-write-organization.test.ts's barrel test does a dynamic import of the whole objectql barrel inside its 5-second window, so on a loaded box it times out while its assertion holds. Noted, not filed (carrier: none).ICryptoProvidergains a required keyed digest — the server-held-key HMAC that #21207's served content hash is ruled onto (option B) #21263 (5945420994) measured zero out-of-repo implementers. A host that callsencrypt,decryptorrotateKeyitself gets the compile error the changeset describes.Generated by Claude Code