@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.
- package/README.md +30 -29
- package/dist/agent-record.d.ts +62 -0
- package/dist/agent-record.js +527 -0
- package/dist/agent-schemas.generated.d.ts +15 -0
- package/dist/agent-schemas.generated.js +5759 -0
- package/dist/case-file.d.ts +32 -89
- package/dist/case-file.js +41 -93
- package/dist/case.d.ts +339 -822
- package/dist/case.js +667 -1160
- package/dist/chain.d.ts +19 -47
- package/dist/chain.js +78 -205
- package/dist/claims.d.ts +211 -0
- package/dist/claims.js +1081 -0
- package/dist/cli.d.ts +45 -68
- package/dist/cli.js +618 -1022
- package/dist/compose.js +23 -65
- package/dist/crypto.d.ts +7 -8
- package/dist/crypto.js +60 -147
- package/dist/declaration.d.ts +68 -194
- package/dist/declaration.js +175 -518
- package/dist/environment.d.ts +9 -30
- package/dist/environment.js +17 -66
- package/dist/evidence.d.ts +119 -160
- package/dist/evidence.js +744 -1103
- package/dist/exit-codes.d.ts +11 -27
- package/dist/exit-codes.js +24 -59
- package/dist/file-match.d.ts +33 -79
- package/dist/file-match.js +33 -69
- package/dist/grading.d.ts +98 -135
- package/dist/grading.js +224 -380
- package/dist/handoff.d.ts +62 -160
- package/dist/handoff.js +153 -393
- package/dist/index.d.ts +14 -10
- package/dist/index.js +60 -167
- package/dist/json-schema.d.ts +29 -0
- package/dist/json-schema.js +298 -0
- package/dist/merkle.js +16 -28
- package/dist/message-parts.d.ts +56 -212
- package/dist/message-parts.js +142 -502
- package/dist/side-content.d.ts +11 -22
- package/dist/side-content.js +22 -65
- package/dist/types.d.ts +17 -0
- package/dist/types.js +19 -5
- package/dist/verifier.d.ts +40 -114
- package/dist/verifier.js +196 -535
- package/dist/witness-cache.d.ts +24 -45
- package/dist/witness-cache.js +38 -81
- package/dist/witness-lookup.d.ts +36 -57
- package/dist/witness-lookup.js +89 -139
- package/dist/zip-reader.d.ts +18 -33
- package/dist/zip-reader.js +19 -34
- package/package.json +11 -3
- package/src/agent-record.ts +608 -0
- package/src/agent-schemas.generated.ts +5764 -0
- package/src/case-file.ts +45 -109
- package/src/case.ts +814 -1328
- package/src/chain.ts +85 -224
- package/src/claims.ts +1253 -0
- package/src/cli.ts +714 -1176
- package/src/compose.ts +33 -79
- package/src/crypto.ts +60 -147
- package/src/declaration.ts +219 -656
- package/src/environment.ts +17 -65
- package/src/evidence.ts +958 -1381
- package/src/exit-codes.ts +24 -63
- package/src/file-match.ts +40 -89
- package/src/grading.ts +287 -421
- package/src/handoff.ts +175 -427
- package/src/index.ts +131 -177
- package/src/json-schema.ts +295 -0
- package/src/merkle.ts +16 -28
- package/src/message-parts.ts +155 -533
- package/src/side-content.ts +25 -68
- package/src/types.ts +55 -36
- package/src/verifier.ts +202 -526
- package/src/witness-cache.ts +45 -94
- package/src/witness-lookup.ts +107 -177
- package/src/zip-reader.ts +20 -38
- package/vectors/README.md +52 -34
- package/vectors/agent-record.json +2175 -0
- package/vectors/case-file-index.json +1 -1
- package/vectors/case-split.json +2 -2
- package/vectors/chain-reading.json +3 -3
- package/vectors/file-match.json +285 -148
- package/vectors/jcs-reader.json +6 -6
- package/vectors/pack-grading.json +102 -25
- package/vectors/sandbox-evidence.json +573 -581
- package/vectors/stamp-on-chain.json +18 -18
- package/vectors/witness-cache.json +49 -49
- package/dist/agent-proof.d.ts +0 -28
- package/dist/agent-proof.js +0 -226
- package/dist/run-adapters.d.ts +0 -10
- package/dist/run-adapters.js +0 -35
- package/dist/run.d.ts +0 -209
- package/dist/run.js +0 -1405
- package/src/agent-proof.ts +0 -264
- package/src/run-adapters.ts +0 -44
- package/src/run.ts +0 -1598
package/src/verifier.ts
CHANGED
|
@@ -1,71 +1,33 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
16
|
-
// accepted
|
|
17
|
-
// major
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
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()`
|
|
78
|
-
//
|
|
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
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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;
|
|
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
|
-
//
|
|
153
|
-
//
|
|
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
|
|
162
|
-
* place this is
|
|
163
|
-
* bytes
|
|
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.
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
268
|
-
*
|
|
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
|
-
//
|
|
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,
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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.
|
|
394
|
-
*
|
|
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
|
-
*
|
|
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
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
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
|
|
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
|
|
468
|
-
* any depth.
|
|
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
|
-
//
|
|
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++;
|
|
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
|
|
535
|
-
// `<major>.<
|
|
536
|
-
//
|
|
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`
|
|
557
|
-
//
|
|
558
|
-
//
|
|
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
|
|
585
|
-
//
|
|
586
|
-
// `
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
632
|
-
//
|
|
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`:
|
|
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
|
|
640
|
-
//
|
|
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
|
|
645
|
-
//
|
|
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
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
697
|
-
//
|
|
698
|
-
// asset_registered -> payload.hash (the registered
|
|
699
|
-
// asset_missing -> payload.baseline.hash (last known-good bytes
|
|
700
|
-
// tamper_detected -> payload.observed.hash (the
|
|
701
|
-
// -> payload.baseline.hash (the known-good bytes
|
|
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
|
-
//
|
|
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
|
|
737
|
-
// mirroring the Python reference kernel exactly:
|
|
453
|
+
// Verify an envelope in either binding mode (envelope-spec §3):
|
|
738
454
|
//
|
|
739
|
-
// -
|
|
740
|
-
//
|
|
741
|
-
// -
|
|
742
|
-
//
|
|
743
|
-
//
|
|
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
|
|
749
|
-
//
|
|
750
|
-
//
|
|
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
|
|
470
|
+
"verifyEnvelope: pass { expectedContentHash } rather than a bare string",
|
|
768
471
|
);
|
|
769
472
|
}
|
|
770
473
|
const opts: VerifyOptions = options ?? {};
|
|
771
|
-
//
|
|
772
|
-
//
|
|
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
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
834
|
-
//
|
|
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"
|
|
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
|
-
//
|
|
868
|
-
//
|
|
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
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
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
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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 ===
|
|
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 ===
|
|
573
|
+
checks.push(external === storedHash);
|
|
915
574
|
}
|
|
916
575
|
payloadHashOk = checks.length > 0 ? checks.every(Boolean) : null;
|
|
917
|
-
// One sentence for
|
|
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
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
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
|
|
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)
|
|
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
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
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
|
-
"
|
|
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
|
|
982
|
-
//
|
|
983
|
-
//
|
|
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
|
+
|