Commit 222ecc2
feat(spec): ICryptoProvider gains a required keyedDigest member; LocalCryptoProvider implements it (#21292)
Fixes #21263
Clause-②: yes (narrowing)
`ICryptoProvider` gains one required member, `keyedDigest(plain:
string)`, which resolves to a string. `LocalCryptoProvider` implements
it. This is the contract half of the maintainer's option B ruling on
#21207. Routing the provider to the exits that serve a content hash is
#21207's exit two, and #21207 is not touched here.
**Cross-lane surface (`domain:services`, declared in the claim):**
`packages/services/service-settings/src/local-crypto-provider.ts` and
its test file.
## The contract (`packages/spec/src/contracts/crypto-provider.ts`)
The new docblock states three requirements on every implementation:
1. **Keyed.** The output MUST NOT be computable from the input without
the provider's key. A provider that holds no key material MUST reject.
It never resolves to an unkeyed value: not a plain hash, and not a MAC
under an empty or publicly known key.
2. **Stable per key.** Under one key, equal input gives equal output in
every process and on every node that holds the key.
3. **Not a substitute for `digest`.** `digest` keeps its own contract
and the stability the audit trail relies on.
`digest`'s docblock gains one paragraph. It says `digest` is not keyed
by contract (plain SHA-256 satisfies it) and points to the new member.
`digest`'s behaviour and wording are otherwise unchanged.
### Choices the card left open
- **Name: `keyedDigest`.** It sits beside `digest` and puts the property
that differs ("keyed") into every call site, so a reader choosing
between the two sees the difference in the name.
- **Asynchronous.** A managed-custody provider computes the MAC inside
its KMS, where the key never leaves. A synchronous signature would force
such a provider to hold the key in process.
- **Output: `hmac-sha256:` followed by 64 lowercase hex characters.**
That is 76 characters drawn from `[0-9a-z:-]`.
- The served version token is documented as opaque
(`SaveMetaItemResponse.version`: "echo it verbatim, never parse it").
- It travels as an HTTP header value, as a query-string value and inside
JSON. Inbound, it is validated only as a string.
- This shape passes all three carriers unescaped.
- The prefix keeps it disjoint from the `sha256:` spelling of the
unkeyed content hash, so a value that was served unkeyed is recognisable
by its prefix alone.
- Hex matches the existing `digest` spelling.
- The shape is fixed in the contract rather than left to each provider,
so a conformance pin can check any provider.
## Key material (`LocalCryptoProvider`)
**Sources, measured at `4bf4e7e70a`.** `resolveDataKey` resolves exactly
one 32-byte data key, from the first of these that applies:
1. an explicit `opts.key`;
2. `OS_SECRET_KEY`;
3. `OS_DEV_CRYPTO_KEY`, or its legacy alias;
4. the persisted key file (read in production; created in development,
or in production only under `OS_CRYPTO_AUTOKEY`);
5. an ephemeral key (always in test mode, and in development only as a
loudly warned last resort).
`keyedDigest` uses whichever key resolved. There is no new secret and no
new environment variable.
**Derived, not reused.** The MAC key is `HMAC-SHA-256(dataKey,
"objectstack/crypto-provider/keyed-digest/v1" || 0x01)`. That is RFC
5869 HKDF-Expand for one block, with the data key as the pseudorandom
key (§3.3 allows skipping Extract when the key is already uniformly
random). The argument, from the docblock written here:
- **One key, one purpose.** The AES-GCM key never becomes a MAC key.
- **Exposure.** `keyedDigest` output is handed to callers. The key
behind it should not be the key that protects every stored secret.
- **Versioned label.** Any future change of construction must take a new
label, so it is deliberate. A pinned test vector makes a drift visible.
Only `createHmac` is used, not `hkdfSync`. The WebContainer path, which
already cannot run AES-GCM through `node:crypto`, is therefore not
handed a second primitive it may lack (not measured there).
**What "no key material" means for this provider, measured.**
- Every environment and file source is length-checked: `parseKey`
accepts exactly 32 bytes.
- In production, construction refuses when no stable source exists.
- The one unchecked route is an explicit `opts.key`. An empty buffer, or
a 16-byte one, constructs today.
- Such an instance now rejects `keyedDigest` with
`KeyedDigestKeyUnavailableError`. It never returns an HMAC under an
empty key, which anyone can compute. This is pinned.
## Census: every in-repo implementation and test double of
`ICryptoProvider`
Measured at `4bf4e7e70a` across `packages/**`, `examples/**` and
`apps/**` with three searches:
- the identifier `ICryptoProvider`;
- files defining `rotateKey`;
- every `setCryptoProvider(`, `cryptoProvider:` and `new
LocalCryptoProvider` site.
`examples/**` and `apps/**` have zero hits.
| Site | Shape | Breaks at type level? | Action |
|---|---|---|---|
| `packages/services/service-settings/src/local-crypto-provider.ts`
(`LocalCryptoProvider`, alias `InMemoryCryptoProvider`) | class
implementing the interface | yes | implements the member |
| `packages/objectql/src/secret-fields.test.ts` (`makeFakeCrypto`) |
literal typed `ICryptoProvider` | yes (compiled by
`check:test-typecheck`) | member added |
| `packages/plugins/plugin-auth/src/sso-client-secret-at-rest.test.ts`
(`makeFakeCrypto`) | function returning `ICryptoProvider` | yes
(compiled by `check:test-typecheck`) | member added |
| `packages/plugins/plugin-webhooks/src/webhook-headers-gate.test.ts`
(`makeFakeCrypto`) | function returning `ICryptoProvider` | yes | member
added |
| `packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts`
(`makeFakeCrypto`) | literal typed `ICryptoProvider` | yes | member
added |
|
`packages/services/service-datasource/src/__tests__/datasource-secret-binder.test.ts`
(`fakeCrypto`) | function returning `ICryptoProvider` | yes
(reverse-verified below) | member added |
| `packages/plugins/plugin-audit/src/audit-bound-previous.test.ts`,
`audit-milestone-summary.test.ts` | literal typed `any` | no | none: no
type break, and no path these tests drive calls the member |
| `packages/plugins/plugin-security/src/insert-check-post-image.test.ts`
| untyped literal passed `as never` | no | none, same reason |
|
`packages/objectql/src/engine-privileged-read-ambient-transaction.test.ts`
| inline literal cast `as any` | no | none, same reason |
| `LocalCryptoProvider` instances in `packages/cli`, `packages/verify`
and the service-settings tests | class instances | no | inherit the
member |
The five added test-double members are deterministic stand-ins, in the
same style as each file's `digest` stand-in. None imitates the keyed
prefix.
Not part of this interface: service-settings' `CryptoAdapter` and its
doubles. That is a different interface, and it is untouched.
## Semver level (landing precondition ②)
The level is **`minor`** for `@objectstack/spec` and
`@objectstack/service-settings`. The changeset also carries a
**BREAKING** banner for implementers and an ADR-0087 disposition.
- A required member breaks every implementation that lacks it at compile
time. The header of `scripts/check-changeset-no-major.mjs` lists "a
required member on a published interface" among the changes that grade
`major` once GA ends the launch window.
- The repository is pre-GA: there is no `.changeset/pre.json`, and the
lockstep group is at 17.5.0. ADR-0087's amendment of 2026-09-13 (the
level half) says a pre-GA break ships `minor`, with the BREAKING banner
and its disposition as the carriers.
- Disposition: `not-required (no-migration-prescription)`. The interface
has no metadata surface, so `objectstack migrate meta` has nothing to
rewrite. `runtime-interface-only` and `type-surface-only` are both
closed to a symbol declared under `packages/spec/src/contracts/`
(ADR-0087 D7 step 2; D8 predicates 2 and 3).
- `service-settings` widens its public class with one method, which also
grades `minor`.
- Gate readings at `574553aad4`:
- `check-changeset-no-major`: exit 0. Its level axis, driven offline by
an event whose body carries `Clause-②: yes`, is green.
- `check-adr-0087-registration`: exit 0, with 1 declared-breaking
changeset carrying its disposition.
- **The arm.** The dev copied the claim's bare `yes` onto line 2.
Measured: the member narrows the set of objects that satisfy the
interface, so under the closed arm pair the exact spelling would be
`Clause-②: yes (narrowing)`. No gate verdict differs between the two
spellings here. The BREAKING banner already declares the break to the
ADR-0087 gate, and both spellings require at least `minor`. The seat has
since set the arm to `Clause-②: yes (narrowing)` here and in its claim
revision, per the at-tier record `5944464743` ②. The changeset line
stays bare, because no push follows and the banner already carries the
break.
## Out-of-repo providers (landing precondition ①)
**Met** (seat note). The dev could not read the cloud repository. The
director seat's census `5945420994` on #21263 read cloud, objectui and
hotcrm, and found zero out-of-repo `ICryptoProvider` implementations.
Every cloud host constructs the framework's `LocalCryptoProvider`
through a `link:` dependency, so no cloud card is owed. The maintainer's
ruling `5945612493` (A) lands this PR alone.
## A recorded decision this PR meets: ADR-0128 §4
ADR-0128 (Accepted) defers a producer-discriminated AAD on
`CryptoContext` (D1). Its §4 lists the triggers that fund it, and one
trigger reads: "`ICryptoProvider` is opened for another breaking change.
... A queued breaking change to this interface should pull D1 in with
it."
This PR is such a breaking change. D1 is **not** bundled here. Per that
same §4, D1 needs a versioned handle and an at-rest rewrap migration,
which is outside this card's surface and outside the dispatch's release
constraint. Whether to land this alone and schedule D1, or to hold this
PR for D1, is raised to the seat as a landing question. It is not
decided here.
## Tests
Each cell names the commit it was read at. After `a71560b345`, only the
changeset and `local-crypto-provider.ts` moved, so readings of other
packages at that commit still describe the final head. Each suite ran
with `--filter` naming that package alone. Each package's dependency
closure was built first, with the suffix (upstream) form `'NAME^...'`.
No downstream (prefix-form) consumer sweep was run: the published face
that moved is one interface member, and the census above enumerates
every in-repo implementer and double. Each package carrying one is in
this table.
| Package | `test` | `typecheck` |
|---|---|---|
| `@objectstack/spec` | `574553aad4`: 597 files, 17474 passed, 1 todo |
`a71560b345`: exit 0 (test layer at its ledger: 52 files, 246 errors,
135 pinned signatures) |
| `@objectstack/service-settings` | `574553aad4`: 33 files, 591 passed |
`574553aad4` tree: exit 0 |
| `@objectstack/objectql` | `aedbc1bf9b`: 360 files, 7082 passed |
`a71560b345`: exit 0 (test layer at its ledger) |
| `@objectstack/plugin-auth` | `a71560b345`: 116 files, 2484 passed |
`a71560b345`: exit 0 (test layer at its ledger) |
| `@objectstack/plugin-webhooks` | `a71560b345`: 13 files, 160 passed |
`a71560b345`: exit 0 |
| `@objectstack/service-datasource` | `a71560b345`: 35 files, 707 passed
| `a71560b345`: exit 0 |
| `@objectstack/plugin-security`, `@objectstack/plugin-audit` | not run
(no file changed) | `a71560b345`: exit 0, confirming their `any`/`never`
doubles need nothing |
**New pins** (`local-crypto-provider.test.ts`, 7 cases):
- the output differs from the unkeyed SHA-256 of the same input, and
from `digest`;
- it is equal for equal input under one key, within an instance, across
instances, and across the hex and base64 spellings of one key;
- it differs under a different key;
- the MAC key is derived, not the data key itself;
- a pinned vector computed outside the implementation (Python `hmac`);
- every key source yields the same digest for the same key bytes;
- an instance without usable key material rejects with the typed
refusal. The positive control takes the same construction route.
**Ablations** (one-shot, through `scripts/ablation-replace.mjs`: the
anchor hit once, the blob changed, and the restore was proven blob-equal
to HEAD with an empty `git diff HEAD`; nothing kept in tree):
| Mutation | Pins that went red |
|---|---|
| keyed output replaced by an unkeyed SHA-256 | 3 red, 21 green:
keyed-vs-unkeyed, different-key, pinned vector |
| refusal replaced by an HMAC under the raw key | 1 red: the
no-key-material refusal |
| MAC key replaced by the data key itself | 2 red: derived-key, pinned
vector |
**Reverse verification** (proves the typecheck read the rebuilt
declarations): deleting the member from the service-datasource double
turned `typecheck` red with `TS2741: Property 'keyedDigest' is missing
... but required in type 'ICryptoProvider'`. The file was restored
blob-equal to HEAD.
**Gates.** The derived union was re-derived on the final head with
`scripts/pm/dispatch-gates.mjs` (no paths) and run at `574553aad4`: 90
of 90 exit 0, with each exit code captured before any pipe. `--ran`
reconciliation reads "90 run, 0 NOT-MEASURED (a DERIVED zero)". `pnpm
--filter @objectstack/spec check:generated` also ran: all 15 generated
artifacts are up to date.
- One gate went red on the first run and was fixed in code:
`check-tenant-audit-census` could not type the second link of a chained
`update` call in the MAC-key derivation. The derivation now uses one
`update` over a prepared buffer. The bytes are the same, and the pinned
vector still holds.
- Three gates refused on the first run for prerequisites
(`check-engine-split-ratio` on the shallow clone;
`check:dual-build-cjs-loads` and `check:i18n` on missing builds). All
three measured exit 0 in the final run after history was deepened and
the builds existed.
## Acceptance notes
- `KeyedDigestKeyUnavailableError` is exported from
`local-crypto-provider.ts` but not from the package index. Nothing
outside the package distinguishes it today. A caller that serves the
digest should treat any rejection as "serve nothing".
- An explicit `opts.key` is still not length-checked at construction
(this predates this PR). A wrong-length key constructs and fails only
when used: `encrypt` throws Node's invalid-key-length error, and
`keyedDigest` now rejects with the typed refusal. It is reachable only
by embedders and tests, because every environment and file source is
checked. Noted, not filed. Carrier: none.
- `createHmac` availability on WebContainer is not measured.
- `origin/main` advanced 5 commits past this branch's base. None of them
touches a file in this diff or any generated artifact, so the branch was
not merged; CI's merge ref covers the rest.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent bada8d3 commit 222ecc2
9 files changed
Lines changed: 270 additions & 3 deletions
File tree
- .changeset
- packages
- objectql/src
- plugins
- plugin-auth/src
- plugin-webhooks/src
- services
- service-datasource/src/__tests__
- service-settings/src
- spec/src/contracts
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
136 | 136 | | |
137 | 137 | | |
138 | 138 | | |
| 139 | + | |
139 | 140 | | |
140 | 141 | | |
141 | 142 | | |
| |||
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
| 103 | + | |
103 | 104 | | |
104 | 105 | | |
105 | 106 | | |
| |||
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
143 | 143 | | |
144 | 144 | | |
145 | 145 | | |
| 146 | + | |
146 | 147 | | |
147 | 148 | | |
148 | 149 | | |
| |||
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
170 | 170 | | |
171 | 171 | | |
172 | 172 | | |
| 173 | + | |
173 | 174 | | |
174 | 175 | | |
175 | 176 | | |
| |||
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
| 38 | + | |
38 | 39 | | |
39 | 40 | | |
40 | 41 | | |
| |||
Lines changed: 114 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| 11 | + | |
11 | 12 | | |
12 | 13 | | |
13 | 14 | | |
| |||
154 | 155 | | |
155 | 156 | | |
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 | + | |
157 | 269 | | |
158 | 270 | | |
159 | 271 | | |
| |||
Lines changed: 76 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| |||
74 | 74 | | |
75 | 75 | | |
76 | 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 | + | |
77 | 103 | | |
78 | 104 | | |
79 | 105 | | |
| |||
94 | 120 | | |
95 | 121 | | |
96 | 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 | + | |
97 | 157 | | |
98 | 158 | | |
99 | 159 | | |
| |||
423 | 483 | | |
424 | 484 | | |
425 | 485 | | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
426 | 492 | | |
427 | 493 | | |
428 | 494 | | |
| |||
431 | 497 | | |
432 | 498 | | |
433 | 499 | | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
434 | 504 | | |
435 | 505 | | |
436 | 506 | | |
| |||
501 | 571 | | |
502 | 572 | | |
503 | 573 | | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
504 | 579 | | |
505 | 580 | | |
506 | 581 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
157 | 157 | | |
158 | 158 | | |
159 | 159 | | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
160 | 164 | | |
161 | 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 | + | |
162 | 197 | | |
0 commit comments