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