@sanning/proof 0.10.0 → 0.11.1

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,7 +7,7 @@
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
@@ -178,6 +178,85 @@ export function jcs(value) {
178
178
  }
179
179
  return canonical;
180
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
+ }
181
260
  // Walk every string in the value (keys and values). The check must run on
182
261
  // the INPUT: `canonicalize` escapes a lone surrogate as `\udXXX` text in its
183
262
  // output, so the malformed code unit is invisible after serialization.
@@ -268,11 +347,34 @@ export function isRfc3339UtcInstant(value) {
268
347
  // present: §2 flags an absent `signed_at` as non-conformant rather than
269
348
  // rejecting it, and `environment` is optional in the skeleton.
270
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
+ //
271
364
  // Own properties only, because those are the members `jcs` signs over.
272
365
  function skeletonFieldErrors(env) {
273
366
  const fields = env;
274
367
  const own = (k) => (Object.prototype.hasOwnProperty.call(fields, k) ? fields[k] : undefined);
275
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
+ }
276
378
  const signedAt = own("signed_at");
277
379
  if (signedAt !== undefined && !isRfc3339UtcInstant(signedAt)) {
278
380
  out.push("malformed signed_at: not an RFC 3339 UTC date-time with Z naming a real date and time (envelope-spec §2)");
@@ -360,6 +462,41 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
360
462
  `Did you mean ${[...KNOWN_VERIFY_OPTIONS].join(" / ")}? ` +
361
463
  "Option keys are camelCase in the TypeScript kernel; the Python kernel uses snake_case.");
362
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) {
363
500
  const expectedContentHash = opts.expectedContentHash;
364
501
  const errors = [];
365
502
  // Guard a malformed input (null / non-object / array) up front — every field
@@ -413,6 +550,16 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
413
550
  // inline payload, supplied committed bytes, or both. With neither, the
414
551
  // binding is undetermined (null): "signature-valid, semantics-undetermined"
415
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.
416
563
  let payloadHashOk;
417
564
  if (typeof env.payload_hash !== "string") {
418
565
  payloadHashOk = false;
@@ -425,7 +572,7 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
425
572
  const recomputed = await sha256Hex(utf8(jcs(env.payload)));
426
573
  checks.push(recomputed === env.payload_hash);
427
574
  if (recomputed !== env.payload_hash) {
428
- 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}`);
429
576
  }
430
577
  }
431
578
  catch (e) {
@@ -437,7 +584,7 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
437
584
  const external = await sha256Hex(opts.payloadBytes);
438
585
  checks.push(external === env.payload_hash);
439
586
  if (external !== env.payload_hash) {
440
- 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}`);
441
588
  }
442
589
  }
443
590
  payloadHashOk = checks.length > 0 ? checks.every(Boolean) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
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",
@@ -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