From c2445424434f0de747ae3a637c28aaee0814874d Mon Sep 17 00:00:00 2001 From: os-sales Date: Fri, 28 Aug 2026 02:13:46 +0000 Subject: [PATCH] docs(spec,service-datasource): name the three CryptoContext producer vocabularies and narrow the AAD guarantee (#12599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CryptoContext.namespace` / `.key` documented themselves as a settings coordinate, one layer below the three producers that construct the type: SettingsService (settings namespace / specifier key), the ObjectQL engine's secret-field path (object name / field name) and the datasource secret binder (caller-supplied namespace, default `datasource` / datasource name). The prose now names all three, and `CryptoHandle.id`, `encrypt()` and `rotateKey()` stop describing `sys_setting.value_enc` as the general destination of a handle. The load-bearing correction is the AAD sentence, which claimed a binding over this pair "Helps reject ciphertexts that were copied across namespaces". The pair is one flat space shared by all three vocabularies, `sys_secret` declares it non-unique by design, and nothing reserves a name in one vocabulary against another — so the sentence is replaced by the guarantee that actually holds: such a binding rejects a swap WITHIN one producer's vocabulary and does NOT exclude a cross-vocabulary pair. The docblock records this as the contract's present state and names the intended end state so the weak guard is not read as the designed one. The identical settings-only prose on `DatasourceSecretBinderDeps.namespace` is corrected in the same change. Documentation only: no behavior change, no schema shape change — `gen:schema`, `gen:openapi` and `gen:docs` all regenerate byte-identically over this diff. Recorded under the maintainer ruling of 2026-08-27 on #12599. Co-authored-by: Claude --- .../crypto-context-producer-vocabularies.md | 45 +++++++++ .../src/datasource-secret-binder.ts | 9 +- .../spec/src/contracts/crypto-provider.ts | 97 +++++++++++++++---- 3 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 .changeset/crypto-context-producer-vocabularies.md diff --git a/.changeset/crypto-context-producer-vocabularies.md b/.changeset/crypto-context-producer-vocabularies.md new file mode 100644 index 0000000000..82d89205bb --- /dev/null +++ b/.changeset/crypto-context-producer-vocabularies.md @@ -0,0 +1,45 @@ +--- +"@objectstack/spec": patch +"@objectstack/service-datasource": patch +--- + +docs(spec,service-datasource): `CryptoContext` documents the three producer vocabularies, and the AAD sentence narrows to the guarantee that holds (#12599) + +Documentation-only correction to the `ICryptoProvider` contract. No behavior +changes and no schema shape changes: `gen:schema`, `gen:openapi` and `gen:docs` +all regenerate byte-identically over this diff. It ships because the corrected +TSDoc is emitted into the published `.d.ts`, so it is what every consumer of +`@objectstack/spec` reads at the call site. + +`CryptoContext.namespace` / `.key` documented themselves as a settings +coordinate ("Settings namespace the value belongs to" / "Specifier key within +the namespace"), one layer below the three producers that actually construct +the type: + +| producer | `ctx.namespace` | `ctx.key` | where `handle.id` is recorded | +|---|---|---|---| +| `SettingsService` | settings namespace | specifier key | `sys_setting.value_enc` | +| the ObjectQL engine's secret-field path | **object name** | **field name** | a `secret:` ref on the business row | +| the datasource secret binder | caller-supplied, default `datasource` | datasource name | a `sys_secret:` credentialsRef | + +The prose now names all three, and `CryptoHandle.id`, `encrypt()` and +`rotateKey()` no longer describe `sys_setting.value_enc` as the general +destination of a handle. + +The load-bearing half is the AAD sentence. It read "Helps reject ciphertexts +that were copied across namespaces", which overstates what a binding over this +pair can provide: `(namespace, key)` is **one flat space shared by all three +vocabularies**, `sys_secret` declares the pair non-unique by design, and nothing +reserves a name in one vocabulary against another. The sentence is replaced by +the guarantee that actually holds — such a binding rejects a ciphertext swapped +between two coordinates *within one producer's vocabulary*, and does **not** +exclude a cross-vocabulary pair. The docblock records this as the contract's +present state and names the intended end state (a producer-discriminated AAD) +so the weak guard is not read as the designed one. + +The same settings-only prose on `DatasourceSecretBinderDeps.namespace` +("Settings namespace recorded on the secret row") is corrected in the same +change. + +Recorded under the maintainer ruling of 2026-08-27 on #12599 (Option A now, +with the producer-discriminated AAD recorded as direction in its own ADR). diff --git a/packages/services/service-datasource/src/datasource-secret-binder.ts b/packages/services/service-datasource/src/datasource-secret-binder.ts index 695e4efc88..acf6c99dfc 100644 --- a/packages/services/service-datasource/src/datasource-secret-binder.ts +++ b/packages/services/service-datasource/src/datasource-secret-binder.ts @@ -48,7 +48,14 @@ export interface DatasourceSecretBinderDeps { engine: SecretStoreEngineLike; /** Crypto provider that wraps cleartext into a {@link CryptoHandle}. */ cryptoProvider: ICryptoProvider; - /** Settings namespace recorded on the secret row (default `'datasource'`). */ + /** + * Namespace recorded on the secret row and used as the `CryptoContext` + * namespace (default `'datasource'`). This is the datasource producer's + * own vocabulary, **not** a settings namespace — the three producers of + * `sys_secret` rows share one flat `(namespace, key)` space and the pair + * does not attribute a row to a producer. See `CryptoContext` in + * `@objectstack/spec`. + */ namespace?: string; } diff --git a/packages/spec/src/contracts/crypto-provider.ts b/packages/spec/src/contracts/crypto-provider.ts index 757423bfe1..8568335809 100644 --- a/packages/spec/src/contracts/crypto-provider.ts +++ b/packages/spec/src/contracts/crypto-provider.ts @@ -1,13 +1,34 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * ICryptoProvider — pluggable encryption hook for the Settings subsystem. + * ICryptoProvider — pluggable encryption hook shared by every platform + * surface that seals a secret at rest. * * The provider's only job is to round-trip plaintext to a *handle* * (a string the caller persists; opaque to everyone else). The handle - * doubles as the value stored in `sys_setting.value_enc` — it usually - * points to a row in `sys_secret`, but the contract intentionally - * leaves the format up to the implementation. + * usually points to a row in `sys_secret`, but the contract intentionally + * leaves the format up to the implementation. Where the caller *records* + * the handle differs per producer — see "Producers" below. + * + * Producers — three independent call sites construct a + * {@link CryptoContext}, and only the first of them means "settings": + * + * 1. **Settings** (`SettingsService`) — `ctx.namespace` is the settings + * namespace, `ctx.key` the specifier key; `handle.id` is recorded in + * `sys_setting.value_enc`. + * 2. **Object secret fields** (the ObjectQL engine's secret-field path) + * — `ctx.namespace` is the **object name**, `ctx.key` the **field + * name**; `handle.id` is recorded as a `secret:` ref on the business + * row itself. + * 3. **Datasource credentials** (the datasource secret binder) — + * `ctx.namespace` is caller-supplied (default `'datasource'`), + * `ctx.key` the datasource name; `handle.id` is recorded as the + * artefact's `sys_secret:` credentialsRef. + * + * All three persist a `sys_secret` row keyed by `handle.id`, and all three + * share one flat `(namespace, key)` space: `sys_secret` declares that pair + * **non-unique** precisely because it does not attribute a row to a + * producer. See {@link CryptoContext} for what that costs an AAD binding. * * Why an interface (not a concrete class): * @@ -24,15 +45,16 @@ * * Lifecycle: * - * 1. `encrypt(plain)` — called once per `set()` for an encrypted - * specifier. Returns a `CryptoHandle` describing both the storage + * 1. `encrypt(plain, ctx)` — called once per write of an encrypted + * value (a settings `set()`, an object secret field, a datasource + * credential). Returns a `CryptoHandle` describing both the storage * handle and the KMS metadata. The caller persists a `sys_secret` - * row keyed by `handle.id` and stores `handle.id` in - * `sys_setting.value_enc`. - * 2. `decrypt(handle)` — called on every `get()` of an encrypted - * specifier to reveal the plaintext to the consumer (e.g. - * EmailService building a transport). Implementations may cache - * decrypted plaintext in-process for the duration of a request. + * row keyed by `handle.id` and records `handle.id` wherever its + * producer keeps it (see "Producers" above). + * 2. `decrypt(handle, ctx)` — called on every read of an encrypted + * value to reveal the plaintext to the consumer (e.g. EmailService + * building a transport). Implementations may cache decrypted + * plaintext in-process for the duration of a request. * 3. `rotateKey(handle)` — re-wraps the same plaintext under a new * KMS key. Returns a new handle (typically `version + 1`). Audit * trail records the rotation as `action='rotate'`. @@ -41,7 +63,12 @@ * multiple async tasks. They should *not* assume sequential access. */ export interface CryptoHandle { - /** Stable opaque id stored in `sys_setting.value_enc`. */ + /** + * Stable opaque id — the key of the `sys_secret` row, recorded by the + * producer wherever that producer keeps its reference (`sys_setting + * .value_enc`, a `secret:` ref on a business row, or a `sys_secret:` + * credentialsRef). Not a settings-only coordinate. + */ readonly id: string; /** Identifier of the KMS key that wrapped the cipher. */ readonly kmsKeyId: string; @@ -60,12 +87,42 @@ export interface CryptoHandle { /** * Optional context passed to encrypt/decrypt so providers can implement * Additional Authenticated Data (AAD) bindings — e.g. AWS KMS encryption - * context. Helps reject ciphertexts that were copied across namespaces. + * context. + * + * ⚠️ **What an AAD over this pair does and does not guarantee.** + * `(namespace, key)` is one flat space shared by the three producer + * vocabularies listed under {@link ICryptoProvider} — settings + * namespace/specifier key, object name/field name, datasource binder — + * and nothing reserves a name in one vocabulary against another. So a + * provider binding its ciphertext to this pair **rejects a ciphertext + * swapped between two coordinates within one producer's vocabulary** (a + * settings value moved to another specifier; a secret field moved to + * another field). It does **NOT** exclude a cross-vocabulary pair: an + * object named `mail` carrying a secret field named `api_key` yields the + * same coordinate as the `mail` settings namespace's `api_key` specifier, + * under the same provider and key, in a `sys_secret` table that permits + * both rows — and a ciphertext swapped between those two rows decrypts + * cleanly. Implementations MUST NOT treat this pair as attributing a + * ciphertext to a producer. + * + * This describes the contract as it stands today, not the shape it is + * meant to keep: the intended end state is a producer-discriminated AAD + * (a scope discriminant on this type, delimiter-safe encoding), deferred + * because it is a breaking `ICryptoProvider` change plus an at-rest + * rewrap of every existing ciphertext. Until that lands, the paragraph + * above is the guarantee — do not read a stronger one into it. */ export interface CryptoContext { - /** Settings namespace the value belongs to. */ + /** + * Producer-scoped namespace: a settings namespace, an **object name** + * (secret fields), or a caller-supplied datasource namespace (default + * `'datasource'`). Not a settings namespace in general. + */ namespace: string; - /** Specifier key within the namespace. */ + /** + * Producer-scoped key within {@link CryptoContext.namespace}: a settings + * specifier key, a **field name** (secret fields), or a datasource name. + */ key: string; /** Optional tenant id for multi-tenant key segregation. */ tenantId?: string; @@ -73,8 +130,9 @@ export interface CryptoContext { export interface ICryptoProvider { /** - * Encrypt plaintext and return a handle. The handle is stored in - * `sys_secret` and referenced by `sys_setting.value_enc`. + * Encrypt plaintext and return a handle. The caller persists it as a + * `sys_secret` row and references it from wherever its producer keeps + * the reference (see "Producers" on {@link ICryptoProvider}). */ encrypt(plain: string, ctx: CryptoContext): Promise; @@ -89,7 +147,8 @@ export interface ICryptoProvider { * Re-wrap the plaintext under the provider's current KMS key. * The returned handle replaces the input handle in `sys_secret`. * Implementations SHOULD bump `version` and update `kmsKeyId` while - * leaving `id` stable (so `sys_setting.value_enc` need not be rewritten). + * leaving `id` stable, so no producer's stored reference to the handle + * has to be rewritten. */ rotateKey(handle: CryptoHandle, ctx: CryptoContext): Promise;