@sanning/proof 0.18.0 → 0.19.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.
@@ -2,6 +2,17 @@ import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from ".
2
2
  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
+ /**
6
+ * The bytes an envelope's inline `payload` commits to: `JCS(payload)`. The ONE
7
+ * place this is spelled: `verifyEnvelope` binds `payload_hash` against these
8
+ * bytes, and the chain links rule 11 reads (`readChainLinks`) read an inline
9
+ * record through them, so the binding rule and the linkage rule cannot
10
+ * disagree about which bytes a record is. Throws where the payload cannot be
11
+ * canonicalized. The twin is Python's `inline_payload_bytes`.
12
+ */
13
+ export declare function inlinePayloadBytes(envelope: {
14
+ payload: unknown;
15
+ }): Uint8Array;
5
16
  export declare function jcs(value: unknown): string;
6
17
  /**
7
18
  * Read bytes that claim to be `jcs(value)` (RFC 8785), strictly. The Python
@@ -48,6 +59,38 @@ export declare function jcs(value: unknown): string;
48
59
  */
49
60
  export declare function utf8Strict(bytes: Uint8Array): string;
50
61
  export declare function jcsParse(bytes: Uint8Array): unknown;
62
+ /**
63
+ * Whether the bytes nest arrays and objects deeper than `max`, read from the
64
+ * bytes before any parser sees them: the greatest number of `[` and `{` open
65
+ * at once outside strings. A `"` opens a string and the next `"` no `\`
66
+ * escapes closes it; a `]` or `}` outside a string closes one open level, if
67
+ * one is open. Defined for every byte string, JSON or not. The Python twin is
68
+ * `exceeds_text_depth` in `canonicalize.py`.
69
+ *
70
+ * On the bytes because a parser's own limit is its host's: CPython 3.10
71
+ * stops near 1,000 levels, `JSON.parse` and CPython 3.13 read far deeper, Go's
72
+ * decoder stops at 10,000 (enclave-attestation section 2.1).
73
+ */
74
+ export declare function exceedsTextDepth(bytes: Uint8Array, max: number): boolean;
75
+ /**
76
+ * Whether a parsed value holds a number no IEEE 754 double holds, at any
77
+ * depth. `JSON.parse` reads `1e400`, and an integer of 400 digits, as
78
+ * `Infinity`. Iterative: the value is a producer's. The Python twin is
79
+ * `has_non_finite_number`.
80
+ */
81
+ export declare function hasNonFiniteNumber(value: unknown): boolean;
82
+ /**
83
+ * The deepest a JSON text an enclave reader parses may nest, and the depth
84
+ * every kernel canonicalizes to (`MAX_CANONICAL_DEPTH`).
85
+ */
86
+ export declare const MAX_READ_DEPTH = 128;
87
+ /**
88
+ * `jcsParse`, within the reader limits of enclave-attestation section 2.1:
89
+ * the bytes nest at most `MAX_READ_DEPTH` levels, checked on the bytes first,
90
+ * and every number in the value is a finite double. Throws past either limit,
91
+ * and wherever `jcsParse` throws. The Python twin is `jcs_loads_within_limits`.
92
+ */
93
+ export declare function jcsParseWithinLimits(bytes: Uint8Array): unknown;
51
94
  /**
52
95
  * A JSON object that names one member twice (I-JSON, RFC 7493 section 2.3).
53
96
  * The Python twin is `DuplicateKey` in `canonicalize.py`, and the message is
package/dist/verifier.js CHANGED
@@ -147,6 +147,17 @@ function boundedInput(value, max) {
147
147
  export function exceedsDepth(value, max) {
148
148
  return boundedInput(value, max) === OVER_DEEP;
149
149
  }
150
+ /**
151
+ * The bytes an envelope's inline `payload` commits to: `JCS(payload)`. The ONE
152
+ * place this is spelled: `verifyEnvelope` binds `payload_hash` against these
153
+ * bytes, and the chain links rule 11 reads (`readChainLinks`) read an inline
154
+ * record through them, so the binding rule and the linkage rule cannot
155
+ * disagree about which bytes a record is. Throws where the payload cannot be
156
+ * canonicalized. The twin is Python's `inline_payload_bytes`.
157
+ */
158
+ export function inlinePayloadBytes(envelope) {
159
+ return utf8(jcs(envelope.payload));
160
+ }
150
161
  // RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
151
162
  // JS implementation; correctness is pinned by the conformance vectors.
152
163
  //
@@ -236,6 +247,98 @@ export function jcsParse(bytes) {
236
247
  rejectUnpairedSurrogates(value);
237
248
  return value;
238
249
  }
250
+ /**
251
+ * Whether the bytes nest arrays and objects deeper than `max`, read from the
252
+ * bytes before any parser sees them: the greatest number of `[` and `{` open
253
+ * at once outside strings. A `"` opens a string and the next `"` no `\`
254
+ * escapes closes it; a `]` or `}` outside a string closes one open level, if
255
+ * one is open. Defined for every byte string, JSON or not. The Python twin is
256
+ * `exceeds_text_depth` in `canonicalize.py`.
257
+ *
258
+ * On the bytes because a parser's own limit is its host's: CPython 3.10
259
+ * stops near 1,000 levels, `JSON.parse` and CPython 3.13 read far deeper, Go's
260
+ * decoder stops at 10,000 (enclave-attestation section 2.1).
261
+ */
262
+ export function exceedsTextDepth(bytes, max) {
263
+ let brackets = 0;
264
+ for (let i = 0; i < bytes.length; i++) {
265
+ const b = bytes[i];
266
+ if (b === 0x5b || b === 0x7b)
267
+ brackets++;
268
+ }
269
+ if (brackets <= max)
270
+ return false; // it cannot open more levels than it has brackets
271
+ let depth = 0;
272
+ let inString = false;
273
+ let escaped = false;
274
+ for (let i = 0; i < bytes.length; i++) {
275
+ const b = bytes[i];
276
+ if (inString) {
277
+ if (escaped)
278
+ escaped = false;
279
+ else if (b === 0x5c)
280
+ escaped = true;
281
+ else if (b === 0x22)
282
+ inString = false;
283
+ }
284
+ else if (b === 0x22) {
285
+ inString = true;
286
+ }
287
+ else if (b === 0x5b || b === 0x7b) {
288
+ if (++depth > max)
289
+ return true;
290
+ }
291
+ else if ((b === 0x5d || b === 0x7d) && depth > 0) {
292
+ depth--;
293
+ }
294
+ }
295
+ return false;
296
+ }
297
+ /**
298
+ * Whether a parsed value holds a number no IEEE 754 double holds, at any
299
+ * depth. `JSON.parse` reads `1e400`, and an integer of 400 digits, as
300
+ * `Infinity`. Iterative: the value is a producer's. The Python twin is
301
+ * `has_non_finite_number`.
302
+ */
303
+ export function hasNonFiniteNumber(value) {
304
+ const stack = [value];
305
+ while (stack.length > 0) {
306
+ const node = stack.pop();
307
+ if (typeof node === "number") {
308
+ if (!Number.isFinite(node))
309
+ return true;
310
+ }
311
+ else if (Array.isArray(node)) {
312
+ for (const child of node)
313
+ stack.push(child);
314
+ }
315
+ else if (typeof node === "object" && node !== null) {
316
+ for (const child of Object.values(node))
317
+ stack.push(child);
318
+ }
319
+ }
320
+ return false;
321
+ }
322
+ /**
323
+ * The deepest a JSON text an enclave reader parses may nest, and the depth
324
+ * every kernel canonicalizes to (`MAX_CANONICAL_DEPTH`).
325
+ */
326
+ export const MAX_READ_DEPTH = MAX_CANONICAL_DEPTH;
327
+ /**
328
+ * `jcsParse`, within the reader limits of enclave-attestation section 2.1:
329
+ * the bytes nest at most `MAX_READ_DEPTH` levels, checked on the bytes first,
330
+ * and every number in the value is a finite double. Throws past either limit,
331
+ * and wherever `jcsParse` throws. The Python twin is `jcs_loads_within_limits`.
332
+ */
333
+ export function jcsParseWithinLimits(bytes) {
334
+ if (exceedsTextDepth(bytes, MAX_READ_DEPTH)) {
335
+ throw new Error(`jcsParseWithinLimits: nests deeper than ${MAX_READ_DEPTH} levels`);
336
+ }
337
+ const value = jcsParse(bytes);
338
+ if (hasNonFiniteNumber(value))
339
+ throw new Error("jcsParseWithinLimits: holds a number no double holds");
340
+ return value;
341
+ }
239
342
  /**
240
343
  * A JSON object that names one member twice (I-JSON, RFC 7493 section 2.3).
241
344
  * The Python twin is `DuplicateKey` in `canonicalize.py`, and the message is
@@ -782,7 +885,7 @@ async function verifyEnvelopeChecked(env, opts) {
782
885
  const checks = [];
783
886
  if ("payload" in env && env.payload !== undefined) {
784
887
  try {
785
- const recomputed = await sha256Hex(utf8(jcs(env.payload)));
888
+ const recomputed = await sha256Hex(inlinePayloadBytes(env));
786
889
  checks.push(recomputed === env.payload_hash);
787
890
  }
788
891
  catch {
@@ -11,6 +11,12 @@ export interface OnChainRow {
11
11
  mismatched: number;
12
12
  unreachable: number;
13
13
  not_checked: number;
14
+ /**
15
+ * How many of the `not_checked` witnesses are simulated: a sandbox plane's
16
+ * stand-in for a chain, never looked up. Present only where there
17
+ * is one, so a production pack's row is unchanged.
18
+ */
19
+ simulated?: number;
14
20
  /** Present only where the pack caches a witness time (5.6.6, v1.25, A30). */
15
21
  witness_times?: WitnessTimeRow[];
16
22
  }
@@ -46,7 +52,13 @@ export interface OnChainHalf {
46
52
  * instead of a `Cli` stream (`io.fetchImpl ?? fetch`, the same fallback the
47
53
  * CLI already applied at its own call site).
48
54
  */
49
- export declare function onChainHalf(packs: readonly (readonly [string, unknown, readonly CachedWitnessTime[]])[], gateways: readonly string[], options?: WitnessLookupOptions): Promise<OnChainHalf>;
55
+ export declare function onChainHalf(packs: readonly (readonly [string, unknown, readonly CachedWitnessTime[]])[], gateways: readonly string[], options?: WitnessLookupOptions,
56
+ /**
57
+ * Where given, receives for each pack the `stamp_ref` of every stamp whose
58
+ * witness this lookup confirmed: the run reading evaluates an enclave token
59
+ * at a stamp's interval end only there.
60
+ */
61
+ witnessed?: string[][]): Promise<OnChainHalf>;
50
62
  /**
51
63
  * `cached` is every witness time the packs cache (5.6.6, v1.25, A30). Where no
52
64
  * lookup ran, each is printed labelled not checked. Where one ran, each time
@@ -19,7 +19,7 @@
19
19
  // one pack (or several) in, one four-state result out, with a pack's
20
20
  // `witness-cache.json` read as part of the pack rather than as a side file a
21
21
  // caller must remember to also pass in.
22
- import { verifyEvidenceBundle } from "./evidence.js";
22
+ import { SIMULATED_WITNESS_KIND, verifyEvidenceBundle } from "./evidence.js";
23
23
  import { WITNESS_NOT_LOOKED_UP, renderWitnessLine } from "./case.js";
24
24
  import { checkWitnessCache, readWitnessCache, renderWitnessTime, } from "./witness-cache.js";
25
25
  function witnessTimeRow(t) {
@@ -45,7 +45,13 @@ function witnessTimeRow(t) {
45
45
  * instead of a `Cli` stream (`io.fetchImpl ?? fetch`, the same fallback the
46
46
  * CLI already applied at its own call site).
47
47
  */
48
- export async function onChainHalf(packs, gateways, options = {}) {
48
+ export async function onChainHalf(packs, gateways, options = {},
49
+ /**
50
+ * Where given, receives for each pack the `stamp_ref` of every stamp whose
51
+ * witness this lookup confirmed: the run reading evaluates an enclave token
52
+ * at a stamp's interval end only there.
53
+ */
54
+ witnessed) {
49
55
  if (gateways.length === 0)
50
56
  return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
51
57
  const fetchImpl = options.fetchImpl ?? fetch;
@@ -62,6 +68,7 @@ export async function onChainHalf(packs, gateways, options = {}) {
62
68
  unreachable: 0,
63
69
  not_checked: 0,
64
70
  };
71
+ witnessed?.push((result.stamps ?? []).filter((st) => st.ok && st.onChain?.rollup === "confirm").map((st) => st.stampRef));
65
72
  for (const w of witnesses) {
66
73
  const rollup = w.onChain?.rollup ?? null;
67
74
  if (rollup === "confirm")
@@ -73,6 +80,9 @@ export async function onChainHalf(packs, gateways, options = {}) {
73
80
  else
74
81
  row.not_checked++;
75
82
  }
83
+ const simulated = witnesses.filter((w) => w.anchorRef?.kind === SIMULATED_WITNESS_KIND).length;
84
+ if (simulated > 0)
85
+ row.simulated = simulated;
76
86
  // The witness times the pack's witness-cache.json gives, looked up where
77
87
  // it gives any (5.6.6, v1.25, A30). A pack with no cache gets no key and
78
88
  // no request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
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",
package/src/case-file.ts CHANGED
@@ -95,7 +95,8 @@ export function howToVerifyText(name: string): string {
95
95
  ` sanning-proof case ${name}\n` +
96
96
  "\n" +
97
97
  "Exit codes: 0 verified, 1 failed, 2 not readable or a usage error,\n" +
98
- "3 could not be settled (partial, pending, incomplete, not found), 4 mixed.\n" +
98
+ "3 could not be settled (partial, pending, incomplete, not found), 4 mixed,\n" +
99
+ "5 sandbox (records signed environment dev, not production evidence).\n" +
99
100
  "\n" +
100
101
  "To also look each witness up on the public record, add --gateways <url,url>.\n" +
101
102
  "The lookup is reported on its own line and never changes the verdict word.\n" +
package/src/case.ts CHANGED
@@ -247,12 +247,13 @@ import {
247
247
  type DeclarationClaims,
248
248
  type DeclarationRole,
249
249
  DECLARATION_VERSION,
250
- DECLARATION_VERSION_V2,
250
+ DECLARATION_VERSIONS,
251
251
  type PackRecord,
252
252
  checkDeclaration,
253
253
  declarationClaims,
254
254
  packRecords,
255
255
  } from "./declaration.js";
256
+ import { readChainLinks } from "./chain.js";
256
257
  import {
257
258
  type HandoffReading,
258
259
  type HeldRecord,
@@ -1516,14 +1517,18 @@ export async function readCase(
1516
1517
  } else if (ungraded) {
1517
1518
  grade = signed.status === "verified" ? "malformed" : (signed.status as PackVerdict);
1518
1519
  } else {
1519
- check = checkDeclaration(pack.bundle, pack.content);
1520
+ // Rule 11 reads the chain links of the same pack, and the reader reads
1521
+ // only the signed body, so the bare check below takes the same reading
1522
+ // (evidence-bundle.md 5.4.2 v1.36, A45).
1523
+ const chainLinks = await readChainLinks(pack.bundle);
1524
+ check = checkDeclaration(pack.bundle, pack.content, chainLinks);
1520
1525
  const completeness = completenessNotes(pack.bundle);
1521
1526
  full = gradePack(verified, accountDisclosure(pack.bundle, pack.content ?? {}), completeness, check);
1522
1527
  const bareGrade = gradePack(
1523
1528
  signed,
1524
1529
  accountDisclosure(pack.bundle, {}),
1525
1530
  completeness,
1526
- checkDeclaration(pack.bundle, null),
1531
+ checkDeclaration(pack.bundle, null, chainLinks),
1527
1532
  );
1528
1533
  // NO `failed` HERE RESTS ON BYTES NOBODY SIGNED. The signed grade decides
1529
1534
  // where it is `failed` or `malformed`; a failure only the side map
@@ -2008,7 +2013,9 @@ function declarationVersionOf(bundle: unknown): string | null {
2008
2013
  return null;
2009
2014
  }
2010
2015
  const version = (declaration as Record<string, unknown>)["declaration_version"];
2011
- return version === DECLARATION_VERSION || version === DECLARATION_VERSION_V2 ? version : null;
2016
+ return typeof version === "string" && (DECLARATION_VERSIONS as readonly string[]).includes(version)
2017
+ ? version
2018
+ : null;
2012
2019
  }
2013
2020
 
2014
2021
  // ---- Presentation (optional) ------------------------------------------------
package/src/chain.ts CHANGED
@@ -49,13 +49,14 @@
49
49
  //
50
50
  // (2) is a transitional accommodation, not the design. `metadata` is
51
51
  // caller-owned and the profile says a verifier MUST NOT read it for
52
- // verification — which is exactly why nothing here feeds a verdict. The fix is
52
+ // verification — which is exactly why nothing read through it feeds a
53
+ // verdict, a claimed chain included (rule 11 reads linkage (1) only). The fix is
53
54
  // for every adapter to set the record's own `previous_hash` (filed separately
54
55
  // against `anchor`); when it lands, the namespace table below stops mattering
55
56
  // and can go.
56
57
 
57
58
  import { hexToBytes, sha256Hex } from "./crypto.js";
58
- import { jcsParse } from "./verifier.js";
59
+ import { inlinePayloadBytes, jcsParse } from "./verifier.js";
59
60
  import { resolveTraceEvents } from "./evidence.js";
60
61
 
61
62
  /**
@@ -280,7 +281,31 @@ function adapterLinkage(
280
281
  * nothing and means a relabelled record DANGLES instead of quietly resolving.
281
282
  * That is why this function is async.
282
283
  */
283
- async function groupChains(bundle: unknown): Promise<Group[]> {
284
+ /**
285
+ * The record an envelope binds, for rule 11's links: `event_bytes` where the
286
+ * entry carries them, else the inline `payload` read through
287
+ * `inlinePayloadBytes`, the bytes `payload_hash` binds. Its identity is the
288
+ * SHA-256 of whichever bytes it is read from. Python's `_bound_record`.
289
+ */
290
+ function boundRecord(
291
+ entry: { event_bytes?: unknown; envelope?: unknown } | undefined,
292
+ ): { record: EventRecord; bytes: Uint8Array } | null {
293
+ const eventBytes = entry?.event_bytes;
294
+ if (typeof eventBytes === "string" && eventBytes.length > 0) return decodeRecord(eventBytes);
295
+ const envelope = entry?.envelope;
296
+ if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) return null;
297
+ if (!("payload" in envelope)) return null;
298
+ try {
299
+ const bytes = inlinePayloadBytes(envelope as { payload: unknown });
300
+ const record = jcsParse(bytes) as EventRecord;
301
+ if (record === null || typeof record !== "object" || Array.isArray(record)) return null;
302
+ return { record, bytes };
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+
308
+ async function groupChains(bundle: unknown, inline = false): Promise<Group[]> {
284
309
  const groups = new Map<string, Group>();
285
310
  const events = eventsOf(bundle);
286
311
 
@@ -292,7 +317,11 @@ async function groupChains(bundle: unknown): Promise<Group[]> {
292
317
  // event id an adapter pointer has nothing to resolve against.
293
318
  if (eventId === null || publicKey === null) continue;
294
319
 
295
- const decoded = decodeRecord(entry?.event_bytes);
320
+ // `inline` (rule 11's links only): a record carried as the envelope's
321
+ // inline `payload` with no `event_bytes` is read too. The chain reading
322
+ // itself keeps reading `event_bytes` alone, so its findings and its rule
323
+ // table do not move.
324
+ const decoded = inline ? boundRecord(entry) : decodeRecord(entry?.event_bytes);
296
325
  if (decoded === null) continue;
297
326
  const { record, bytes } = decoded;
298
327
 
@@ -448,6 +477,54 @@ function findingsFor(group: Group): ChainFinding[] {
448
477
  return findings;
449
478
  }
450
479
 
480
+ /**
481
+ * One record's place in a chain, as the reader grouped it, for a check that
482
+ * needs the links themselves rather than the findings: rule 11 of
483
+ * evidence-bundle.md 5.4.2, which holds a claimed chain to every link. The
484
+ * twin of Python's `ChainLink`.
485
+ */
486
+ export interface ChainLink {
487
+ eventId: string;
488
+ publicKey: string;
489
+ chainKey: string;
490
+ source: ChainSource;
491
+ namespace?: string;
492
+ /** The SHA-256 of the record's committed bytes, or its event id for an adapter chain. */
493
+ selfId: string;
494
+ /** The pointer the record carries, or `null` where it begins a chain. */
495
+ prev: string | null;
496
+ seq: number | null;
497
+ /** Position in `body.events`. */
498
+ order: number;
499
+ }
500
+
501
+ /**
502
+ * Every record the chain reader places in a chain, in bundle order, with the
503
+ * identity and pointer it read: the grouping `readChains` reads, over every
504
+ * record an envelope binds, `event_bytes` or the inline `payload` where no
505
+ * `event_bytes` travel, with the identity the SHA-256 of those bytes, exactly
506
+ * as the envelope verifier binds `payload_hash`.
507
+ */
508
+ export async function readChainLinks(bundle: unknown): Promise<ChainLink[]> {
509
+ const links: ChainLink[] = [];
510
+ for (const g of await groupChains(bundle, true)) {
511
+ for (const m of g.members) {
512
+ links.push({
513
+ eventId: m.eventId,
514
+ publicKey: g.publicKey,
515
+ chainKey: g.chainKey,
516
+ source: g.source,
517
+ ...(g.namespace !== undefined ? { namespace: g.namespace } : {}),
518
+ selfId: m.selfId,
519
+ prev: m.prev,
520
+ seq: m.seq,
521
+ order: m.order,
522
+ });
523
+ }
524
+ }
525
+ return links.sort((a, b) => a.order - b.order);
526
+ }
527
+
451
528
  /**
452
529
  * Follow every chain the bundle's records describe, and report the links that
453
530
  * do not resolve within it.