@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/dist/verifier.js
CHANGED
|
@@ -1,74 +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
|
import canonicalize from "canonicalize";
|
|
10
5
|
import { SIGNATURE_FAILED, ed25519Refusal, isHexField, sha256Hex, utf8 } from "./crypto.js";
|
|
11
|
-
|
|
12
|
-
// accepted
|
|
13
|
-
// major
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
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()`
|
|
68
|
-
//
|
|
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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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;
|
|
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
|
-
//
|
|
144
|
-
//
|
|
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
|
|
152
|
-
* place this is
|
|
153
|
-
* bytes
|
|
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.
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
252
|
-
*
|
|
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
|
-
//
|
|
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,
|
|
367
|
-
*
|
|
368
|
-
*
|
|
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.
|
|
390
|
-
*
|
|
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
|
-
*
|
|
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
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
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
|
|
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
|
|
473
|
-
* any depth.
|
|
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
|
-
//
|
|
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++;
|
|
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
|
|
542
|
-
// `<major>.<
|
|
543
|
-
//
|
|
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`
|
|
562
|
-
//
|
|
563
|
-
//
|
|
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
|
|
591
|
-
//
|
|
592
|
-
// `
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
637
|
-
//
|
|
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`:
|
|
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
|
|
645
|
-
//
|
|
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
|
|
650
|
-
//
|
|
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
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
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
|
-
//
|
|
751
|
-
// is
|
|
752
|
-
//
|
|
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
|
|
453
|
+
throw new TypeError("verifyEnvelope: pass { expectedContentHash } rather than a bare string");
|
|
762
454
|
}
|
|
763
455
|
const opts = options ?? {};
|
|
764
|
-
//
|
|
765
|
-
//
|
|
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
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
821
|
-
//
|
|
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"
|
|
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
|
-
//
|
|
851
|
-
//
|
|
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
|
|
858
|
-
//
|
|
859
|
-
//
|
|
860
|
-
//
|
|
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
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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 ===
|
|
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 ===
|
|
545
|
+
checks.push(external === storedHash);
|
|
899
546
|
}
|
|
900
547
|
payloadHashOk = checks.length > 0 ? checks.every(Boolean) : null;
|
|
901
|
-
// One sentence for
|
|
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
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
//
|
|
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
|
|
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)
|
|
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
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
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
|
-
"
|
|
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
|
|
964
|
-
//
|
|
965
|
-
//
|
|
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
|
+
}
|