@sanning/proof 0.17.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.
package/src/verifier.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import canonicalize from "canonicalize";
11
11
 
12
- import { ed25519Verify, isHexField, sha256Hex, utf8 } from "./crypto.js";
12
+ import { SIGNATURE_FAILED, ed25519Refusal, isHexField, sha256Hex, utf8 } from "./crypto.js";
13
13
  import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from "./types.js";
14
14
 
15
15
  // Fail-closed accepted-profile registry (envelope-spec §2): exactly the
@@ -157,6 +157,18 @@ export function exceedsDepth(value: unknown, max: number): boolean {
157
157
  return boundedInput(value, max) === OVER_DEEP;
158
158
  }
159
159
 
160
+ /**
161
+ * The bytes an envelope's inline `payload` commits to: `JCS(payload)`. The ONE
162
+ * place this is spelled: `verifyEnvelope` binds `payload_hash` against these
163
+ * bytes, and the chain links rule 11 reads (`readChainLinks`) read an inline
164
+ * record through them, so the binding rule and the linkage rule cannot
165
+ * disagree about which bytes a record is. Throws where the payload cannot be
166
+ * canonicalized. The twin is Python's `inline_payload_bytes`.
167
+ */
168
+ export function inlinePayloadBytes(envelope: { payload: unknown }): Uint8Array {
169
+ return utf8(jcs(envelope.payload));
170
+ }
171
+
160
172
  // RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
161
173
  // JS implementation; correctness is pinned by the conformance vectors.
162
174
  //
@@ -251,6 +263,87 @@ export function jcsParse(bytes: Uint8Array): unknown {
251
263
  return value;
252
264
  }
253
265
 
266
+ /**
267
+ * Whether the bytes nest arrays and objects deeper than `max`, read from the
268
+ * bytes before any parser sees them: the greatest number of `[` and `{` open
269
+ * at once outside strings. A `"` opens a string and the next `"` no `\`
270
+ * escapes closes it; a `]` or `}` outside a string closes one open level, if
271
+ * one is open. Defined for every byte string, JSON or not. The Python twin is
272
+ * `exceeds_text_depth` in `canonicalize.py`.
273
+ *
274
+ * On the bytes because a parser's own limit is its host's: CPython 3.10
275
+ * stops near 1,000 levels, `JSON.parse` and CPython 3.13 read far deeper, Go's
276
+ * decoder stops at 10,000 (enclave-attestation section 2.1).
277
+ */
278
+ export function exceedsTextDepth(bytes: Uint8Array, max: number): boolean {
279
+ let brackets = 0;
280
+ for (let i = 0; i < bytes.length; i++) {
281
+ const b = bytes[i]!;
282
+ if (b === 0x5b || b === 0x7b) brackets++;
283
+ }
284
+ if (brackets <= max) return false; // it cannot open more levels than it has brackets
285
+ let depth = 0;
286
+ let inString = false;
287
+ let escaped = false;
288
+ for (let i = 0; i < bytes.length; i++) {
289
+ const b = bytes[i]!;
290
+ if (inString) {
291
+ if (escaped) escaped = false;
292
+ else if (b === 0x5c) escaped = true;
293
+ else if (b === 0x22) inString = false;
294
+ } else if (b === 0x22) {
295
+ inString = true;
296
+ } else if (b === 0x5b || b === 0x7b) {
297
+ if (++depth > max) return true;
298
+ } else if ((b === 0x5d || b === 0x7d) && depth > 0) {
299
+ depth--;
300
+ }
301
+ }
302
+ return false;
303
+ }
304
+
305
+ /**
306
+ * Whether a parsed value holds a number no IEEE 754 double holds, at any
307
+ * depth. `JSON.parse` reads `1e400`, and an integer of 400 digits, as
308
+ * `Infinity`. Iterative: the value is a producer's. The Python twin is
309
+ * `has_non_finite_number`.
310
+ */
311
+ export function hasNonFiniteNumber(value: unknown): boolean {
312
+ const stack: unknown[] = [value];
313
+ while (stack.length > 0) {
314
+ const node = stack.pop();
315
+ if (typeof node === "number") {
316
+ if (!Number.isFinite(node)) return true;
317
+ } else if (Array.isArray(node)) {
318
+ for (const child of node) stack.push(child);
319
+ } else if (typeof node === "object" && node !== null) {
320
+ for (const child of Object.values(node)) stack.push(child);
321
+ }
322
+ }
323
+ return false;
324
+ }
325
+
326
+ /**
327
+ * The deepest a JSON text an enclave reader parses may nest, and the depth
328
+ * every kernel canonicalizes to (`MAX_CANONICAL_DEPTH`).
329
+ */
330
+ export const MAX_READ_DEPTH = MAX_CANONICAL_DEPTH;
331
+
332
+ /**
333
+ * `jcsParse`, within the reader limits of enclave-attestation section 2.1:
334
+ * the bytes nest at most `MAX_READ_DEPTH` levels, checked on the bytes first,
335
+ * and every number in the value is a finite double. Throws past either limit,
336
+ * and wherever `jcsParse` throws. The Python twin is `jcs_loads_within_limits`.
337
+ */
338
+ export function jcsParseWithinLimits(bytes: Uint8Array): unknown {
339
+ if (exceedsTextDepth(bytes, MAX_READ_DEPTH)) {
340
+ throw new Error(`jcsParseWithinLimits: nests deeper than ${MAX_READ_DEPTH} levels`);
341
+ }
342
+ const value = jcsParse(bytes);
343
+ if (hasNonFiniteNumber(value)) throw new Error("jcsParseWithinLimits: holds a number no double holds");
344
+ return value;
345
+ }
346
+
254
347
  /**
255
348
  * A JSON object that names one member twice (I-JSON, RFC 7493 section 2.3).
256
349
  * The Python twin is `DuplicateKey` in `canonicalize.py`, and the message is
@@ -809,7 +902,7 @@ async function verifyEnvelopeChecked(
809
902
  const checks: boolean[] = [];
810
903
  if ("payload" in env && env.payload !== undefined) {
811
904
  try {
812
- const recomputed = await sha256Hex(utf8(jcs(env.payload)));
905
+ const recomputed = await sha256Hex(inlinePayloadBytes(env as { payload: unknown }));
813
906
  checks.push(recomputed === env.payload_hash);
814
907
  } catch {
815
908
  checks.push(false);
@@ -835,16 +928,20 @@ async function verifyEnvelopeChecked(
835
928
  // invalidating the primary signature; the field is reserved/default-absent.
836
929
  // The corpus has no co-signed vectors, so this strip is pinned by an explicit
837
930
  // unit test rather than by conformance.
931
+ // The refusal names the clause of the Ed25519 rule a signature breaks
932
+ // (envelope-spec §7, A44), so a forged shape reads differently from a
933
+ // corrupted byte; an equation that does not hold reads the bare sentence.
838
934
  let signatureOk = false;
839
935
  try {
840
936
  const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
841
- signatureOk = await ed25519Verify(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
842
- if (!signatureOk) errors.push("Ed25519 signature verification failed");
937
+ const refusal = await ed25519Refusal(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
938
+ signatureOk = refusal === null;
939
+ if (refusal !== null) errors.push(refusal);
843
940
  } catch {
844
941
  // The sentence the Python kernel writes for the same failure. What was
845
942
  // thrown is the host's wording (`jcs: input nesting exceeds ...`), and a
846
943
  // public result carries the kernel's own words only (proof#150).
847
- errors.push("Ed25519 signature verification failed");
944
+ errors.push(SIGNATURE_FAILED);
848
945
  }
849
946
 
850
947
  // Check 3 (optional) — Content Bind: the user's bytes match a hash this
@@ -20,7 +20,7 @@
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
22
 
23
- import { verifyEvidenceBundle } from "./evidence.js";
23
+ import { SIMULATED_WITNESS_KIND, verifyEvidenceBundle } from "./evidence.js";
24
24
  import type { WitnessLine, WitnessState } from "./case.js";
25
25
  import { WITNESS_NOT_LOOKED_UP, renderWitnessLine } from "./case.js";
26
26
  import {
@@ -44,6 +44,12 @@ export interface OnChainRow {
44
44
  mismatched: number;
45
45
  unreachable: number;
46
46
  not_checked: number;
47
+ /**
48
+ * How many of the `not_checked` witnesses are simulated: a sandbox plane's
49
+ * stand-in for a chain, never looked up. Present only where there
50
+ * is one, so a production pack's row is unchanged.
51
+ */
52
+ simulated?: number;
47
53
  /** Present only where the pack caches a witness time (5.6.6, v1.25, A30). */
48
54
  witness_times?: WitnessTimeRow[];
49
55
  }
@@ -97,6 +103,12 @@ export async function onChainHalf(
97
103
  packs: readonly (readonly [string, unknown, readonly CachedWitnessTime[]])[],
98
104
  gateways: readonly string[],
99
105
  options: WitnessLookupOptions = {},
106
+ /**
107
+ * Where given, receives for each pack the `stamp_ref` of every stamp whose
108
+ * witness this lookup confirmed: the run reading evaluates an enclave token
109
+ * at a stamp's interval end only there.
110
+ */
111
+ witnessed?: string[][],
100
112
  ): Promise<OnChainHalf> {
101
113
  if (gateways.length === 0) return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
102
114
  const fetchImpl = options.fetchImpl ?? fetch;
@@ -113,6 +125,9 @@ export async function onChainHalf(
113
125
  unreachable: 0,
114
126
  not_checked: 0,
115
127
  };
128
+ witnessed?.push(
129
+ (result.stamps ?? []).filter((st) => st.ok && st.onChain?.rollup === "confirm").map((st) => st.stampRef),
130
+ );
116
131
  for (const w of witnesses) {
117
132
  const rollup = w.onChain?.rollup ?? null;
118
133
  if (rollup === "confirm") row.confirmed++;
@@ -120,6 +135,8 @@ export async function onChainHalf(
120
135
  else if (rollup === "unreachable") row.unreachable++;
121
136
  else row.not_checked++;
122
137
  }
138
+ const simulated = witnesses.filter((w) => w.anchorRef?.kind === SIMULATED_WITNESS_KIND).length;
139
+ if (simulated > 0) row.simulated = simulated;
123
140
  // The witness times the pack's witness-cache.json gives, looked up where
124
141
  // it gives any (5.6.6, v1.25, A30). A pack with no cache gets no key and
125
142
  // no request.
package/vectors/README.md CHANGED
@@ -214,3 +214,40 @@ tree. One splitting row disagrees with the page on purpose: `record-file-names`
214
214
  a record named `__proto__`, which the page's `assemble` loses because it keys
215
215
  disclosed content by a plain object. A page that vendors this table fails that
216
216
  row until it keys content by a `Map`.
217
+
218
+ ## `sandbox-evidence.json`
219
+
220
+ What both commands print and exit for a pack whose records are signed
221
+ `environment: "dev"` (envelope-spec §2, where a verifier MAY surface the value
222
+ as `environment_marker`), and what they may claim about a witness a sandbox
223
+ plane simulated. Implemented by [`../src/environment.ts`](../src/environment.ts),
224
+ the `sandboxExit` of [`../src/exit-codes.ts`](../src/exit-codes.ts) and the two
225
+ commands in [`../src/cli.ts`](../src/cli.ts), and asserted against this table by
226
+ [`../test/sandbox-evidence.test.ts`](../test/sandbox-evidence.test.ts) **and by
227
+ the Python kernel against the same bytes**, in `tests/test_sandbox_evidence.py`,
228
+ and through both commands as child processes by `cross-kernel/run_sandbox.sh`.
229
+ Every expectation is written by hand in
230
+ `tools/gen-vectors/gen_sandbox_evidence_table.py`, which writes this file. Two
231
+ of the cases are the real sandbox pack in
232
+ [`../test/fixtures/sandbox/`](../test/fixtures/sandbox/), with its provenance.
233
+
234
+ **The one thing a consumer must not do with it: print a `dev` pack as
235
+ verified.** The verdict word stays what the checks established; the `verify`
236
+ headline is `SANDBOX` and the `case` title `Sandbox`, the line
237
+ `Sandbox evidence (environment dev): not production evidence` is printed
238
+ wherever the marker is present, and the exit code is 5 where it would be 0, or
239
+ where every record is `dev` and only simulated witnesses are unchecked. A
240
+ failure is still 1, and a production or mixed pack with a simulated witness is
241
+ still 3. And a `simulated` witness, a stamp's or a checkpoint's, is never
242
+ fetched and never counted as checked on a chain. Each case's
243
+ `simulatedSentence` says whether `verify` says "a simulated witness is not on a
244
+ chain, so nothing was looked up": only a run with `--gateways` that counted a
245
+ simulated witness and was not checked on a chain. Both tests assert the
246
+ TypeScript verdict line and the Python `Gateways` line exactly.
247
+
248
+ Two more members: `stoppedEarly` holds packs whose verification stops before
249
+ any witness is read, each of which must print and write the same with
250
+ `--gateways`, reachable or not, as offline (the Python `Gateways` line names
251
+ the gateways passed); `agentProof` holds an agent proof signed `dev`, which the
252
+ TypeScript `verify` prints as `SANDBOX` with exit 5 and the Python one, which
253
+ implements no agent-proof verifier, refuses with exit 2.