@sanning/proof 0.19.1 → 0.20.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.
Files changed (98) hide show
  1. package/README.md +30 -29
  2. package/dist/agent-record.d.ts +62 -0
  3. package/dist/agent-record.js +527 -0
  4. package/dist/agent-schemas.generated.d.ts +15 -0
  5. package/dist/agent-schemas.generated.js +5759 -0
  6. package/dist/case-file.d.ts +32 -89
  7. package/dist/case-file.js +41 -93
  8. package/dist/case.d.ts +339 -822
  9. package/dist/case.js +667 -1160
  10. package/dist/chain.d.ts +19 -47
  11. package/dist/chain.js +78 -205
  12. package/dist/claims.d.ts +211 -0
  13. package/dist/claims.js +1081 -0
  14. package/dist/cli.d.ts +45 -68
  15. package/dist/cli.js +618 -1022
  16. package/dist/compose.js +23 -65
  17. package/dist/crypto.d.ts +7 -8
  18. package/dist/crypto.js +60 -147
  19. package/dist/declaration.d.ts +68 -194
  20. package/dist/declaration.js +175 -518
  21. package/dist/environment.d.ts +9 -30
  22. package/dist/environment.js +17 -66
  23. package/dist/evidence.d.ts +119 -160
  24. package/dist/evidence.js +744 -1103
  25. package/dist/exit-codes.d.ts +11 -27
  26. package/dist/exit-codes.js +24 -59
  27. package/dist/file-match.d.ts +33 -79
  28. package/dist/file-match.js +33 -69
  29. package/dist/grading.d.ts +98 -135
  30. package/dist/grading.js +224 -380
  31. package/dist/handoff.d.ts +62 -160
  32. package/dist/handoff.js +153 -393
  33. package/dist/index.d.ts +14 -10
  34. package/dist/index.js +60 -167
  35. package/dist/json-schema.d.ts +29 -0
  36. package/dist/json-schema.js +298 -0
  37. package/dist/merkle.js +16 -28
  38. package/dist/message-parts.d.ts +56 -212
  39. package/dist/message-parts.js +142 -502
  40. package/dist/side-content.d.ts +11 -22
  41. package/dist/side-content.js +22 -65
  42. package/dist/types.d.ts +17 -0
  43. package/dist/types.js +19 -5
  44. package/dist/verifier.d.ts +40 -114
  45. package/dist/verifier.js +196 -535
  46. package/dist/witness-cache.d.ts +24 -45
  47. package/dist/witness-cache.js +38 -81
  48. package/dist/witness-lookup.d.ts +36 -57
  49. package/dist/witness-lookup.js +89 -139
  50. package/dist/zip-reader.d.ts +18 -33
  51. package/dist/zip-reader.js +19 -34
  52. package/package.json +11 -3
  53. package/src/agent-record.ts +608 -0
  54. package/src/agent-schemas.generated.ts +5764 -0
  55. package/src/case-file.ts +45 -109
  56. package/src/case.ts +814 -1328
  57. package/src/chain.ts +85 -224
  58. package/src/claims.ts +1253 -0
  59. package/src/cli.ts +714 -1176
  60. package/src/compose.ts +33 -79
  61. package/src/crypto.ts +60 -147
  62. package/src/declaration.ts +219 -656
  63. package/src/environment.ts +17 -65
  64. package/src/evidence.ts +958 -1381
  65. package/src/exit-codes.ts +24 -63
  66. package/src/file-match.ts +40 -89
  67. package/src/grading.ts +287 -421
  68. package/src/handoff.ts +175 -427
  69. package/src/index.ts +131 -177
  70. package/src/json-schema.ts +295 -0
  71. package/src/merkle.ts +16 -28
  72. package/src/message-parts.ts +155 -533
  73. package/src/side-content.ts +25 -68
  74. package/src/types.ts +55 -36
  75. package/src/verifier.ts +202 -526
  76. package/src/witness-cache.ts +45 -94
  77. package/src/witness-lookup.ts +107 -177
  78. package/src/zip-reader.ts +20 -38
  79. package/vectors/README.md +52 -34
  80. package/vectors/agent-record.json +2175 -0
  81. package/vectors/case-file-index.json +1 -1
  82. package/vectors/case-split.json +2 -2
  83. package/vectors/chain-reading.json +3 -3
  84. package/vectors/file-match.json +285 -148
  85. package/vectors/jcs-reader.json +6 -6
  86. package/vectors/pack-grading.json +102 -25
  87. package/vectors/sandbox-evidence.json +573 -581
  88. package/vectors/stamp-on-chain.json +18 -18
  89. package/vectors/witness-cache.json +49 -49
  90. package/dist/agent-proof.d.ts +0 -28
  91. package/dist/agent-proof.js +0 -226
  92. package/dist/run-adapters.d.ts +0 -10
  93. package/dist/run-adapters.js +0 -35
  94. package/dist/run.d.ts +0 -209
  95. package/dist/run.js +0 -1405
  96. package/src/agent-proof.ts +0 -264
  97. package/src/run-adapters.ts +0 -44
  98. package/src/run.ts +0 -1598
package/src/verifier.ts CHANGED
@@ -1,71 +1,33 @@
1
- // Independent client-side implementation of the envelope verification
2
- // algorithm (specs/envelope-spec.md). This is deliberately a second
3
- // implementation of the same algorithm the Go kernel runs — a second,
4
- // conformance-tested verifier is what demonstrates the product's claim:
5
- // verification needs no Sanning code in the trust path.
6
- //
7
- // Conformance is enforced by test/conformance.test.ts, which runs every
8
- // corpus test vector through this code and asserts byte-for-byte agreement.
1
+ // Client-side envelope verification (specs/envelope-spec.md), an independent
2
+ // implementation so that verification needs no Sanning code in the trust path.
3
+ // test/conformance.test.ts holds it to every corpus vector.
9
4
 
10
5
  import canonicalize from "canonicalize";
11
6
 
12
7
  import { SIGNATURE_FAILED, ed25519Refusal, isHexField, sha256Hex, utf8 } from "./crypto.js";
13
- import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from "./types.js";
14
-
15
- // Fail-closed accepted-profile registry (envelope-spec §2): exactly the
16
- // accepted profile majors, nothing else. Minors within an accepted
17
- // major ("sanning.agent/v1.<minor>") are additive and tolerated — matching the Go
18
- // reference kernel's semantics (pkg/proof isSupportedSpec) so the kernels do
19
- // not diverge on minors. Accepting a new profile is a deliberate one-entry
20
- // addition HERE and only here. sanning.events/v2 (external commitment + Minimal
21
- // disclosure; envelope-spec §4) is verified through the SAME primitives — the
22
- // signed scope is still envelope-minus-signature-minus-co_signatures, with no
23
- // profile-specific dialect (no underscore strip, that is mlflow-only).
24
- //
25
- // Exported for parity with the sibling kernels' registries — Python
26
- // `ACCEPTED_SPEC_MAJORS`, Go `SupportedSpecMajor` — so the accept-set is
27
- // assertable rather than inferable: test/accept-set.test.ts holds it to the
28
- // ratified §4/§5 set transcribed in cross-kernel/accept-set.json, and the
29
- // cross-kernel gate probes one signed envelope per identifier. This entry list
30
- // is NOT the ratified accept-set today: sanning.mlflow/v1 is in the ratified
31
- // set and absent here, an escalated divergence (cross-kernel/README.md
32
- // § Known divergences) whose resolution is a founder decision, not an edit.
33
- //
34
- // FROZEN, and that is load-bearing, not hygiene: `specVersionSupported` (and
35
- // therefore `verifyEnvelope`) reads THIS array, so an exported mutable one
36
- // would let any code sharing the module instance widen the accept-set of a
37
- // running verifier — `ACCEPTED_SPEC_MAJORS.push("x/v1")` and the kernel starts
38
- // admitting `x/v1`. `Object.freeze` makes that throw (the package is ESM, so
39
- // always strict mode) and the `readonly string[]` type rejects it at compile
40
- // time. The elements are strings, so a shallow freeze is a deep one. Pinned by
41
- // test/accept-set.test.ts "the exported registry cannot be widened at runtime".
8
+ import type { ContentRole, Envelope, EnvelopeFault, VerificationResult, VerifyOptions } from "./types.js";
9
+ import { ENVELOPE_FAULTS } from "./types.js";
10
+
11
+ // Fail-closed accepted-profile registry (envelope-spec section 2): exactly these
12
+ // majors. Numeric minors within an accepted major are additive and tolerated.
13
+ // Accepting a profile is a one-entry addition here and only here.
14
+ // test/accept-set.test.ts holds it to cross-kernel/accept-set.json;
15
+ // sanning.mlflow/v1 is a known divergence (cross-kernel/README.md).
42
16
  //
43
- // `sanning.events/v2` replaced `sanning.events/v1` in the producer-to-agent cut
44
- // (envelope-spec A10, governance.md §8 W-3). The old major is rejected like any
45
- // other unknown one: DL-73 keeps no reader for it.
46
- export const ACCEPTED_SPEC_MAJORS: readonly string[] = Object.freeze([
47
- "sanning.agent/v1",
48
- "sanning.events/v2",
49
- ]);
50
-
51
- // Maximum JSON container-nesting depth a verifier will canonicalize. Inputs
52
- // nested deeper are rejected as malformed BEFORE canonicalization runs. This is
53
- // a NORMATIVE cross-kernel invariant (envelope-spec §2 shared-invariant 7): the
54
- // bound must be the same fixed constant in every kernel. Without it, a
55
- // deeply-nested body one kernel canonicalizes on its large native stack while
56
- // another hits its recursion limit (CPython defaults to ~1000 frames) yields a
57
- // split verdict — the same bytes reported `verified`/`failed` by one kernel and
58
- // crash/`malformed` by another, on a depth the producer of the bytes fully
59
- // controls. 128 is >10x any legitimate evidence structure (which nests <15) and
60
- // safely under the tightest kernel recursion limit, with headroom for the JCS
61
- // library's own frames-per-level. Mirrors the Python kernel's MAX_CANONICAL_DEPTH.
17
+ // Frozen because `verifyEnvelope` reads this array: a mutable export would let
18
+ // any code sharing the module widen a running verifier's accept-set.
19
+ export const ACCEPTED_SPEC_MAJORS: readonly string[] = Object.freeze(["sanning.events/v2"]);
20
+
21
+ // Maximum JSON nesting depth a verifier canonicalizes; deeper input is
22
+ // malformed before canonicalization runs (envelope-spec §2 shared invariant 7).
23
+ // Every kernel must use the same bound, or a producer-chosen depth splits the
24
+ // verdict between a kernel that recurses and one that hits its stack limit.
25
+ // Matches Python's MAX_CANONICAL_DEPTH.
62
26
  export const MAX_CANONICAL_DEPTH = 128;
63
27
 
64
- // Returned by `boundedInput` in place of a value that nests past the bound.
65
28
  // A symbol, so no input can forge it.
66
29
  const OVER_DEEP = Symbol("canonicalization input nests past the bound");
67
30
 
68
- // The serializer's own test (`object.toJSON instanceof Function`), on an object.
69
31
  function hasToJSON(node: unknown): boolean {
70
32
  return (
71
33
  node !== null &&
@@ -74,11 +36,8 @@ function hasToJSON(node: unknown): boolean {
74
36
  );
75
37
  }
76
38
 
77
- // Follow `node.toJSON()` until the value has none, which is what the serializer
78
- // does (`serialize(object.toJSON())`: in place, at the same depth). Bounded,
79
- // because the serializer burns a stack frame per hop: a `toJSON` that keeps
80
- // returning another `toJSON` object is over-deep, not something to follow
81
- // forever.
39
+ // Follow `node.toJSON()` as the serializer does, bounded: a chain of `toJSON`
40
+ // objects is over-deep, not something to follow forever.
82
41
  function resolveToJSON(node: unknown, max: number): unknown {
83
42
  let resolved = node;
84
43
  for (let hops = 0; hasToJSON(resolved); hops++) {
@@ -88,42 +47,17 @@ function resolveToJSON(node: unknown, max: number): unknown {
88
47
  return resolved;
89
48
  }
90
49
 
91
- // Bound nesting AND resolve `toJSON`, in one pass, returning the value the rest
92
- // of `jcs` must use: a structure with every `toJSON` already resolved, so
93
- // `canonicalize` never calls one itself. `OVER_DEEP` if any container nests
94
- // deeper than `max` levels. The root container is depth 1; each nested
95
- // array/object is one deeper; scalars have no children and never increase depth.
96
- //
97
- // Deliberately NOT recursive: this runs on already-parsed, possibly-hostile
98
- // input, so it must never itself overflow the call stack — it walks an explicit
99
- // heap stack instead.
100
- //
101
- // It returns a resolved COPY rather than checking the caller's value in place,
102
- // and that is the load-bearing part. `canonicalize` serializes `object.toJSON()`
103
- // IN PLACE OF the object and recurses into the result, so a guard that only
104
- // LOOKS at what `toJSON` returns and then lets the serializer ask again calls it
105
- // twice, and the two answers need not match: an unstable `toJSON` hands the
106
- // guard a shallow value and the serializer a deep one, which is the overflow the
107
- // bound exists to prevent, reached through the bound (proof#71 review). Copying
108
- // what was measured is what makes check and use the same value. It also settles
109
- // a getter, which is read once here and never re-read.
110
- //
111
- // Every object copy has a NULL prototype, and that is load-bearing too. RFC
112
- // 8785 has no reserved member names, and `JSON.parse` keeps `__proto__` as an
113
- // ordinary own key. On a plain `{}`, `copy["__proto__"] = x` runs the setter
114
- // inherited from `Object.prototype`: it replaces the copy's prototype and
115
- // creates no key, so the member and everything under it vanished from the
116
- // canonical bytes. Content there was covered by no hash and no signature, and
117
- // this kernel disagreed with the Python and Go kernels on the same bytes
118
- // (0.9.0; the same bug class as proof#31 and proof#34). With no prototype
119
- // there is no setter, so every key, `__proto__` included, lands as an own data
120
- // property. `canonicalize` reads the copy with `Object.keys` and `object[key]`
121
- // and tests `toJSON instanceof Function`, none of which a prototype affects
122
- // for JSON data, so no other input's output changes.
50
+ // Null prototype, so a `__proto__` key from `JSON.parse` lands as an own data
51
+ // property instead of running the inherited setter and vanishing from the
52
+ // canonical bytes (and from the hash and signature).
123
53
  function emptyObject(): Record<string, unknown> {
124
54
  return Object.create(null) as Record<string, unknown>;
125
55
  }
126
56
 
57
+ // Bound nesting and resolve `toJSON` in one pass, returning a resolved copy
58
+ // (or `OVER_DEEP`) for `jcs` to serialize. Iterative, because the input is
59
+ // hostile. A copy, so the check and the serialization see the same value: an
60
+ // unstable `toJSON` or getter cannot answer differently the second time.
127
61
  function boundedInput(value: unknown, max: number): unknown {
128
62
  const root = resolveToJSON(value, max);
129
63
  if (root === OVER_DEEP || root === null || typeof root !== "object") return root;
@@ -131,8 +65,6 @@ function boundedInput(value: unknown, max: number): unknown {
131
65
  const stack: Array<[object, Record<string, unknown> | unknown[], number]> = [[root, copy, 1]];
132
66
  while (stack.length > 0) {
133
67
  const [src, dst, depth] = stack.pop()!;
134
- // `Object.entries` is what `canonicalize` walks for an object, and for an
135
- // array it yields the same index keys its `reduce` visits.
136
68
  for (const [key, rawChild] of Object.entries(src)) {
137
69
  const child = resolveToJSON(rawChild, max);
138
70
  if (child === OVER_DEEP) return OVER_DEEP;
@@ -140,7 +72,7 @@ function boundedInput(value: unknown, max: number): unknown {
140
72
  (dst as Record<string, unknown>)[key] = child;
141
73
  continue;
142
74
  }
143
- if (depth + 1 > max) return OVER_DEEP; // a container nested past the bound
75
+ if (depth + 1 > max) return OVER_DEEP;
144
76
  const childCopy: Record<string, unknown> | unknown[] = Array.isArray(child) ? [] : emptyObject();
145
77
  (dst as Record<string, unknown>)[key] = childCopy;
146
78
  stack.push([child, childCopy, depth + 1]);
@@ -149,45 +81,25 @@ function boundedInput(value: unknown, max: number): unknown {
149
81
  return copy;
150
82
  }
151
83
 
152
- // Test whether `value` nests JSON containers deeper than `max` levels. Same walk
153
- // `jcs` runs, so the predicate and the serialization agree by construction.
154
- // Callers holding a value with a `toJSON` pay for it once per call: this runs it,
155
- // and so does the `jcs` that usually follows. Within `jcs` itself it runs once.
84
+ // Whether `value` nests JSON containers deeper than `max`. Same walk as `jcs`,
85
+ // so the predicate and the serialization agree.
156
86
  export function exceedsDepth(value: unknown, max: number): boolean {
157
87
  return boundedInput(value, max) === OVER_DEEP;
158
88
  }
159
89
 
160
90
  /**
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`.
91
+ * The bytes an envelope's inline `payload` commits to: `JCS(payload)`. The one
92
+ * place this is defined, so payload binding and chain linkage agree on a
93
+ * record's bytes. Throws where the payload cannot be canonicalized.
167
94
  */
168
95
  export function inlinePayloadBytes(envelope: { payload: unknown }): Uint8Array {
169
96
  return utf8(jcs(envelope.payload));
170
97
  }
171
98
 
172
- // RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
173
- // JS implementation; correctness is pinned by the conformance vectors.
174
- //
175
- // Nesting is bounded FIRST (MAX_CANONICAL_DEPTH): the depth guard throws before
176
- // `rejectLoneSurrogates` (recursive) or `canonicalize` (recursive) ever see the
177
- // input, so neither can overflow, and a too-deep input is a caller/input error
178
- // that the callers bucket as malformed (same contract as a malformed key).
179
- //
180
- // Both of them then see `boundedInput`'s output, never the caller's value. That
181
- // is what makes the two checks below true of what actually gets serialized: the
182
- // bound holds against an unstable `toJSON`, and a lone surrogate behind a
183
- // `toJSON` is seen rather than emitted (proof#71 and its review).
184
- //
185
- // Lone (unpaired) UTF-16 surrogates are REJECTED, never passed through: RFC
186
- // 8785 requires well-formed UTF-8 output, JS strings can carry lone
187
- // surrogates that `canonicalize` would emit as-is, and the sibling kernels
188
- // cannot represent them (Python raises on encode; Go's JSON decoder replaces
189
- // them). Reject-only is the one behavior all three kernels can share —
190
- // pinned here and by the corpus lone-surrogate negative.
99
+ // RFC 8785 (JCS) canonicalization. Nesting is bounded first, so neither
100
+ // recursive step can overflow; a too-deep input throws and callers treat it as
101
+ // malformed. Lone UTF-16 surrogates are rejected, never emitted: RFC 8785
102
+ // requires well-formed UTF-8, and no other kernel can represent them.
191
103
  export function jcs(value: unknown): string {
192
104
  const input = boundedInput(value, MAX_CANONICAL_DEPTH);
193
105
  if (input === OVER_DEEP) {
@@ -203,58 +115,23 @@ export function jcs(value: unknown): string {
203
115
  return canonical;
204
116
  }
205
117
 
206
- // The byte-order mark, as a CHARACTER. RFC 8785 output never begins with
207
- // U+FEFF, so bytes that do are not conformant whatever a JSON reader makes of
208
- // them.
118
+ // RFC 8785 output never begins with U+FEFF.
209
119
  const JCS_BOM = "";
210
120
 
211
- /**
212
- * Read bytes that claim to be `jcs(value)` (RFC 8785), strictly. The Python
213
- * twin is `jcs_loads` in `src/sanning_proof/canonicalize.py`, and every site in
214
- * either kernel that reads bytes a producer signed goes through one of the two.
215
- *
216
- * ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. Five checks
217
- * carry it, none of them left to a decoder's defaults: strict UTF-8, no leading
218
- * byte-order mark, no non-finite literal (this kernel's `JSON.parse` rejects
219
- * `NaN`, `Infinity` and `-Infinity` natively, and Python names them in a
220
- * `parse_constant` hook), no object that repeats a member name
221
- * (`DuplicateKeyError`, proof#144), and no unpaired surrogate in the parsed
222
- * value.
223
- *
224
- * THE FOURTH CHECK IS NOT THE FIRST ONE TWICE. The strict decode refuses a
225
- * surrogate written as raw bytes; it cannot see one written as the ASCII
226
- * escape `\ud800`, which `JSON.parse` turns into a lone code unit AFTER the
227
- * decode has passed. `"c\ud800x"` is pure ASCII on the wire, so this reader
228
- * accepted it while its own text above said it does not, and one poisoned
229
- * record then deleted the Python kernel's whole chain reading (the gate on
230
- * proof#154). Both spellings are the same malformed record, and both are
231
- * refused here.
232
- *
233
- * `new TextDecoder()` alone is none of that. It replaces every invalid sequence
234
- * with U+FFFD and strips a BOM. Python's `json.loads(<bytes>)` is not it
235
- * either, in the opposite direction: CPython sniffs the encoding and decodes
236
- * with `errors="surrogatepass"`, so it strips a UTF-8 BOM, accepts a UTF-16 or
237
- * UTF-32 body, and passes a lone surrogate and a CESU-8 pair through. Both
238
- * defaults have shipped a split verdict on one pack: a raw `0x80` inside a
239
- * stamp body read `verified` here and `failed` there (gate 4), and then a
240
- * `BOM + JCS(record)` checkpoint read `verified` there and `mixed` here (gate
241
- * 5), both on proof#140. JCS is defined over UTF-8 and I-JSON (RFC 7493)
242
- * forbids a lone surrogate, so each of those inputs is MALFORMED rather than
243
- * another spelling of the same record.
244
- *
245
- * Throws for anything that is not conformant; every call site treats a throw as
246
- * "this reader cannot read these bytes" and names the check that failed.
247
- */
248
- /**
249
- * Bytes as strict UTF-8, a leading byte-order mark KEPT as a character.
250
- * `utf8_strict` in the Python twin's `canonicalize.py` is the same rule, and
251
- * `jcsParse` below performs the identical decode. Throws on bytes that are not
252
- * UTF-8.
253
- */
121
+ /** Bytes as strict UTF-8, a leading byte-order mark kept as a character. Throws on invalid UTF-8. */
254
122
  export function utf8Strict(bytes: Uint8Array): string {
255
123
  return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
256
124
  }
257
125
 
126
+ /**
127
+ * Read bytes that claim to be `jcs(value)`, strictly: valid UTF-8, no leading
128
+ * BOM, no non-finite literal, no repeated member name, and no unpaired
129
+ * surrogate (including one written as a `\ud800` escape). Every site that reads
130
+ * bytes a producer signed goes through this or its Python twin `jcs_loads`, so
131
+ * the kernels cannot split on decoder defaults.
132
+ *
133
+ * @throws When the bytes are not conformant.
134
+ */
258
135
  export function jcsParse(bytes: Uint8Array): unknown {
259
136
  const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
260
137
  if (text.startsWith(JCS_BOM)) throw new Error("jcsParse: leading byte-order mark");
@@ -264,97 +141,8 @@ export function jcsParse(bytes: Uint8Array): unknown {
264
141
  }
265
142
 
266
143
  /**
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
-
347
- /**
348
- * A JSON object that names one member twice (I-JSON, RFC 7493 section 2.3).
349
- * The Python twin is `DuplicateKey` in `canonicalize.py`, and the message is
350
- * the same kernel wording in both: `duplicate key ` and the name as
351
- * `renderKey` spells it.
352
- *
353
- * JCS (RFC 8785) is defined over I-JSON, and I-JSON forbids duplicate member
354
- * names, so bytes that repeat a name are MALFORMED rather than another spelling
355
- * of the last value. `JSON.parse` and Python's `json.loads` both keep the last
356
- * value and say nothing, which let a bundle carrying `"spec_version":"junk"`
357
- * before its real `spec_version` read `verified` in both kernels (proof#144).
144
+ * A JSON object that names one member twice. I-JSON (RFC 7493 §2.3) forbids it,
145
+ * so the bytes are malformed rather than a spelling of the last value.
358
146
  */
359
147
  export class DuplicateKeyError extends Error {
360
148
  readonly key: string;
@@ -364,17 +152,13 @@ export class DuplicateKeyError extends Error {
364
152
  }
365
153
  }
366
154
 
367
- // The longest rendered name a message carries, in characters, before it is
368
- // cut. The rendering is pure ASCII, so the cut lands on the same character in
369
- // both kernels.
155
+ // Rendering is pure ASCII, so the cut lands on the same character in every kernel.
370
156
  const RENDERED_KEY_MAX = 64;
371
157
 
372
158
  /**
373
- * A member name as a quoted, pure-ASCII string, identically in both kernels
374
- * (`render_key` in the Python twin). Printable ASCII other than `"` and `\` is
375
- * kept; `"` and `\` are backslash-escaped; every other UTF-16 code unit is
376
- * `\uXXXX` in lower-case hex. A rendering longer than 64 characters is cut
377
- * there and `...` follows the closing quote.
159
+ * A member name as a quoted, pure-ASCII string, identical in both kernels.
160
+ * Printable ASCII is kept (`"` and `\` escaped), every other code unit is
161
+ * `\uXXXX`, and anything past 64 characters is cut with `...`.
378
162
  */
379
163
  export function renderKey(key: string): string {
380
164
  let body = "";
@@ -390,13 +174,10 @@ export function renderKey(key: string): string {
390
174
  }
391
175
 
392
176
  /**
393
- * `JSON.parse(text)`, refusing an object that repeats a member name. The Python
394
- * twin is `loads_refusing_duplicates` in `canonicalize.py`.
177
+ * `JSON.parse(text)`, refusing an object that repeats a member name. Parses
178
+ * first, so non-JSON fails as JSON, then rescans for repeats.
395
179
  *
396
- * `JSON.parse` cannot see a repeat, because it keeps the last value, so the
397
- * text is scanned once more AFTER it parsed. The parse runs first, so text
398
- * that is not JSON at all fails as JSON and never as a duplicate, as it does in
399
- * the Python twin. Throws `DuplicateKeyError`.
180
+ * @throws DuplicateKeyError
400
181
  */
401
182
  export function parseRefusingDuplicates(text: string): unknown {
402
183
  const value: unknown = JSON.parse(text);
@@ -406,23 +187,13 @@ export function parseRefusingDuplicates(text: string): unknown {
406
187
  }
407
188
 
408
189
  /**
409
- * The repeated member name to report for text `JSON.parse` has already
410
- * accepted, or `undefined` when no object repeats one.
411
- *
412
- * WHICH ONE, because both kernels must name the same one. Objects are checked
413
- * as they CLOSE, innermost first, which is the order Python's
414
- * `object_pairs_hook` sees them in; inside one object the name reported is the
415
- * first whose repeat comes earliest. Names compare after unescaping, so `"a"`
416
- * and `"\u0061"` are one name, as they are to both parsers.
417
- *
418
- * ITERATIVE, with an explicit stack, for the reason `rejectUnpairedSurrogates`
419
- * is: this walks bytes a producer wrote. It relies on the text being valid
420
- * JSON, which is why it runs only after `JSON.parse` succeeded: outside a
421
- * string, only `{`, `[`, `}`, `]`, `,` and `"` change what comes next.
190
+ * The repeated member name to report, or `undefined`. Objects are checked as
191
+ * they close, innermost first, matching Python's `object_pairs_hook`, so both
192
+ * kernels name the same key. Iterative, because the text is producer-written;
193
+ * runs only on text `JSON.parse` accepted.
422
194
  */
423
195
  export function firstDuplicateKey(text: string): string | undefined {
424
- // `names` is null for an array. `expectName` is true where the next string
425
- // is a member name rather than a value.
196
+ // `names` is null for an array; `expectName` marks the next string as a key.
426
197
  const stack: Array<{ names: Set<string> | null; expectName: boolean; dup?: string }> = [];
427
198
  const n = text.length;
428
199
  let i = 0;
@@ -464,15 +235,8 @@ export function firstDuplicateKey(text: string): string | undefined {
464
235
  }
465
236
 
466
237
  /**
467
- * Refuse a parsed value carrying an unpaired surrogate, in a key or a value, at
468
- * any depth. The Python twin is `_reject_unpaired_surrogates`, and the two name
469
- * the failure with the same words.
470
- *
471
- * Deliberately ITERATIVE. `rejectLoneSurrogates` below walks a value this
472
- * kernel is about to serialize, which `boundedInput` has already depth-bounded.
473
- * This one walks whatever `JSON.parse` returned for a producer's bytes, with no
474
- * bound in front of it, so a recursive walk would turn a deeply-nested record
475
- * into a `RangeError` out of the reader itself.
238
+ * Refuse a parsed value carrying an unpaired surrogate, in a key or value, at
239
+ * any depth. Iterative: the parsed value has no depth bound in front of it.
476
240
  */
477
241
  function rejectUnpairedSurrogates(value: unknown): void {
478
242
  const stack: unknown[] = [value];
@@ -491,9 +255,7 @@ function rejectUnpairedSurrogates(value: unknown): void {
491
255
  }
492
256
  }
493
257
 
494
- // Walk every string in the value (keys and values). The check must run on
495
- // the INPUT: `canonicalize` escapes a lone surrogate as `\udXXX` text in its
496
- // output, so the malformed code unit is invisible after serialization.
258
+ // Must run on the input: `canonicalize` escapes a lone surrogate, hiding it.
497
259
  function rejectLoneSurrogates(value: unknown): void {
498
260
  if (typeof value === "string") {
499
261
  if (hasLoneSurrogate(value)) {
@@ -521,7 +283,7 @@ function hasLoneSurrogate(s: string): boolean {
521
283
  if (c >= 0xd800 && c <= 0xdbff) {
522
284
  const next = i + 1 < s.length ? s.charCodeAt(i + 1) : 0;
523
285
  if (next >= 0xdc00 && next <= 0xdfff) {
524
- i++; // valid pair
286
+ i++;
525
287
  continue;
526
288
  }
527
289
  return true; // high surrogate without a low
@@ -531,13 +293,9 @@ function hasLoneSurrogate(s: string): boolean {
531
293
  return false;
532
294
  }
533
295
 
534
- // Grammar-strict accept check (envelope-spec §2): `<major>` exactly, or
535
- // `<major>.<minor>` where minor is `[0-9]+`. A non-numeric minor suffix is
536
- // malformed, not a tolerated future version — matching the Python kernel's
537
- // 0.1.1 semantics (the TS/Go fail-open lenience was closed deliberately).
538
- //
539
- // `accepted` NARROWS the registry and never widens it, as the Python twin's
540
- // `spec_version_supported(spec_version, accepted)` does (P-10, 0.13.0).
296
+ // Grammar-strict accept check (envelope-spec section 2): `<major>` or
297
+ // `<major>.<digits>`. Any other suffix is malformed. `accepted` narrows the
298
+ // registry and never widens it.
541
299
  export function specVersionSupported(
542
300
  specVersion: string,
543
301
  accepted?: readonly string[],
@@ -553,20 +311,9 @@ export function specVersionSupported(
553
311
  });
554
312
  }
555
313
 
556
- // `signed_at` format (envelope-spec §2: "RFC 3339 UTC, `Z`"), proof#95. The
557
- // accepted form is RFC 3339 `date-time` narrowed three ways, identically in
558
- // every kernel:
559
- // - the zone is an uppercase `Z`: never a numeric offset (not even
560
- // `+00:00`), never a lowercase `z` or `t`;
561
- // - the date is a real proleptic Gregorian date from 0001-01-01 to
562
- // 9999-12-31, so February 29 only in a leap year;
563
- // - second 60 is refused. RFC 3339 allows it at a real leap second, but
564
- // which instants those were needs a table, and JavaScript's `Date`,
565
- // Python's `datetime` and Go's `time` cannot hold one, so a verified `:60`
566
- // would fail the first consumer that parses it.
567
- // Fractional seconds of any length are allowed. Digits are ASCII `[0-9]`
568
- // (Python's `\d` matches any Unicode digit, so the Python kernel spells it the
569
- // same way).
314
+ // `signed_at` (envelope-spec section 2): RFC 3339 `date-time`, identically in every
315
+ // kernel: uppercase `Z` only, a real date from 0001-01-01 to 9999-12-31, and no
316
+ // second 60 (no common runtime can hold a leap second). ASCII digits only.
570
317
  const RFC3339_UTC = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?Z$/;
571
318
  const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
572
319
 
@@ -581,28 +328,12 @@ export function isRfc3339UtcInstant(value: unknown): boolean {
581
328
  return day <= days && hour <= 23 && minute <= 59 && second <= 59;
582
329
  }
583
330
 
584
- // The skeleton fields whose FORMAT §2 fixes and no other check reaches: a
585
- // correctly signed envelope with `signed_at: "2026-02-30T00:00:00Z"` or
586
- // `environment: "prod\u0000uction"` passes the signature and the payload
587
- // binding, so without this it verified in full. Each is checked only when
588
- // present: §2 flags an absent `signed_at` as non-conformant rather than
589
- // rejecting it, and `environment` is optional in the skeleton.
590
- //
591
- // `public_key` AND `signature` ARE CHECKED HERE TOO (proof#146, under
592
- // proof#135 option B). The strict `hexToBytes` inside `ed25519Verify` is what
593
- // makes the three kernels agree on the VERDICT; naming the spelling is what
594
- // makes them agree on the REASON. On its own a respelled key reads `Ed25519
595
- // signature verification failed`, which sends an auditor looking for a forged
596
- // signature that is not there. proof#135 was opened against this function's
597
- // Python twin, for covering `signed_at` and `environment` and not `public_key`.
598
- //
599
- // ONLY A STRING IS CHECKED, the same rule `envelopeHexError` follows one layer
600
- // up: an absent or non-string `public_key` is already a failed signature in all
601
- // three kernels (Go names it "missing signature or public_key"), and naming it
602
- // here as a spelling would state one fact twice and in the wrong words. CASE IS
603
- // NOT CHECKED: proof#135 pinned the charset and left the case open.
604
- //
605
- // Own properties only, because those are the members `jcs` signs over.
331
+ // The skeleton fields whose format §2 fixes and no other check reaches; each is
332
+ // checked only when present. A well-signed envelope with an impossible
333
+ // `signed_at` would otherwise verify in full. `public_key` and `signature` are
334
+ // checked here so a respelled key is named as a spelling, not reported as a
335
+ // forged signature; only strings are checked, and case is not. Own properties
336
+ // only, because those are what `jcs` signs.
606
337
  function skeletonFieldErrors(env: Envelope): string[] {
607
338
  const fields = env as unknown as Record<string, unknown>;
608
339
  const own = (k: string): unknown => (Object.prototype.hasOwnProperty.call(fields, k) ? fields[k] : undefined);
@@ -613,72 +344,60 @@ function skeletonFieldErrors(env: Envelope): string[] {
613
344
  ] as const) {
614
345
  const value = own(name);
615
346
  if (typeof value === "string" && !isHexField(value, nibbles)) {
616
- out.push(`malformed ${name}: not ${nibbles} hex characters (envelope-spec §2)`);
347
+ out.push(`malformed ${name}: not ${nibbles} hex characters (envelope-spec section 2)`);
617
348
  }
618
349
  }
619
350
  const signedAt = own("signed_at");
620
351
  if (signedAt !== undefined && !isRfc3339UtcInstant(signedAt)) {
621
- out.push("malformed signed_at: not an RFC 3339 UTC date-time with Z naming a real date and time (envelope-spec §2)");
352
+ out.push("malformed signed_at: not an RFC 3339 UTC date-time with Z naming a real date and time (envelope-spec section 2)");
622
353
  }
623
354
  const environment = own("environment");
624
355
  if (environment !== undefined && environment !== "dev" && environment !== "production") {
625
- out.push('malformed environment: not "dev" or "production" (envelope-spec §2)');
356
+ out.push('malformed environment: not "dev" or "production" (envelope-spec section 2)');
626
357
  }
627
358
  out.push(...identityFieldErrors(own));
628
359
  return out;
629
360
  }
630
361
 
631
- // The four fields envelope-spec §2 types and A23 pins (proof#97, proof#155).
632
- // Every pattern is anchored at both ends with no `m` flag, so `$` never matches
633
- // before a trailing newline, and uses ASCII classes only.
362
+ // The four identity fields envelope-spec §2 types. Every pattern is anchored at
363
+ // both ends, no `m` flag, ASCII classes only.
634
364
  //
635
- // `event_id`: "UUID v4, lowercase", the pattern both SDKs already refuse to
636
- // sign anything else under (`UUID_V4_RE` in `@sanning/anchor`, `_UUID_V4_RE` in
637
- // `sanning-anchor`); the Go agent's `uuid.NewString` emits the same.
365
+ // `event_id`: UUID v4, lowercase, as both SDKs sign.
638
366
  const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
639
- // `payload_hash`: sha256-hex, which invariant 2 defines as lowercase hex with no
640
- // `0x`. Lower case is pinned here, unlike `public_key`, because every kernel
641
- // compares the value against a digest it recomputes in lower case: an
642
- // upper-case `payload_hash` already failed wherever there was material.
367
+ // `payload_hash`: lowercase sha256 hex. Unlike `public_key`, case is pinned:
368
+ // every kernel compares it against a lowercase recomputed digest.
643
369
  const SHA256_HEX = /^[0-9a-f]{64}$/;
644
- // `payload_ref`: §2 says "string (URI)" and names no grammar, so this pins what
645
- // both SDKs emit: an ASCII scheme, a colon, then RFC 3986 unreserved, reserved
646
- // and percent-encoded characters, at most 2048 of them in all (`REF_RE` and
647
- // `MAX_REF_LEN` in `@sanning/anchor`'s `ref.ts`).
370
+ // `payload_ref`: §2 names no grammar, so this pins what the SDKs emit: an ASCII
371
+ // scheme, a colon, RFC 3986 characters, at most 2048 in all.
648
372
  const URI_REF = /^[A-Za-z][A-Za-z0-9+.-]*:[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]*$/;
649
373
  const MAX_URI_REF = 2048;
650
374
  // `subject`: "string fields <=128 chars matching `^[A-Za-z0-9_.:-]+$`".
651
375
  const SUBJECT_FIELD = /^[A-Za-z0-9_.:-]{1,128}$/;
652
376
 
653
- // `event_id`, `payload_hash`, `payload_ref` and `subject` (A23). Each is checked
654
- // only when present, and present includes a JSON null, as for `signed_at`.
655
- // `payload_hash` is the exception: only a STRING is checked here, because a
656
- // missing or non-string one is already `missing payload_hash` below, and naming
657
- // it twice would state one fact in two sets of words. `subject` fixes only its
658
- // STRING members: §2 types no other, so a member of another type is left as it
659
- // was. Own members only, as everywhere in this file. The messages are
660
- // byte-identical to the Python and Go kernels'.
377
+ // Each checked only when present (including JSON null). `payload_hash` only as
378
+ // a string: a missing one is reported below. `subject` checks only its string
379
+ // members. Messages are byte-identical across kernels.
661
380
  function identityFieldErrors(own: (k: string) => unknown): string[] {
662
381
  const out: string[] = [];
663
382
  const eventId = own("event_id");
664
383
  if (eventId !== undefined && !(typeof eventId === "string" && UUID_V4.test(eventId))) {
665
- out.push("malformed event_id: not a lowercase UUID v4 (envelope-spec §2)");
384
+ out.push("malformed event_id: not a lowercase UUID v4 (envelope-spec section 2)");
666
385
  }
667
386
  const payloadHash = own("payload_hash");
668
387
  if (typeof payloadHash === "string" && !SHA256_HEX.test(payloadHash)) {
669
- out.push("malformed payload_hash: not 64 lowercase hex characters (envelope-spec §2)");
388
+ out.push("malformed payload_hash: not 64 lowercase hex characters (envelope-spec section 2)");
670
389
  }
671
390
  const payloadRef = own("payload_ref");
672
391
  if (
673
392
  payloadRef !== undefined &&
674
393
  !(typeof payloadRef === "string" && payloadRef.length <= MAX_URI_REF && URI_REF.test(payloadRef))
675
394
  ) {
676
- out.push("malformed payload_ref: not a URI of at most 2048 RFC 3986 characters (envelope-spec §2)");
395
+ out.push("malformed payload_ref: not a URI of at most 2048 RFC 3986 characters (envelope-spec section 2)");
677
396
  }
678
397
  const subject = own("subject");
679
398
  if (subject !== undefined && !isWellFormedSubject(subject)) {
680
399
  out.push(
681
- "malformed subject: not an object whose string fields are 1 to 128 characters of [A-Za-z0-9_.:-] (envelope-spec §2)",
400
+ "malformed subject: not an object whose string fields are 1 to 128 characters of [A-Za-z0-9_.:-] (envelope-spec section 2)",
682
401
  );
683
402
  }
684
403
  return out;
@@ -693,16 +412,14 @@ function isWellFormedSubject(subject: unknown): boolean {
693
412
  return true;
694
413
  }
695
414
 
696
- // The content hash(es) an envelope commits to, by event type — the values a
697
- // reverse lookup can match the user's in-browser file hash against.
698
- // asset_registered -> payload.hash (the registered, known-good bytes)
699
- // asset_missing -> payload.baseline.hash (last known-good bytes that vanished)
700
- // tamper_detected -> payload.observed.hash (the tampered bytes that were flagged)
701
- // -> payload.baseline.hash (the known-good bytes it diverged from)
702
- // Other event types commit to no asset content hash.
415
+ // The content hashes an envelope commits to, by event type: what a reverse
416
+ // lookup matches a user's file hash against.
417
+ // asset_registered -> payload.hash (the registered bytes)
418
+ // asset_missing -> payload.baseline.hash (last known-good bytes)
419
+ // tamper_detected -> payload.observed.hash (the flagged bytes)
420
+ // -> payload.baseline.hash (the known-good bytes)
703
421
  export function contentHashes(env: Envelope): { role: ContentRole; hash: string }[] {
704
- // Minimal-disclosure / external-commitment envelopes have no inline payload
705
- // (and no event_type) — they commit to no envelope-readable content hash.
422
+ // External-commitment envelopes have no inline payload, so no hash here.
706
423
  const p = (env.payload ?? {}) as {
707
424
  hash?: unknown;
708
425
  baseline?: { hash?: unknown };
@@ -733,46 +450,29 @@ export function contentHashes(env: Envelope): { role: ContentRole; hash: string
733
450
  return out;
734
451
  }
735
452
 
736
- // Verify an envelope — both binding modes (envelope-spec §3), one rule,
737
- // mirroring the Python reference kernel exactly:
453
+ // Verify an envelope in either binding mode (envelope-spec §3):
738
454
  //
739
- // - envelope carries `payload` → inline check: recompute
740
- // SHA-256(JCS(payload)) and compare to payload_hash
741
- // - caller supplies `payloadBytes` → external check:
742
- // SHA-256(payloadBytes) must equal payload_hash
743
- // - both available → both must pass
744
- // - neither → payloadHashOk = null and the
745
- // verdict does NOT fail: "signature-valid, semantics-undetermined"
746
- // (§3.1/§6.2). The signature alone still proves who signed what bytes.
455
+ // - inline `payload` -> SHA-256(JCS(payload)) must equal payload_hash
456
+ // - supplied `payloadBytes` -> SHA-256(payloadBytes) must equal payload_hash
457
+ // - both -> both must pass
458
+ // - neither -> payloadHashOk = null, and the verdict does not
459
+ // fail: the signature still proves who signed what (§3.1, §6 item 2).
747
460
  //
748
- // payload_hash itself MUST be present (a string) in every mode — its
749
- // absence is rejected per envelope-spec §2, independent of whether there is
750
- // material to compare it against.
751
- //
752
- // Binding-mode detection is structural, not registry-driven: mode confusion
753
- // is closed by the signed scope itself (an inline payload is INSIDE the
754
- // signature, so stripping it to fake "external" — or injecting one to fake
755
- // "inline" — breaks the signature).
756
- //
757
- // The second argument is a VerifyOptions object ({ payloadBytes,
758
- // expectedContentHash, acceptedSpecMajors }). Until 0.13.0 it could also be a
759
- // bare `expectedContentHash` string; that overload is gone (P-10), so the two
760
- // kernels take one options shape: write `{ expectedContentHash: hash }`.
461
+ // `payload_hash` must be present in every mode. Mode detection is structural:
462
+ // an inline payload is inside the signed scope, so adding or stripping one
463
+ // breaks the signature.
761
464
  export async function verifyEnvelope(
762
465
  env: Envelope,
763
466
  options?: VerifyOptions,
764
467
  ): Promise<VerificationResult> {
765
468
  if (typeof options === "string") {
766
469
  throw new TypeError(
767
- "verifyEnvelope: pass { expectedContentHash } rather than a bare string (0.13.0)",
470
+ "verifyEnvelope: pass { expectedContentHash } rather than a bare string",
768
471
  );
769
472
  }
770
473
  const opts: VerifyOptions = options ?? {};
771
- // REFUSE an unknown option key rather than ignore it. `expected_content_hash`
772
- // — the Python kernel's spelling — previously fell through as `undefined`, so
773
- // the content bind silently never ran and the caller got a clean pass with no
774
- // errors at all. A check that quietly does not happen is worse than one that
775
- // fails: the caller believes they verified an artifact they never checked.
474
+ // Refuse an unknown option rather than ignore it: a misspelled option would
475
+ // otherwise skip its check and report a clean pass.
776
476
  const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash", "acceptedSpecMajors"]);
777
477
  const unknownOptions = Object.keys(opts).filter((k) => !KNOWN_VERIFY_OPTIONS.has(k));
778
478
  if (unknownOptions.length > 0) {
@@ -782,36 +482,24 @@ export async function verifyEnvelope(
782
482
  "Option keys are camelCase in the TypeScript kernel; the Python kernel uses snake_case.",
783
483
  );
784
484
  }
785
- // ── A verifier never raises on an input (proof#85 item 3) ──
786
- //
787
- // The option check above is the one exception and stays OUTSIDE this try: an
788
- // unknown option key is the CALLER's mistake, not the envelope's, and turning
789
- // a typo into a verdict is how a check silently never runs.
790
- //
791
- // Below, every field read is a read of attacker-shaped data. `env` is typed
792
- // as an `Envelope`, but nothing enforces that at runtime, and an object whose
793
- // property reads throw (a `get`-trap `Proxy`, a getter that raises, an
794
- // exotic object from another realm) passed the null/non-object guard and then
795
- // escaped as a raised exception. An auditor cannot tell a raised exception
796
- // apart from a broken tool, which is the one thing this kernel exists not to
797
- // hand them. CLAUDE.md states the rule flatly: malformed envelopes return a
798
- // failed `VerificationResult`, they do not throw. This closes the class, not
799
- // the one `Proxy` case: #77 hardened the anchor-trace path for the same
800
- // reason, and a per-input patch would have to be written again for the next
801
- // exotic object.
485
+ // A verifier never throws on an input: a raised exception is
486
+ // indistinguishable from a broken tool. Every field read below is of
487
+ // attacker-shaped data (a throwing getter or `Proxy` included). The option
488
+ // check above stays outside, because a bad option is the caller's mistake.
802
489
  try {
803
490
  return await verifyEnvelopeChecked(env, opts);
804
491
  } catch {
805
492
  return {
806
493
  ok: false,
807
494
  specVersionOk: false,
808
- // `null`, never `false`: nothing was read, so nothing was determined.
809
- // The same rule the not-a-JSON-object branch below follows.
495
+ // `null`: nothing was read, so nothing was determined.
810
496
  payloadHashOk: null,
811
497
  signatureOk: false,
812
498
  contentHashOk: opts.expectedContentHash === undefined ? null : false,
813
499
  contentRole: null,
814
500
  errors: ["envelope could not be read"],
501
+ fault: "not_an_envelope",
502
+ faults: ["not_an_envelope"],
815
503
  };
816
504
  }
817
505
  }
@@ -823,40 +511,26 @@ async function verifyEnvelopeChecked(
823
511
  const expectedContentHash = opts.expectedContentHash;
824
512
  const errors: string[] = [];
825
513
 
826
- // Guard a malformed input (null / non-object / array) up front — every field
827
- // access below assumes an object. A hostile gateway or a hand-edited report
828
- // can supply anything; treat it as "not verified," never a thrown exception.
514
+ // Malformed input is "not verified", never a thrown exception.
829
515
  if (env === null || typeof env !== "object" || Array.isArray(env)) {
830
516
  return {
831
517
  ok: false,
832
518
  specVersionOk: false,
833
- // `null`, not `false` (proof#62). This field carries "we cannot tell"
834
- // against "it is wrong", and that distinction is what the whole
835
- // minimum-disclosure posture rests on: a withheld record is `null` BY
836
- // DESIGN and must never read as a failure (`evidence-bundle.md` §3.1,
837
- // §6.2). There is no envelope here, so there is nothing to bind and
838
- // nothing was determined. `false` asserted a binding had been checked
839
- // and had failed, which is a claim this branch cannot support.
840
- //
841
- // The Python kernel already answered `None`, so the two disagreed on
842
- // the dimension `verify-page` renders: an auditor on one CLI saw a
843
- // dimension that could not be determined, and on the other one that
844
- // failed, for the same bytes. `ok` is `false` either way, so no rollup
845
- // verdict changes; what changes is what we tell the reader.
846
- //
847
- // `contentHashOk` on the next line already follows this rule.
519
+ // `null`, not `false`: with no envelope nothing was determined, and a
520
+ // withheld binding must never read as a failure (envelope-spec.md §3.1).
848
521
  payloadHashOk: null,
849
522
  signatureOk: false,
850
523
  contentHashOk: expectedContentHash === undefined ? null : false,
851
524
  contentRole: null,
852
525
  errors: ["envelope is not a JSON object"],
526
+ fault: "not_an_envelope",
527
+ faults: ["not_an_envelope"],
853
528
  };
854
529
  }
855
530
 
856
531
  const specVersionOk = specVersionSupported(env.spec_version, opts.acceptedSpecMajors);
857
532
  if (!specVersionOk) {
858
- // Absent or null reads "missing", as in the Python kernel. Everything else
859
- // is quoted the way both kernels quote a value in a finding.
533
+ // Absent or null reads "missing"; anything else is quoted.
860
534
  errors.push(
861
535
  env.spec_version === undefined || env.spec_version === null
862
536
  ? "missing spec_version"
@@ -864,46 +538,31 @@ async function verifyEnvelopeChecked(
864
538
  );
865
539
  }
866
540
 
867
- // Skeleton field format (envelope-spec §2, proof#95): a present `signed_at`
868
- // or `environment` that is not what §2 says fails the verdict. There is no
869
- // result field for it, so the verdict shape is unchanged for every envelope
870
- // the spec allows; the reason is in `errors`.
541
+ // A present `signed_at` or `environment` in the wrong format fails the verdict, as does
542
+ // a missing `payload_hash`.
871
543
  const fieldErrors = skeletonFieldErrors(env);
544
+ if (typeof env.payload_hash !== "string") {
545
+ fieldErrors.push("missing payload_hash (envelope-spec section 2: required in every profile)");
546
+ }
872
547
  errors.push(...fieldErrors);
873
548
  const fieldsOk = fieldErrors.length === 0;
874
549
 
875
- // Check 1 — Payload binding (both modes; see header).
876
- //
877
- // payload_hash MUST be present and a string in every mode — envelope-spec
878
- // §2: "a verifier cannot proceed without spec_version / public_key /
879
- // signature / payload_hash and MUST reject their absence." All three
880
- // kernels enforce this (the Python reference's prior compare-only-if-present
881
- // lenience was the bug; §2 is binding).
882
- //
883
- // When present, it is COMPARED only against material that exists — an
884
- // inline payload, supplied committed bytes, or both. With neither, the
885
- // binding is undetermined (null): "signature-valid, semantics-undetermined"
886
- // (§3.1/§6.2). null does not fail the verdict; absence does.
550
+ // Check 1: payload binding. `payload_hash` is compared only against material that
551
+ // exists; with none it is undetermined (`null`), which does not fail. A hash that is not
552
+ // 64 hex characters binds nothing either; one in upper case is compared in lower case, so
553
+ // a hash in the wrong form reads as a form error, never as bytes that changed.
887
554
  //
888
- // THE ENVELOPE'S OWN `payload_hash` IS NOT ECHOED INTO THESE REASONS, and
889
- // that is a change (proof#154 F4). It is producer-controlled text of any
890
- // length, and it went into an auditor-facing error list verbatim: a producer
891
- // could put a newline and a forged `payload_hash: OK envelope verified`
892
- // line, or an ANSI escape that paints `VERIFIED` green, into what the
893
- // verifier prints. No other field in this file echoes its value, the Python
894
- // twin names the same failure without one, and the value is in the envelope
895
- // the auditor already holds. Neither is the recomputed digest: the reason is
896
- // the Python kernel's sentence, word for word (proof#150).
897
- let payloadHashOk: boolean | null;
898
- if (typeof env.payload_hash !== "string") {
899
- payloadHashOk = false;
900
- errors.push("missing payload_hash (envelope-spec §2: required in every profile)");
901
- } else {
555
+ // The envelope's own `payload_hash` is never echoed into these reasons: it is
556
+ // producer-controlled text, and a newline or ANSI escape could forge lines in
557
+ // what the verifier prints.
558
+ let payloadHashOk: boolean | null = null;
559
+ if (typeof env.payload_hash === "string" && HEX64.test(env.payload_hash)) {
560
+ const storedHash = env.payload_hash.toLowerCase();
902
561
  const checks: boolean[] = [];
903
562
  if ("payload" in env && env.payload !== undefined) {
904
563
  try {
905
564
  const recomputed = await sha256Hex(inlinePayloadBytes(env as { payload: unknown }));
906
- checks.push(recomputed === env.payload_hash);
565
+ checks.push(recomputed === storedHash);
907
566
  } catch {
908
567
  checks.push(false);
909
568
  errors.push("payload is not JCS-canonicalizable");
@@ -911,26 +570,19 @@ async function verifyEnvelopeChecked(
911
570
  }
912
571
  if (opts.payloadBytes !== undefined) {
913
572
  const external = await sha256Hex(opts.payloadBytes);
914
- checks.push(external === env.payload_hash);
573
+ checks.push(external === storedHash);
915
574
  }
916
575
  payloadHashOk = checks.length > 0 ? checks.every(Boolean) : null;
917
- // One sentence for a mismatch against the inline payload, the supplied
918
- // bytes or both, and none when the payload could not be canonicalized: the
919
- // Python kernel's rule, word for word (proof#150).
576
+ // One sentence for any mismatch; none when canonicalization failed.
920
577
  if (payloadHashOk === false && !errors.some((e) => e.startsWith("payload is not"))) {
921
578
  errors.push("payload_hash does not match the committed bytes");
922
579
  }
923
580
  }
924
581
 
925
- // Check 2 — Signature Confirmed: Ed25519 over the signed scope, which is
926
- // JCS(envelope minus `signature` minus `co_signatures`) per envelope-spec §2.
927
- // The co_signatures carve-out (§7.1) lets a countersignature be added without
928
- // invalidating the primary signature; the field is reserved/default-absent.
929
- // The corpus has no co-signed vectors, so this strip is pinned by an explicit
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.
582
+ // Check 2: Ed25519 over JCS(envelope minus `signature` and `co_signatures`)
583
+ // (§2). The `co_signatures` carve-out (§7.1) lets a countersignature be added
584
+ // later; a unit test pins it. The refusal names the clause of the Ed25519 rule
585
+ // that broke (§7).
934
586
  let signatureOk = false;
935
587
  try {
936
588
  const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
@@ -938,33 +590,24 @@ async function verifyEnvelopeChecked(
938
590
  signatureOk = refusal === null;
939
591
  if (refusal !== null) errors.push(refusal);
940
592
  } catch {
941
- // The sentence the Python kernel writes for the same failure. What was
942
- // thrown is the host's wording (`jcs: input nesting exceeds ...`), and a
943
- // public result carries the kernel's own words only (proof#150).
593
+ // The kernel's own sentence, never the host's thrown text.
944
594
  errors.push(SIGNATURE_FAILED);
945
595
  }
946
596
 
947
- // Check 3 (optional) — Content Bind: the user's bytes match a hash this
948
- // envelope commits to. The tag got us here; only this proves the bytes match.
597
+ // Check 3 (optional): content bind. Only this proves the user's bytes match.
949
598
  let contentHashOk: boolean | null = null;
950
599
  let contentRole: ContentRole | null = null;
951
600
  if (expectedContentHash !== undefined) {
952
601
  const committed = contentHashes(env);
953
602
  if (committed.length === 0) {
954
- // UNDETERMINED, not failed. An external-commitment envelope
955
- // (`sanning.events/v2`) has no inline payload, so it commits to no
956
- // envelope-readable content hash and there is nothing here to compare
957
- // against. Reporting that as a failure would render "we cannot check
958
- // this" as "it did not match" — what P-10 and I-5 exist to prevent — and
959
- // would fail every envelope on the DL-52 path.
960
- //
961
- // The caller is still told the check did not happen; silence would be
962
- // worse than either verdict.
603
+ // Undetermined, not failed: an external-commitment envelope commits to
604
+ // no readable content hash, and "cannot check" must not read as "did not
605
+ // match". The caller is still told the check did not run.
963
606
  contentHashOk = null;
964
607
  errors.push(
965
608
  "this envelope commits to no readable content hash (external-commitment " +
966
- "profile), so the provided content hash could not be checked — " +
967
- "undetermined, not failed",
609
+ "profile), so the provided content hash could not be checked; " +
610
+ "not checked, not failed",
968
611
  );
969
612
  } else {
970
613
  const want = expectedContentHash.toLowerCase();
@@ -977,18 +620,14 @@ async function verifyEnvelopeChecked(
977
620
  }
978
621
  }
979
622
 
623
+ const faults: EnvelopeFault[] =
624
+ specVersionOk && fieldsOk && signatureOk && payloadHashOk !== false
625
+ ? []
626
+ : envelopeFaults(env, { signatureOk, payloadHashOk, specVersionOk, fieldsOk });
980
627
  return {
981
- // null (binding not checkable) does not fail the verdict — Python-kernel
982
- // parity: ok = spec && sig && payloadHashOk is not False.
983
- // A supplied `expectedContentHash` that matches nothing this envelope
984
- // commits to is a FAILING verdict, not a side note (proof#42). This is the
985
- // check an external auditor runs — "does this file match what was
986
- // anchored" — and reporting it only in `contentHashOk` meant the top-level
987
- // verdict, the field a caller naturally reads, said pass over a file that
988
- // does not match.
989
- //
990
- // Absence stays undetermined: `contentHashOk` is null when no hash was
991
- // supplied, and null must not fail (P-10 — absence is not failure).
628
+ // `null` does not fail. A supplied `expectedContentHash` that matches
629
+ // nothing does: it is the check an auditor runs, and `ok` is what a caller
630
+ // reads.
992
631
  ok: specVersionOk && fieldsOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
993
632
  specVersionOk,
994
633
  payloadHashOk,
@@ -996,6 +635,43 @@ async function verifyEnvelopeChecked(
996
635
  contentHashOk,
997
636
  contentRole,
998
637
  errors,
638
+ fault: faults[0] ?? null,
639
+ faults,
999
640
  };
1000
641
  }
1001
642
 
643
+ const ENVELOPE_MEMBERS = ["spec_version", "public_key", "signature", "payload_hash"];
644
+ const HEX64 = /^[0-9a-fA-F]{64}$/;
645
+
646
+ type FaultChecks = { signatureOk: boolean; payloadHashOk: boolean | null; specVersionOk: boolean; fieldsOk: boolean };
647
+
648
+ /**
649
+ * Every one of `ENVELOPE_FAULTS` that applies, in that order. Each is judged without the
650
+ * others: a key that cannot be read checks no signature, and still the bytes are checked
651
+ * against `payload_hash`. A field's form is judged only under a signature that holds over a
652
+ * version this verifier knows.
653
+ */
654
+ export function envelopeFaults(env: unknown, checks: FaultChecks): EnvelopeFault[] {
655
+ if (
656
+ env === null ||
657
+ typeof env !== "object" ||
658
+ Array.isArray(env) ||
659
+ !ENVELOPE_MEMBERS.some((m) => Object.prototype.hasOwnProperty.call(env, m))
660
+ ) {
661
+ return ["not_an_envelope"];
662
+ }
663
+ const found = new Set<EnvelopeFault>();
664
+ const key = (env as { public_key?: unknown }).public_key;
665
+ if (typeof key !== "string" || !HEX64.test(key)) found.add("key_unreadable");
666
+ else if (!checks.signatureOk) found.add("signature_failed");
667
+ if (checks.payloadHashOk === false) found.add("payload_unbound");
668
+ if (!checks.specVersionOk) found.add("spec_version_unknown");
669
+ else if (checks.signatureOk && !checks.fieldsOk) found.add("nonconforming");
670
+ return ENVELOPE_FAULTS.filter((f) => found.has(f));
671
+ }
672
+
673
+ /** The worst of `envelopeFaults`, or `null`. */
674
+ export function envelopeFault(env: unknown, checks: FaultChecks): EnvelopeFault | null {
675
+ return envelopeFaults(env, checks)[0] ?? null;
676
+ }
677
+