Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions estate-safety-kit/PROVENANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# estate-safety-kit — canonical source + vendoring contract

Three small, security-relevant helpers that were each written correctly, then written
*again* — independently, with no code sharing — somewhere else in the estate, because
there was no shared home for cross-repo, cross-language "closes a real defect class"
code. This directory is that home. The estate's answer to "how do we share trust-
sensitive code across repos" is **not** a package registry — it is the same disciplined
vendoring pattern already used for the hellgraph engine tarball
(`prophet-platform/tools/revendor_engine.py` +
`tools/assert_vendored_engine_marker.py`) and for vendored schema sets
(`apps/compute-gateway/src/compute_gateway/schemas/PROVENANCE.md`): **one canonical
source, verbatim copies at consumers, a recorded commit pin, and a checker that proves
byte-identity instead of trusting a comment.**

## The three helpers

| file | closes | first written | duplicated / re-derived |
|---|---|---|---|
| `js/urlSafe.ts` | click-XSS via an unsanitised `:href` scheme (`javascript:`, `data:`, `vbscript:`, …) bound from an upstream-controlled URL | `socioprophet-web/app-vue/src/services/url-safe.ts` — [socioprophet#477](https://github.com/SocioProphet/socioprophet/pull/477) | copy-pasted (not imported — app-vue and client-vue don't share a workspace) into `socioprophet-web/client-vue/src/utils/urlSafe.ts` — [socioprophet#550](https://github.com/SocioProphet/socioprophet/pull/550) |
| `js/mintId.ts` | id-collision from minting an identifier as `hash(inputs + Date.now())` — collides same-millisecond, and is offline-recomputable from anything that publishes the inputs | `apps/health-twin/src/ids.ts` (`mintId`) — [prophet-platform#1070](https://github.com/SocioProphet/prophet-platform/pull/1070), fixing the exact same shape independently found in both a grant-id mint and `consult.ts`'s three id sites | the same PATTERN (not the code — nobody shared it) had to be independently re-derived for `bootProofRecord` in socioprophet's server contracts — [socioprophet#484](https://github.com/SocioProphet/socioprophet/pull/484) |
| `py/bounded_int.py` | "must be a non-negative int" treated as a size bound, when Python ints are arbitrary-precision — a digit-encoded blob smuggles arbitrary payload through a field typed only by sign | `apps/compute-gateway/src/compute_gateway/engine.py`'s exhaust guard — introduced [prophet-platform#1067](https://github.com/SocioProphet/prophet-platform/pull/1067), the open door found in [#1071](https://github.com/SocioProphet/prophet-platform/pull/1071), the int64 bound closing it in [#1118](https://github.com/SocioProphet/prophet-platform/pull/1118) | never generalized even to the sibling app `nugget-extractor` in the *same* repo, which independently grew its own, differently-shaped, pending-cap fix for a related-but-distinct DoS class (`apps/nugget-extractor/tests/test_pending_cap.py`) |

Adjacent context, not the same specific defect but the same session's broader
socioprophet-web XSS/security-hardening sweep that this duplication was found inside of:
[socioprophet#478](https://github.com/SocioProphet/socioprophet/pull/478) (v-html
sanitization on notebook cell output), [#483](https://github.com/SocioProphet/socioprophet/pull/483)
(mesh bearer token moved out of localStorage), [#486](https://github.com/SocioProphet/socioprophet/pull/486)
(six low-severity cockpit findings). None of these three duplicate `isSafeHttp` /
`mintId` / bounded-int — they're cited here only because the pattern that produced this
kit ("fixed the same bug shape more than once, in more than one repo, because there was
nowhere to put the fix once") was noticed while working through that same sweep.

## Why vendoring, not an npm package / PyPI package

This estate has explicitly avoided adding new package-registry surfaces for
cross-repo-shared code (`feedback_vendor_dont_reference_external_cdn.md`,
`feedback_sovereign_decoupled_no_bloat.md`): a private registry is a new supply-chain
surface, a new publish pipeline, and a new versioning burden, for three files. The
existing convention for exactly this shape of problem — the hellgraph engine tarball,
the zero-trust kernel schemas above — is **vendor with a provenance record and a
freshness check**, so that's what this is.

## The vendoring contract

A consumer that wants one of these helpers:

1. **Copies the file verbatim** into its own tree (e.g.
`socioprophet-web/app-vue/src/services/url-safe.ts`). No edits — if the helper needs
to change for that consumer, that is a signal the canonical source needs to change
(open a PR here, or generalize the API — see `mintId(prefix, bytes)`'s width
parameter for how the kit already accommodates one real divergence, health-twin's
stricter 64-hex ratchet, without forking the file).
2. **Records a `PROVENANCE.txt`** alongside the vendored file:
```
source-repo: SourceOS-Linux/sourceos-spec
source-path: estate-safety-kit/js/urlSafe.ts
source-commit: <full 40-hex commit SHA this copy was taken from>
vendored-path: socioprophet-web/app-vue/src/services/url-safe.ts
vendored-at: <date>
```
3. **Runs `tools/check_vendored_safety_kit.py`** (in CI, and locally before a re-vendor
PR) against the vendored file. It reads the `PROVENANCE.txt`, fetches the canonical
file at the pinned commit (from `raw.githubusercontent.com`, or from a local
`--source-root` checkout for offline/dev use), and fails loudly on any byte
difference — the same "prove it, don't just claim it" discipline as
`assert_vendored_engine_marker.py`, applied to a small source file instead of a
tarball member (byte comparison instead of marker-substring containment, because a
file this size can be diffed exactly).

## Re-vendoring (canonical source changed)

1. Land the change here, get it merged to `main`.
2. For each consumer: copy the updated file, update `source-commit` in its
`PROVENANCE.txt` to the new merge commit, re-run
`tools/check_vendored_safety_kit.py` to confirm the copy is byte-identical again.
3. Consumers are NOT required to re-vendor in lockstep — an out-of-date pin is visible
(the checker still passes against the *old* pinned commit; it does not silently claim
currency with `main`) rather than invisible, which is the property that matters. A
freshness sweep across consumers is a separate, later concern (see
`feedback_vendored_dist_freshness.md`), not blocking on this PR.

## Status of this PR

New shared-infrastructure pattern — held for human review of the vendoring convention
itself, not just the code inside it (the three defects it closes are already fixed
independently at every site listed above; this PR does not change behavior anywhere
except socioprophet's two consumers being re-pointed to vendored copies of the same
logic they already ran).
86 changes: 86 additions & 0 deletions estate-safety-kit/js/mintId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Pins the property `mintId` exists to guarantee — collision-free minting, not
* derivation from inputs — plus the shape and floor invariants. The regression this
* guards against (`hash(inputs + Date.now())` colliding same-millisecond) is exactly
* what "50 calls with identical inputs in a tight loop -> 50 unique ids" below would
* have caught in health-twin's consult.ts before it was fixed.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { mintId, idPattern, MIN_ID_BYTES } from './mintId.ts';

describe('mintId — CSPRNG id minting', () => {
test('default width is 128 bits (32 hex chars) behind the prefix', () => {
const id = mintId('grant');
assert.match(id, /^grant-[0-9a-f]{32}$/);
});

test('is content-independent — identical prefix, called back-to-back, never repeats', () => {
// This is the exact regression: the old scheme was a hash of its own inputs, so
// identical logical inputs in the same millisecond minted the SAME id. mintId takes
// no content input at all, so "identical inputs" isn't even expressible here — the
// property under test is that N calls with the SAME prefix in a tight loop are N
// unique ids, which is what the old scheme failed at 79% of the time under load.
const n = 2000;
const ids = new Set<string>();
for (let i = 0; i < n; i++) ids.add(mintId('consult'));
assert.equal(ids.size, n, 'every mint in a tight loop must be unique — this is the collision defect');
});

test('respects a wider width for callers with a stricter ratchet (health-twin: 32 bytes / 64 hex)', () => {
const id = mintId('op', 32);
assert.match(id, /^op-[0-9a-f]{64}$/);
});

test('refuses a width below the 128-bit floor', () => {
assert.throws(() => mintId('x', 4), RangeError);
assert.throws(() => mintId('x', 15), RangeError);
assert.doesNotThrow(() => mintId('x', MIN_ID_BYTES));
});

test('refuses a non-integer width', () => {
assert.throws(() => mintId('x', 16.5), RangeError);
assert.throws(() => mintId('x', NaN), RangeError);
});

test('refuses an empty or non-string prefix', () => {
assert.throws(() => mintId(''), TypeError);
assert.throws(() => mintId(null as unknown as string), TypeError);
assert.throws(() => mintId(undefined as unknown as string), TypeError);
});

test('id is lowercase hex — no uppercase, no non-hex characters', () => {
const id = mintId('more');
const [, hex] = id.split('-');
assert.equal(hex, hex.toLowerCase());
assert.match(hex, /^[0-9a-f]+$/);
});
});

describe('idPattern — shape-check builder', () => {
test('matches what mintId actually produces, for the default width', () => {
const pattern = idPattern('grant');
assert.match(mintId('grant'), pattern);
});

test('matches what mintId actually produces, for a non-default width', () => {
const pattern = idPattern('op', 32);
assert.match(mintId('op', 32), pattern);
});

test('does not match a narrower or wider id than the width it was built for', () => {
const pattern = idPattern('grant', 32); // expects 64 hex chars
assert.doesNotMatch(mintId('grant'), pattern); // mintId('grant') defaults to 32 hex chars
});

test('does not match a different prefix', () => {
const pattern = idPattern('grant');
assert.doesNotMatch(mintId('consult'), pattern);
});

test('escapes regex metacharacters in the prefix rather than interpreting them', () => {
const pattern = idPattern('a.b');
assert.doesNotMatch('aXb-' + '0'.repeat(32), pattern);
assert.match('a.b-' + '0'.repeat(32), pattern);
});
});
83 changes: 83 additions & 0 deletions estate-safety-kit/js/mintId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* ONE decision about where an identifier comes from, for every ledger that mints one.
*
* ESTATE SAFETY KIT — canonical source (SourceOS-Linux/sourceos-spec's estate-safety-kit/).
* See estate-safety-kit/PROVENANCE.md in that repo for what this closes,
* which repos hit the defect independently, and the vendoring contract every consumer
* must follow. Do not edit a vendored copy directly — edit this file, re-vendor, done.
*
* THE DEFECT, found independently at least twice in prophet-platform's health-twin
* before this file existed: a grant id minted as `grant-<sha256(agent|scope|Date.now())>`
* and a consult id minted as `consult-<sha256(pseudonym|scope|Date.now()-scope)>`. Both are
* a HASH OF THEIR OWN INPUTS, and both inherit two properties from that:
*
* 1. THEY COLLIDE. The only varying input is a millisecond. Two records minted with the
* same logical inputs in the same millisecond get the SAME id — measured at 79% of
* 200 concurrent issues on a laptop, for the grant case. A collided id is not a
* cosmetic duplicate: the ledger holds two rows under one identity, lookup silently
* resolves to one of them, the other holder's secret stops authenticating with no
* error anyone can point at, revoking the id revokes one row and leaves the other,
* and every receipt naming that id is ambiguous about which record it sealed.
*
* 2. THEY ARE RECOMPUTABLE OFFLINE. If the inputs and a millisecond-precision timestamp
* are ever published beside the id (a natural thing to do — `granted_at` on a grant,
* say), anyone holding that listing can recompute every id in it. The id is then an
* offline guessing target handed out for free, which matters most when the id is also
* the input to revocation or lookup.
*
* THE DECISION: an identifier is MINTED, not DERIVED. Bytes straight from the CSPRNG, hex,
* behind a human-readable typed prefix. Nothing about the record is recoverable from the
* id, nothing about the id is predictable from the record, and the collision probability
* over any ledger this helper could serve is not a number worth writing down.
*
* WHY NOT sha256(inputs + a random nonce), the other obvious repair: the moment a random
* nonce is inside the hash, the output IS random — the deterministic inputs contribute
* nothing an attacker cannot already see, and the hash contributes nothing but the
* appearance of derivation. It reads like a content address and is not one. Minting the
* bytes directly says what is actually happening.
*
* WHAT AN ID IS NOT. It is not a content address and it is not a receipt — those stay a
* hash over their own canonical, sealed content, because their entire job IS to be
* recomputable from the facts they seal. An id and a receipt are two different things that
* happen to both be hex strings.
*
* WIDTH. Default is 16 bytes (128 bits) — ample collision resistance for any per-service
* ledger, minted with `randomBytes`, not a UUID library (no extra dependency, same CSPRNG
* underneath). A caller with a stricter estate-wide ratchet — health-twin's is "no id ends
* in an 8-hex digest, all of them carry a full 64 hex", to keep every emitted id
* indistinguishable in shape from a sha256 digest — passes `bytes: 32` to mint the same
* shape it already emits; the floor below refuses anything under 128 bits so a caller
* cannot accidentally weaken it to something guessable.
*/
import { randomBytes } from 'node:crypto';

/** Minimum width this helper will mint. Below 128 bits is not "smaller ids", it is a
* different security property — refuse it here rather than let a caller discover the
* difference empirically. */
export const MIN_ID_BYTES = 16;

/**
* Mint a fresh, content-independent identifier: `<prefix>-<CSPRNG bytes as hex>`.
*
* @param prefix human-readable, lowercase-and-hyphen typed prefix (`grant`, `consult`, `op`).
* @param bytes CSPRNG bytes to mint, default 16 (128 bits). Must be >= {@link MIN_ID_BYTES}.
*/
export function mintId(prefix: string, bytes: number = MIN_ID_BYTES): string {
if (typeof prefix !== 'string' || prefix.length === 0) {
throw new TypeError(`mintId: prefix must be a non-empty string (got ${JSON.stringify(prefix)})`);
}
if (!Number.isInteger(bytes) || bytes < MIN_ID_BYTES) {
throw new RangeError(`mintId: bytes must be an integer >= ${MIN_ID_BYTES} (got ${bytes})`);
}
return `${prefix}-${randomBytes(bytes).toString('hex')}`;
}

/** Build the shape-check pattern `mintId` guarantees for a given prefix and width, so an
* invariant can check "is this string actually one of ours" without hardcoding hex length
* in two places. Matches a LOWERCASE prefix followed by `-` and `2*bytes` lowercase hex
* digits — the same shape `mintId` produces, nothing looser. */
export function idPattern(prefix: string, bytes: number = MIN_ID_BYTES): RegExp {
const hexLen = bytes * 2;
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escapedPrefix}-[0-9a-f]{${hexLen}}$`);
}
Loading
Loading