@sanning/proof 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,42 @@ export declare const ACCEPTED_SPEC_MAJORS: readonly string[];
3
3
  export declare const MAX_CANONICAL_DEPTH = 128;
4
4
  export declare function exceedsDepth(value: unknown, max: number): boolean;
5
5
  export declare function jcs(value: unknown): string;
6
+ /**
7
+ * Read bytes that claim to be `jcs(value)` (RFC 8785), strictly. The Python
8
+ * twin is `jcs_loads` in `src/sanning_proof/canonicalize.py`, and every site in
9
+ * either kernel that reads bytes a producer signed goes through one of the two.
10
+ *
11
+ * ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. Four checks
12
+ * carry it, none of them left to a decoder's defaults: strict UTF-8, no leading
13
+ * byte-order mark, no non-finite literal (this kernel's `JSON.parse` rejects
14
+ * `NaN`, `Infinity` and `-Infinity` natively, and Python names them in a
15
+ * `parse_constant` hook), and no unpaired surrogate in the parsed value.
16
+ *
17
+ * THE FOURTH CHECK IS NOT THE FIRST ONE TWICE. The strict decode refuses a
18
+ * surrogate written as raw bytes; it cannot see one written as the ASCII
19
+ * escape `\ud800`, which `JSON.parse` turns into a lone code unit AFTER the
20
+ * decode has passed. `"c\ud800x"` is pure ASCII on the wire, so this reader
21
+ * accepted it while its own text above said it does not, and one poisoned
22
+ * record then deleted the Python kernel's whole chain reading (the gate on
23
+ * proof#154). Both spellings are the same malformed record, and both are
24
+ * refused here.
25
+ *
26
+ * `new TextDecoder()` alone is none of that. It replaces every invalid sequence
27
+ * with U+FFFD and strips a BOM. Python's `json.loads(<bytes>)` is not it
28
+ * either, in the opposite direction: CPython sniffs the encoding and decodes
29
+ * with `errors="surrogatepass"`, so it strips a UTF-8 BOM, accepts a UTF-16 or
30
+ * UTF-32 body, and passes a lone surrogate and a CESU-8 pair through. Both
31
+ * defaults have shipped a split verdict on one pack: a raw `0x80` inside a
32
+ * stamp body read `verified` here and `failed` there (gate 4), and then a
33
+ * `BOM + JCS(record)` checkpoint read `verified` there and `mixed` here (gate
34
+ * 5), both on proof#140. JCS is defined over UTF-8 and I-JSON (RFC 7493)
35
+ * forbids a lone surrogate, so each of those inputs is MALFORMED rather than
36
+ * another spelling of the same record.
37
+ *
38
+ * Throws for anything that is not conformant; every call site treats a throw as
39
+ * "this reader cannot read these bytes" and names the check that failed.
40
+ */
41
+ export declare function jcsParse(bytes: Uint8Array): unknown;
6
42
  export declare function specVersionSupported(specVersion: string): boolean;
7
43
  export declare function isRfc3339UtcInstant(value: unknown): boolean;
8
44
  export declare function contentHashes(env: Envelope): {
package/dist/verifier.js CHANGED
@@ -7,13 +7,13 @@
7
7
  // Conformance is enforced by test/conformance.test.ts, which runs every
8
8
  // corpus test vector through this code and asserts byte-for-byte agreement.
9
9
  import canonicalize from "canonicalize";
10
- import { ed25519Verify, sha256Hex, utf8 } from "./crypto.js";
10
+ import { ed25519Verify, isHexField, sha256Hex, utf8 } from "./crypto.js";
11
11
  // Fail-closed accepted-profile registry (envelope-spec §2): exactly the
12
12
  // accepted profile majors, nothing else. Minors within an accepted
13
13
  // major ("sanning.agent/v1.<minor>") are additive and tolerated — matching the Go
14
14
  // reference kernel's semantics (pkg/proof isSupportedSpec) so the kernels do
15
15
  // not diverge on minors. Accepting a new profile is a deliberate one-entry
16
- // addition HERE and only here. sanning.events/v1 (external commitment + Minimal
16
+ // addition HERE and only here. sanning.events/v2 (external commitment + Minimal
17
17
  // disclosure; envelope-spec §4) is verified through the SAME primitives — the
18
18
  // signed scope is still envelope-minus-signature-minus-co_signatures, with no
19
19
  // profile-specific dialect (no underscore strip, that is mlflow-only).
@@ -35,9 +35,13 @@ import { ed25519Verify, sha256Hex, utf8 } from "./crypto.js";
35
35
  // always strict mode) and the `readonly string[]` type rejects it at compile
36
36
  // time. The elements are strings, so a shallow freeze is a deep one. Pinned by
37
37
  // test/accept-set.test.ts "the exported registry cannot be widened at runtime".
38
+ //
39
+ // `sanning.events/v2` replaced `sanning.events/v1` in the producer-to-agent cut
40
+ // (envelope-spec A10, governance.md §8 W-3). The old major is rejected like any
41
+ // other unknown one: DL-73 keeps no reader for it.
38
42
  export const ACCEPTED_SPEC_MAJORS = Object.freeze([
39
43
  "sanning.agent/v1",
40
- "sanning.events/v1",
44
+ "sanning.events/v2",
41
45
  ]);
42
46
  // Maximum JSON container-nesting depth a verifier will canonicalize. Inputs
43
47
  // nested deeper are rejected as malformed BEFORE canonicalization runs. This is
@@ -174,6 +178,85 @@ export function jcs(value) {
174
178
  }
175
179
  return canonical;
176
180
  }
181
+ // The byte-order mark, as a CHARACTER. RFC 8785 output never begins with
182
+ // U+FEFF, so bytes that do are not conformant whatever a JSON reader makes of
183
+ // them.
184
+ const JCS_BOM = "";
185
+ /**
186
+ * Read bytes that claim to be `jcs(value)` (RFC 8785), strictly. The Python
187
+ * twin is `jcs_loads` in `src/sanning_proof/canonicalize.py`, and every site in
188
+ * either kernel that reads bytes a producer signed goes through one of the two.
189
+ *
190
+ * ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. Four checks
191
+ * carry it, none of them left to a decoder's defaults: strict UTF-8, no leading
192
+ * byte-order mark, no non-finite literal (this kernel's `JSON.parse` rejects
193
+ * `NaN`, `Infinity` and `-Infinity` natively, and Python names them in a
194
+ * `parse_constant` hook), and no unpaired surrogate in the parsed value.
195
+ *
196
+ * THE FOURTH CHECK IS NOT THE FIRST ONE TWICE. The strict decode refuses a
197
+ * surrogate written as raw bytes; it cannot see one written as the ASCII
198
+ * escape `\ud800`, which `JSON.parse` turns into a lone code unit AFTER the
199
+ * decode has passed. `"c\ud800x"` is pure ASCII on the wire, so this reader
200
+ * accepted it while its own text above said it does not, and one poisoned
201
+ * record then deleted the Python kernel's whole chain reading (the gate on
202
+ * proof#154). Both spellings are the same malformed record, and both are
203
+ * refused here.
204
+ *
205
+ * `new TextDecoder()` alone is none of that. It replaces every invalid sequence
206
+ * with U+FFFD and strips a BOM. Python's `json.loads(<bytes>)` is not it
207
+ * either, in the opposite direction: CPython sniffs the encoding and decodes
208
+ * with `errors="surrogatepass"`, so it strips a UTF-8 BOM, accepts a UTF-16 or
209
+ * UTF-32 body, and passes a lone surrogate and a CESU-8 pair through. Both
210
+ * defaults have shipped a split verdict on one pack: a raw `0x80` inside a
211
+ * stamp body read `verified` here and `failed` there (gate 4), and then a
212
+ * `BOM + JCS(record)` checkpoint read `verified` there and `mixed` here (gate
213
+ * 5), both on proof#140. JCS is defined over UTF-8 and I-JSON (RFC 7493)
214
+ * forbids a lone surrogate, so each of those inputs is MALFORMED rather than
215
+ * another spelling of the same record.
216
+ *
217
+ * Throws for anything that is not conformant; every call site treats a throw as
218
+ * "this reader cannot read these bytes" and names the check that failed.
219
+ */
220
+ export function jcsParse(bytes) {
221
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
222
+ if (text.startsWith(JCS_BOM))
223
+ throw new Error("jcsParse: leading byte-order mark");
224
+ const value = JSON.parse(text);
225
+ rejectUnpairedSurrogates(value);
226
+ return value;
227
+ }
228
+ /**
229
+ * Refuse a parsed value carrying an unpaired surrogate, in a key or a value, at
230
+ * any depth. The Python twin is `_reject_unpaired_surrogates`, and the two name
231
+ * the failure with the same words.
232
+ *
233
+ * Deliberately ITERATIVE. `rejectLoneSurrogates` below walks a value this
234
+ * kernel is about to serialize, which `boundedInput` has already depth-bounded.
235
+ * This one walks whatever `JSON.parse` returned for a producer's bytes, with no
236
+ * bound in front of it, so a recursive walk would turn a deeply-nested record
237
+ * into a `RangeError` out of the reader itself.
238
+ */
239
+ function rejectUnpairedSurrogates(value) {
240
+ const stack = [value];
241
+ while (stack.length > 0) {
242
+ const node = stack.pop();
243
+ if (typeof node === "string") {
244
+ if (hasLoneSurrogate(node))
245
+ throw new Error("jcsParse: unpaired surrogate");
246
+ }
247
+ else if (Array.isArray(node)) {
248
+ for (const child of node)
249
+ stack.push(child);
250
+ }
251
+ else if (node !== null && typeof node === "object") {
252
+ for (const [key, child] of Object.entries(node)) {
253
+ if (hasLoneSurrogate(key))
254
+ throw new Error("jcsParse: unpaired surrogate");
255
+ stack.push(child);
256
+ }
257
+ }
258
+ }
259
+ }
177
260
  // Walk every string in the value (keys and values). The check must run on
178
261
  // the INPUT: `canonicalize` escapes a lone surrogate as `\udXXX` text in its
179
262
  // output, so the malformed code unit is invisible after serialization.
@@ -264,11 +347,34 @@ export function isRfc3339UtcInstant(value) {
264
347
  // present: §2 flags an absent `signed_at` as non-conformant rather than
265
348
  // rejecting it, and `environment` is optional in the skeleton.
266
349
  //
350
+ // `public_key` AND `signature` ARE CHECKED HERE TOO (proof#146, under
351
+ // proof#135 option B). The strict `hexToBytes` inside `ed25519Verify` is what
352
+ // makes the three kernels agree on the VERDICT; naming the spelling is what
353
+ // makes them agree on the REASON. On its own a respelled key reads `Ed25519
354
+ // signature verification failed`, which sends an auditor looking for a forged
355
+ // signature that is not there. proof#135 was opened against this function's
356
+ // Python twin, for covering `signed_at` and `environment` and not `public_key`.
357
+ //
358
+ // ONLY A STRING IS CHECKED, the same rule `envelopeHexError` follows one layer
359
+ // up: an absent or non-string `public_key` is already a failed signature in all
360
+ // three kernels (Go names it "missing signature or public_key"), and naming it
361
+ // here as a spelling would state one fact twice and in the wrong words. CASE IS
362
+ // NOT CHECKED: proof#135 pinned the charset and left the case open.
363
+ //
267
364
  // Own properties only, because those are the members `jcs` signs over.
268
365
  function skeletonFieldErrors(env) {
269
366
  const fields = env;
270
367
  const own = (k) => (Object.prototype.hasOwnProperty.call(fields, k) ? fields[k] : undefined);
271
368
  const out = [];
369
+ for (const [name, nibbles] of [
370
+ ["public_key", 64],
371
+ ["signature", 128],
372
+ ]) {
373
+ const value = own(name);
374
+ if (typeof value === "string" && !isHexField(value, nibbles)) {
375
+ out.push(`malformed ${name}: not ${nibbles} hex characters (envelope-spec §2)`);
376
+ }
377
+ }
272
378
  const signedAt = own("signed_at");
273
379
  if (signedAt !== undefined && !isRfc3339UtcInstant(signedAt)) {
274
380
  out.push("malformed signed_at: not an RFC 3339 UTC date-time with Z naming a real date and time (envelope-spec §2)");
@@ -356,6 +462,41 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
356
462
  `Did you mean ${[...KNOWN_VERIFY_OPTIONS].join(" / ")}? ` +
357
463
  "Option keys are camelCase in the TypeScript kernel; the Python kernel uses snake_case.");
358
464
  }
465
+ // ── A verifier never raises on an input (proof#85 item 3) ──
466
+ //
467
+ // The option check above is the one exception and stays OUTSIDE this try: an
468
+ // unknown option key is the CALLER's mistake, not the envelope's, and turning
469
+ // a typo into a verdict is how a check silently never runs.
470
+ //
471
+ // Below, every field read is a read of attacker-shaped data. `env` is typed
472
+ // as an `Envelope`, but nothing enforces that at runtime, and an object whose
473
+ // property reads throw (a `get`-trap `Proxy`, a getter that raises, an
474
+ // exotic object from another realm) passed the null/non-object guard and then
475
+ // escaped as a raised exception. An auditor cannot tell a raised exception
476
+ // apart from a broken tool, which is the one thing this kernel exists not to
477
+ // hand them. CLAUDE.md states the rule flatly: malformed envelopes return a
478
+ // failed `VerificationResult`, they do not throw. This closes the class, not
479
+ // the one `Proxy` case: #77 hardened the anchor-trace path for the same
480
+ // reason, and a per-input patch would have to be written again for the next
481
+ // exotic object.
482
+ try {
483
+ return await verifyEnvelopeChecked(env, opts);
484
+ }
485
+ catch (e) {
486
+ return {
487
+ ok: false,
488
+ specVersionOk: false,
489
+ // `null`, never `false`: nothing was read, so nothing was determined.
490
+ // The same rule the not-a-JSON-object branch below follows.
491
+ payloadHashOk: null,
492
+ signatureOk: false,
493
+ contentHashOk: opts.expectedContentHash === undefined ? null : false,
494
+ contentRole: null,
495
+ errors: [`envelope could not be read: ${stringifyErr(e)}`],
496
+ };
497
+ }
498
+ }
499
+ async function verifyEnvelopeChecked(env, opts) {
359
500
  const expectedContentHash = opts.expectedContentHash;
360
501
  const errors = [];
361
502
  // Guard a malformed input (null / non-object / array) up front — every field
@@ -409,6 +550,16 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
409
550
  // inline payload, supplied committed bytes, or both. With neither, the
410
551
  // binding is undetermined (null): "signature-valid, semantics-undetermined"
411
552
  // (§3.1/§6.2). null does not fail the verdict; absence does.
553
+ //
554
+ // THE ENVELOPE'S OWN `payload_hash` IS NOT ECHOED INTO THESE REASONS, and
555
+ // that is a change (proof#154 F4). It is producer-controlled text of any
556
+ // length, and it went into an auditor-facing error list verbatim: a producer
557
+ // could put a newline and a forged `payload_hash: OK envelope verified`
558
+ // line, or an ANSI escape that paints `VERIFIED` green, into what the
559
+ // verifier prints. No other field in this file echoes its value, the Python
560
+ // twin names the same failure without one, and the value is in the envelope
561
+ // the auditor already holds. The RECOMPUTED digest stays: this kernel
562
+ // produced it, so it is 64 characters of lowercase hex by construction.
412
563
  let payloadHashOk;
413
564
  if (typeof env.payload_hash !== "string") {
414
565
  payloadHashOk = false;
@@ -421,7 +572,7 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
421
572
  const recomputed = await sha256Hex(utf8(jcs(env.payload)));
422
573
  checks.push(recomputed === env.payload_hash);
423
574
  if (recomputed !== env.payload_hash) {
424
- errors.push(`payload_hash mismatch: envelope=${env.payload_hash} recomputed=${recomputed}`);
575
+ errors.push(`payload_hash mismatch: the envelope's payload_hash is not SHA-256(JCS(payload)), recomputed=${recomputed}`);
425
576
  }
426
577
  }
427
578
  catch (e) {
@@ -433,7 +584,7 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
433
584
  const external = await sha256Hex(opts.payloadBytes);
434
585
  checks.push(external === env.payload_hash);
435
586
  if (external !== env.payload_hash) {
436
- errors.push(`payload_hash does not match the committed bytes: envelope=${env.payload_hash} bytes=${external}`);
587
+ errors.push(`payload_hash does not match the committed bytes: bytes=${external}`);
437
588
  }
438
589
  }
439
590
  payloadHashOk = checks.length > 0 ? checks.every(Boolean) : null;
@@ -462,7 +613,7 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
462
613
  const committed = contentHashes(env);
463
614
  if (committed.length === 0) {
464
615
  // UNDETERMINED, not failed. An external-commitment envelope
465
- // (`sanning.events/v1`) has no inline payload, so it commits to no
616
+ // (`sanning.events/v2`) has no inline payload, so it commits to no
466
617
  // envelope-readable content hash and there is nothing here to compare
467
618
  // against. Reporting that as a failure would render "we cannot check
468
619
  // this" as "it did not match" — what P-10 and I-5 exist to prevent — and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.9.2",
4
- "description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
3
+ "version": "0.11.0",
4
+ "description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1 and sanning.events/v2. Conformance-gated byte-for-byte against the test-vectors corpus.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { hexToBytes, utf8 } from "./crypto.js";
18
18
  import { leafHash, verifyInclusion } from "./merkle.js";
19
- import { MAX_CANONICAL_DEPTH, exceedsDepth, jcs, verifyEnvelope } from "./verifier.js";
19
+ import { MAX_CANONICAL_DEPTH, exceedsDepth, jcs, jcsParse, verifyEnvelope } from "./verifier.js";
20
20
  import type { Envelope } from "./types.js";
21
21
 
22
22
  export const AGENT_PROOF_SPEC_PREFIX = "sanning.agent.proof/v1";
@@ -213,7 +213,13 @@ async function refetch(
213
213
  const onChain = new Uint8Array(await resp.arrayBuffer());
214
214
  if (toHex(onChain) === expectedHex) return { ok: true, unavailable: false };
215
215
  try {
216
- const parsed = JSON.parse(new TextDecoder().decode(onChain));
216
+ // `jcsParse`, matching `_probe_gateway` in
217
+ // `src/sanning_proof/evidence.py`. These bytes arrive from a gateway or
218
+ // a CDN in front of one, so a lenient decoder is reachable without any
219
+ // party building a pack: a CDN serving the envelope with a byte-order
220
+ // mark split this probe from the one in `evidence.ts`, and two honest
221
+ // readers of ONE transaction disagreed (proof#140 gate 5).
222
+ const parsed = jcsParse(onChain);
217
223
  if (toHex(utf8(jcs(parsed))) === expectedHex) return { ok: true, unavailable: false };
218
224
  } catch {
219
225
  // fall through