Skip to content

Commit 7660d81

Browse files
committed
fix(metadata-protocol)!: with no crypto provider, version tokens are keyed under a process-scoped ephemeral key, never empty (#21207)
The first cut served an empty version token on a host with no crypto provider. Every save then handed out the same empty token, and a client that sends no pin for an empty token turned every pinned reset into an unpinned one: the optimistic lock failed open. CI's real reset-door pin caught it. The doors now key under the provider when one is registered and, while none is, under 32 random bytes drawn once per process and never written anywhere. A token is always served, differs when the content differs, is never the unkeyed stored hash, and no empty or withheld token equals it. A token held across a restart, or across a provider's registration, is refused once with 409. The no-provider refusal branch is gone, and the batch-publish conformance pin is back to its base bytes: it passes as it was written. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ad5a00 commit 7660d81

7 files changed

Lines changed: 206 additions & 111 deletions

‎.changeset/21207-keyed-served-content-hash.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ fix(metadata-protocol)!: a metadata body's stored content hash is served and com
1111

1212
Clause-②: yes (narrowing)
1313

14-
<!-- adr-0087: not-required (no-migration-prescription) the stored content hash of a metadata body stays the canonical hash at rest and no metadata body, authorable key, spelling or export moves; what changes is the form a door serves the hash in (the crypto provider's keyed digest), the form an inbound version token is compared in, and which query shapes the doors accept over the two hash columns, so `objectstack migrate meta` has nothing to rewrite. The operator-run rewrite this release asks for is of audit, activity and decision-audit copies, not of metadata. The other categories are closed on facts: every package here publishes (not `unpublished`); no ADR-0087 id covers a served version token or a refused query shape (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
14+
<!-- adr-0087: not-required (no-migration-prescription) the stored content hash of a metadata body stays the canonical hash at rest and no metadata body, authorable key, spelling or export moves; what changes is the form a door serves the hash in (a keyed digest: the crypto provider's, or a process-scoped ephemeral key's when none is registered), the form an inbound version token is compared in, and which query shapes the doors accept over the two hash columns, so `objectstack migrate meta` has nothing to rewrite. The operator-run rewrite this release asks for is of audit, activity and decision-audit copies, not of metadata. The other categories are closed on facts: every package here publishes (not `unpublished`); no ADR-0087 id covers a served version token or a refused query shape (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
1515

1616
**BREAKING**: this narrows what the metadata doors serve and accept for the stored content hash of a metadata body — a hash over the whole stored body, withheld credential material included. Served beside the projected body it let a reader confirm a guess at that material offline; filtered on, it confirmed one online. It ships as `minor` under the launch-window convention for accept-set narrowings.
1717

1818
**Three things change for callers and operators.**
1919

20-
1. **A held version token gets one `409 METADATA_CONFLICT`.** Every door that hands out a metadata version token — the save, publish, package-publish and rollback receipts and the history read — now hands out the crypto provider's keyed digest of the stored hash instead of the hash itself, and the save and reset doors compare a token they are sent in that same form. A token a client held from before the upgrade is refused once; take the token from the next read or receipt and retry. A deployment that registers no crypto provider issues no token — the receipts' version is empty and the history read serves none — and refuses a sent token with the same `409`; send the write without one, or register a provider.
20+
1. **A held version token gets one `409 METADATA_CONFLICT`.** Every door that hands out a metadata version token — the save, publish, package-publish and rollback receipts and the history read — now hands out a keyed digest of the stored hash instead of the hash itself, and the save and reset doors compare a token they are sent in that same form. The key is the crypto provider's; a host that registers none keys under a process-scoped ephemeral key instead, so a token is always issued and never empty. A token a client held from before the upgrade is refused once; take the token from the next read or receipt and retry. On a host with no provider the same happens after a restart, and on any host when a provider is first registered. An empty, withheld, raw or stale token is refused with the same `409`; it is never read as "no pin".
2121
2. **Filter, sort and group on the two stored content-hash columns, and on the version history's change note, now answer `400 INVALID_FIELD`** — on the generic data door, the MCP stdio reader and the analytics door, before the engine runs. The change note is included because a draft promotion that stated no message of its own recorded the draft's stored hash in it; the publish door now always states a hash-free message, and a note written before this release is served with the quoted hash in keyed form. A data-door search over the two stored-metadata tables no longer scans those columns or the stored body column, and an explicit search-field list naming one answers the same `400`. Every other column of the two tables is served, filtered, sorted and grouped as before, and every other object is unchanged.
2222
3. **Operators run `os migrate audit-metadata-bodies` once after upgrading, dry run first.** The audit ledger, the activity feed and the metadata decision-audit trail no longer copy the stored hash. The extended command drops it from the copies already written and withholds it in the decision-audit notes and their copies: a dry run by default, `--apply` to rewrite, idempotent. The version history stays the lineage.
2323

24-
**What else changes.** The data door and the MCP stdio reader serve the two hash columns of the stored-metadata tables in keyed form, or omit them with no provider. A `409` conflict refusal carries keyed values or none. The ObjectQL engine gains a read accessor for the registered provider's keyed digest; it is additive. A member's read of these tables is refused as before.
24+
**What else changes.** The data door serves the two hash columns of the stored-metadata tables in keyed form, under the same key as the version tokens. The MCP stdio reader serves them keyed under the crypto provider's key, and omits them on a host with no provider. A `409` conflict refusal carries keyed values or none. The ObjectQL engine gains a read accessor for the registered provider's keyed digest; it is additive. A member's read of these tables is refused as before.

‎packages/metadata-protocol/src/metadata-redaction.ts‎

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
* belongs on that list first; that file is `packages/spec`'s to change.
6767
*/
6868

69+
import { createHmac, randomBytes } from 'node:crypto';
6970
import { getMetadataTypeRedactor } from '@objectstack/spec/kernel';
7071
import type { MetadataTypeRedactor } from '@objectstack/spec/kernel';
7172
// [#21120] The family-wide stored-metadata-body primitives — the object set,
@@ -829,8 +830,8 @@ const QUOTED_STORED_HASH = /(?<![\w-])sha256:[0-9a-f]{64}/g;
829830

830831
/**
831832
* Free text with every quoted stored content hash replaced by its served form:
832-
* the keyed digest, or `(withheld)` with no crypto provider. Text that quotes
833-
* none is returned as is.
833+
* the keyed digest, or `(withheld)` when the caller holds no digest. Text that
834+
* quotes none is returned as is.
834835
*/
835836
export async function serveStoredHashTokens(text: string, digest: StoredHashDigest | undefined): Promise<string> {
836837
const quoted = text.match(QUOTED_STORED_HASH);
@@ -843,12 +844,46 @@ export async function serveStoredHashTokens(text: string, digest: StoredHashDige
843844
/** The keyed-digest primitive of the registered crypto provider (`ICryptoProvider.keyedDigest`). */
844845
export type StoredHashDigest = (plain: string) => Promise<string>;
845846

847+
/**
848+
* [#21207] The process key {@link ephemeralStoredHashDigest} keys under: 32
849+
* random bytes, drawn on first use, held only in this module, never written,
850+
* logged or served.
851+
*/
852+
let ephemeralDigestKey: Buffer | undefined;
853+
854+
/**
855+
* [#21207] The keyed digest the `/meta` doors serve and compare metadata
856+
* version tokens under while NO crypto provider is registered: `hmac-sha256:`
857+
* plus hex, the provider contract's own output shape, under a process-scoped
858+
* ephemeral key.
859+
*
860+
* Why a key and not "serve nothing": a version token is an optimistic lock.
861+
* Serving none hands every caller the same empty token, and a client that
862+
* (rightly) sends no pin for an empty token turns every pinned write into an
863+
* unpinned one, so the lock fails OPEN without a word. A token keyed under a
864+
* secret nobody outside this process holds keeps the three properties the lock
865+
* needs: it differs when the content differs; it is never the unkeyed stored
866+
* hash, so it confirms no guess at withheld material offline; and no empty or
867+
* withheld value ever equals it.
868+
*
869+
* What it costs: the key dies with the process. A token held across a restart,
870+
* or across the moment a host registers a real provider (the doors read the
871+
* provider per use), names no current version and is refused once with
872+
* `409 METADATA_CONFLICT`; the next read or receipt serves the current one.
873+
* Every protocol in one process shares this key, so per-environment protocols
874+
* answer one another's tokens.
875+
*/
876+
export const ephemeralStoredHashDigest: StoredHashDigest = async (plain: string): Promise<string> => {
877+
ephemeralDigestKey ??= randomBytes(32);
878+
return `hmac-sha256:${createHmac('sha256', ephemeralDigestKey).update(plain, 'utf8').digest('hex')}`;
879+
};
880+
846881
/**
847882
* The form a stored content hash is SERVED in: the keyed digest of the stored
848-
* value under the provider's server-held key; `null` when nothing is stored
849-
* (a delete event, a first version's parent); `undefined` — WITHHELD — when no
850-
* provider is registered, or when the stored value is not a string this
851-
* function can judge. ⛔ Never the stored value itself.
883+
* value under a server-held key; `null` when nothing is stored (a delete
884+
* event, a first version's parent); `undefined` (WITHHELD) when the caller
885+
* holds no digest, or when the stored value is not a string this function can
886+
* judge. ⛔ Never the stored value itself.
852887
*
853888
* A failing digest is not caught: a provider that cannot compute it fails the
854889
* read rather than serving what it exists to replace.
@@ -865,9 +900,9 @@ export async function servedContentHash(
865900
/**
866901
* Serve one row of a stored-metadata table with its content-hash columns in
867902
* their served form ({@link servedContentHash}): keyed, `null` kept `null`, and
868-
* the column OMITTED when the value is withheld — and, with no crypto provider,
869-
* both columns omitted outright. A row of any other object, and a row carrying
870-
* neither column, is returned by reference.
903+
* the column OMITTED when the value is withheld — and, when the caller holds no
904+
* digest, both columns omitted outright. A row of any other object, and a row
905+
* carrying neither column, is returned by reference.
871906
*/
872907
export async function serveStoredMetadataHashColumns<T>(
873908
object: string,
@@ -879,7 +914,7 @@ export async function serveStoredMetadataHashColumns<T>(
879914
const out: Record<string, unknown> = { ...row };
880915
for (const column of STORED_METADATA_HASH_COLUMNS) {
881916
if (!(column in out)) continue;
882-
// No provider: the column is not served at all — a `null` included, so
917+
// No digest: the column is not served at all — a `null` included, so
883918
// a reader cannot tell a withheld hash from an absent one either.
884919
const served = digest ? await servedContentHash(out[column], digest) : undefined;
885920
if (served === undefined) delete out[column];

‎packages/metadata-protocol/src/protocol.data-door-stored-content-hash.test.ts‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* - SERVED: each column is the crypto provider's keyed digest of the stored
1515
* value — neither the stored value nor a recomputation, stable across reads,
1616
* moving on any body change including a credential-only one; a `null` stays
17-
* `null`; with no provider both columns are omitted;
17+
* `null`; with no provider the key is the process-scoped ephemeral one;
1818
* - EVALUATED: a filter, sort or grouping on either column is refused before
1919
* the engine is asked — `INVALID_FIELD` / 400, the shape the body column's
2020
* refusals already answer — and a search never scans them (nor the body
@@ -156,20 +156,23 @@ describe('[#21207] data door — the content-hash columns are served keyed', ()
156156
expect(got.record.previous_checksum).toMatch(KEYED);
157157
});
158158

159-
it('no provider: both columns are omitted on every read, the other columns untouched', async () => {
159+
it('no provider: both columns keyed under the process key, never stored, stable across reads', async () => {
160160
const { p } = makeProtocol({ provider: false });
161161
const list: any = await p.findData({ object: 'sys_metadata', query: {} });
162-
for (const row of list.records) {
163-
expect('checksum' in row).toBe(false);
164-
expect(typeof row.name).toBe('string');
162+
const again: any = await p.findData({ object: 'sys_metadata', query: {} });
163+
for (const row of SYS_METADATA_ROWS) {
164+
const served = byId(list.records, row.id);
165+
expect(served.checksum).toMatch(KEYED);
166+
expect(served.checksum).not.toBe(row.checksum);
167+
expect(served.checksum).not.toBe(await keyedDigest(row.checksum));
168+
expect(byId(again.records, row.id).checksum).toBe(served.checksum);
169+
expect(typeof served.name).toBe('string');
165170
}
166171
const hist: any = await p.findData({ object: 'sys_metadata_history', query: {} });
167-
for (const row of hist.records) {
168-
expect('checksum' in row).toBe(false);
169-
expect('previous_checksum' in row).toBe(false);
170-
}
172+
expect(byId(hist.records, 'h_1').previous_checksum).toBeNull();
173+
for (const row of hist.records) expect(row.checksum).toMatch(KEYED);
171174
const got: any = await p.getData({ object: 'sys_metadata', id: 'm_view' });
172-
expect('checksum' in got.record).toBe(false);
175+
expect(got.record.checksum).toBe(byId(list.records, 'm_view').checksum);
173176
expect(got.record.name).toBe('all_tasks');
174177
});
175178

@@ -261,17 +264,20 @@ describe('[#21207] data door — the history change note that quotes a stored ha
261264
const QUOTED = hashSpec({ quoted: true });
262265
const NOTE_ROWS = [{ id: 'h_note', type: 'view', name: 'all_tasks', version: 3, operation_type: 'publish', metadata: '{}', checksum: QUOTED, previous_checksum: null, change_note: `publish draft (hash ${QUOTED})` }];
263266

264-
it('is served with the quote keyed, and withheld with no provider', async () => {
267+
it('is served with the quote keyed, under the provider\'s key or the process key', async () => {
265268
const saved = [...HISTORY_ROWS];
266269
HISTORY_ROWS.push(...(NOTE_ROWS as any));
267270
try {
268271
const { p } = makeProtocol();
269272
const got: any = await p.getData({ object: 'sys_metadata_history', id: 'h_note' });
270273
expect(got.record.change_note).toBe(`publish draft (hash ${await keyedDigest(QUOTED)})`);
271274
const bare = makeProtocol({ provider: false });
272-
const withheld: any = await bare.p.findData({ object: 'sys_metadata_history', query: {} });
273-
expect(byId(withheld.records, 'h_note').change_note).toBe('publish draft (hash (withheld))');
274-
expect(JSON.stringify(withheld.records)).not.toContain(QUOTED);
275+
const keyed: any = await bare.p.findData({ object: 'sys_metadata_history', query: {} });
276+
const note = byId(keyed.records, 'h_note');
277+
// The quote is served as the row's own served hash column.
278+
expect(note.change_note).toBe(`publish draft (hash ${note.checksum})`);
279+
expect(note.checksum).toMatch(KEYED);
280+
expect(JSON.stringify(keyed.records)).not.toContain(QUOTED);
275281
} finally {
276282
HISTORY_ROWS.length = 0;
277283
HISTORY_ROWS.push(...saved);

0 commit comments

Comments
 (0)