Skip to content

Commit 629f48a

Browse files
committed
feat(spec,service-settings): ADR-0128 anchors, the stage-1 changeset, 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>
1 parent a2dcfd2 commit 629f48a

4 files changed

Lines changed: 76 additions & 6 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-settings': minor
4+
'@objectstack/objectql': patch
5+
'@objectstack/service-datasource': patch
6+
---
7+
8+
feat(spec): `CryptoContext` gains a required `scope` discriminant, and `LocalCryptoProvider` binds it into a delimiter-safe, versioned AAD (ADR-0128 D1–D3, #21326 stage 1)
9+
10+
Clause-②: yes
11+
12+
**BREAKING** for `ICryptoProvider` implementers and for every direct caller of
13+
`encrypt`, `decrypt` or `rotateKey`: `CryptoContext.scope` is required, so a
14+
context literal without it stops compiling (`TS2741`), and the compiler names the
15+
missing member. `LocalCryptoProvider` also refuses such a context at runtime with
16+
`CryptoContextScopeError`, for a caller the compiler never saw. Code that only
17+
injects a provider is unaffected.
18+
19+
`scope` is a member of the new closed set `CRYPTO_CONTEXT_SCOPES` (type
20+
`CryptoContextScope`), one member per producer of `CryptoContext`:
21+
`settings` (`SettingsService`), `object_secret_field` (the ObjectQL engine's
22+
secret-field path) and `datasource_credential` (the datasource secret binder).
23+
Each producer in this release passes its own member on every call. A new producer
24+
adds its own member; it never borrows an existing one.
25+
26+
What the contract now requires of every provider that binds AAD:
27+
28+
- **Producer-discriminated (D1).** The AAD binds `(scope, namespace, key)`, so a
29+
ciphertext sealed by one producer does not authenticate under another
30+
producer's context, whatever the two `(namespace, key)` pairs are.
31+
- **Delimiter-safe (D2).** Distinct triples produce distinct AAD bytes. An
32+
unescaped join is not permitted.
33+
- **Versioned.** A ciphertext records which AAD derivation sealed it, and is
34+
opened only with that derivation. An unknown derivation fails closed. No second
35+
derivation or scope is ever tried after a failure (D3).
36+
37+
`LocalCryptoProvider` seals every new value under derivation version 2: a lead
38+
byte that never occurs in UTF-8, a versioned label, then the scope, namespace and
39+
key, each prefixed with its 4-byte length. The ciphertext carries a `v2:` marker.
40+
A ciphertext with no marker is version 1, the bare base64 every earlier release
41+
sealed, and it still opens with the older `(namespace, key)` binding. Existing
42+
secrets therefore keep working with no action, and carry the older binding until
43+
they are re-wrapped. Re-wrapping existing ciphertext at rest is stage 2 of
44+
#21326. `rotateKey` already re-seals a version-1 handle under version 2. Any other
45+
marker is refused with `UnknownCiphertextVersionError`.
46+
47+
Operational note: a secret set or rotated by this release carries the `v2:`
48+
marker, and an earlier release cannot open it. A rollback past this release needs
49+
those values to be set again.
50+
51+
`@objectstack/objectql` and `@objectstack/service-datasource` pass their own
52+
scope on every seal and open. Their public surface is unchanged.
53+
54+
<!-- adr-0087: not-required (no-migration-prescription) `CryptoContext` and `ICryptoProvider` are TypeScript contracts with no metadata surface: no Zod schema, no authorable key, no export renamed or removed, and no stored row changes shape (`sys_secret.ciphertext` is provider-defined, and every existing ciphertext still opens), so `objectstack migrate meta` has nothing to rewrite. The affected party is a provider implementer or a direct caller, and the compiler names the missing member at their call site. -->

‎packages/spec/src/contracts/crypto-provider.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,14 @@ export type CryptoContextScope = (typeof CRYPTO_CONTEXT_SCOPES)[number];
143143
* or a canonical structured encoding. ⛔ Never an unescaped join:
144144
* neither a settings specifier key nor a caller-supplied datasource
145145
* namespace is barred from containing any separator.
146-
* 3. **Record its derivation in what it seals** (D3). A provider whose AAD
147-
* derivation changes records, in the ciphertext it returns, which
148-
* derivation sealed it. `decrypt` and `rotateKey` open a ciphertext with
149-
* the derivation it records, and refuse — fail closed — one whose
150-
* derivation they do not know. ⛔ Never try a second derivation, or a
151-
* second scope, after one fails: the record decides, nothing is guessed.
146+
* 3. **Record its derivation in what it seals** (§4's versioned handle). A
147+
* provider whose AAD derivation changes records, in the ciphertext it
148+
* returns, which derivation sealed it. `decrypt` and `rotateKey` open a
149+
* ciphertext with the derivation it records, and refuse — fail closed —
150+
* one whose derivation they do not know. ⛔ Never try a second
151+
* derivation, or a second scope, after one fails (D3: the fix lives at
152+
* the producer of the AAD, never in a fallback): the record decides,
153+
* nothing is guessed.
152154
*
153155
* ⚠️ **What it does not cover yet.** A ciphertext sealed before its
154156
* provider adopted the scope (for `LocalCryptoProvider`: every handle whose
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"file": "packages/services/service-settings/src/local-crypto-provider.ts",
3+
"adrs": [
4+
"ADR-0128"
5+
],
6+
"invariant": "Every seal is AAD derivation version 2: a 0xFF lead byte, a versioned label, then scope, namespace and key each length-prefixed, marked `v2:` on the ciphertext. A ciphertext with no marker is version 1 (the bare base64 every earlier release sealed), opened read-only with the old namespace-and-key derivation; any other marker is refused, fail closed. `decrypt` dispatches on the recorded marker and never tries a second derivation; a context without a member of the closed scope set is refused, never defaulted (ADR-0128 D1-D3)."
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"file": "packages/spec/src/contracts/crypto-provider.ts",
3+
"adrs": [
4+
"ADR-0128"
5+
],
6+
"invariant": "`CryptoContext.scope` is REQUIRED and drawn from the closed `CRYPTO_CONTEXT_SCOPES` set, one member per producer of `CryptoContext`; a new producer adds its own member and never borrows one. Every provider that binds AAD folds the scope in, encodes the triple delimiter-safely (never an unescaped join), records in what it seals which derivation sealed it, and opens only with the recorded derivation — an unknown one fails closed, and no second derivation or scope is ever tried (ADR-0128 D1-D3)."
7+
}

0 commit comments

Comments
 (0)