@bounded-authority-protocol/verifier 0.2.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/LICENSE +190 -0
- package/NOTICE +10 -0
- package/README.md +183 -0
- package/dist/src/base64url.d.ts +2 -0
- package/dist/src/base64url.js +115 -0
- package/dist/src/base64url.js.map +1 -0
- package/dist/src/bounds.d.ts +50 -0
- package/dist/src/bounds.js +114 -0
- package/dist/src/bounds.js.map +1 -0
- package/dist/src/compact.d.ts +19 -0
- package/dist/src/compact.js +111 -0
- package/dist/src/compact.js.map +1 -0
- package/dist/src/digest.d.ts +8 -0
- package/dist/src/digest.js +116 -0
- package/dist/src/digest.js.map +1 -0
- package/dist/src/ed25519.d.ts +7 -0
- package/dist/src/ed25519.js +61 -0
- package/dist/src/ed25519.js.map +1 -0
- package/dist/src/error.d.ts +15 -0
- package/dist/src/error.js +34 -0
- package/dist/src/error.js.map +1 -0
- package/dist/src/facts.d.ts +104 -0
- package/dist/src/facts.js +6 -0
- package/dist/src/facts.js.map +1 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +31 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/jcs.d.ts +3 -0
- package/dist/src/jcs.js +285 -0
- package/dist/src/jcs.js.map +1 -0
- package/dist/src/json.d.ts +25 -0
- package/dist/src/json.js +473 -0
- package/dist/src/json.js.map +1 -0
- package/dist/src/jwk.d.ts +14 -0
- package/dist/src/jwk.js +77 -0
- package/dist/src/jwk.js.map +1 -0
- package/dist/src/selector.d.ts +16 -0
- package/dist/src/selector.js +142 -0
- package/dist/src/selector.js.map +1 -0
- package/dist/src/uri.d.ts +4 -0
- package/dist/src/uri.js +280 -0
- package/dist/src/uri.js.map +1 -0
- package/dist/src/v1.d.ts +211 -0
- package/dist/src/v1.js +2206 -0
- package/dist/src/v1.js.map +1 -0
- package/dist/src/v2.d.ts +291 -0
- package/dist/src/v2.js +2300 -0
- package/dist/src/v2.js.map +1 -0
- package/package.json +46 -0
package/dist/src/v2.js
ADDED
|
@@ -0,0 +1,2300 @@
|
|
|
1
|
+
import { fail, assert, trying } from "./error.js";
|
|
2
|
+
import { jsonDecode, strUtf8, utf8Str } from "./json.js";
|
|
3
|
+
import { base64urlDecode, base64urlEncode } from "./base64url.js";
|
|
4
|
+
import { parseCompact, assembleSegments, scanCompact } from "./compact.js";
|
|
5
|
+
import { jwkFromPublicKey, thumbprintRaw, jwkEncodePublic, jwkDecodePublic, thumbprint } from "./jwk.js";
|
|
6
|
+
import { importPublicKey, ed25519Verify, sha256, sha256Concat, _resetCensus } from "./ed25519.js";
|
|
7
|
+
import { digestWithPrefix, typedProject } from "./digest.js";
|
|
8
|
+
import { semanticIdentity } from "./selector.js";
|
|
9
|
+
import { uriNormalize } from "./uri.js";
|
|
10
|
+
import { jcsEncode } from "./jcs.js";
|
|
11
|
+
import { resolve, coerceBounds, boundsNew, boundsMaximum, MAXIMUM_BOUNDS, MAXIMA } from "./bounds.js";
|
|
12
|
+
// The v2 verification façade (the v2 contract-major profile). Mirrors the v1 façade with
|
|
13
|
+
// exactly three deltas: payloads carry v:2 (v1 bytes are rejected and vice versa), the
|
|
14
|
+
// domain separators are BAP2-REQUEST\0 / BAP2-CHAIN\0 / BAP2-ARCHIVE\0EXPORT\0, and the
|
|
15
|
+
// selector algebra admits the inclusive same-tag range kinds lte/gte (ADR 0028). Headers,
|
|
16
|
+
// typs, claims, bounds, JCS, JWK, URI rules, and fact shapes are unchanged from v1 (modulo
|
|
17
|
+
// version: 2). Each function returns Result<T> = Ok|Err (the {:ok,value}|{:error,:invalid}
|
|
18
|
+
// mirror). No authorized/decision surface (rule 1). All claims revalidated at every public
|
|
19
|
+
// entry (REQ1-VERIFY-revalidate). The v2 certified corpus is the byte-level arbiter; the v2
|
|
20
|
+
// reference carries no local-loopback HTTP profile, so this façade omits it.
|
|
21
|
+
const ALG = "EdDSA";
|
|
22
|
+
const GRANT_TYP = "ba+cap";
|
|
23
|
+
const PROOF_TYP = "dpop+jwt";
|
|
24
|
+
const ANCHOR_TYP = "ba+chain-anchor";
|
|
25
|
+
const TRANSITION_TYP = "ba+key-transition";
|
|
26
|
+
const VERSION = 2;
|
|
27
|
+
const DOT = 0x2e;
|
|
28
|
+
// BAP2-REQUEST\0 prefix for the request digest (the v2 domain separator; ADR 0028's
|
|
29
|
+
// contract-major activation changes every BAP1- separator to its BAP2- form).
|
|
30
|
+
export const REQUEST_PREFIX = new Uint8Array([
|
|
31
|
+
0x42, 0x41, 0x50, 0x32, 0x2d, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x00, // "BAP2-REQUEST\0"
|
|
32
|
+
]);
|
|
33
|
+
// BAP2-CHAIN\0 prefix for consumption-row hashing (the v2 form of ADR 0004 § Consumption rows).
|
|
34
|
+
export const ROW_PREFIX = new Uint8Array([
|
|
35
|
+
0x42, 0x41, 0x50, 0x32, 0x2d, 0x43, 0x48, 0x41, 0x49, 0x4e, 0x00, // "BAP2-CHAIN\0"
|
|
36
|
+
]);
|
|
37
|
+
// BAP2-ARCHIVE\0EXPORT\0 prefix (the v2 form of ADR 0004 § Anchored export; NOT framed).
|
|
38
|
+
export const ARCHIVE_PREFIX = strUtf8("BAP2-ARCHIVE\0EXPORT\0");
|
|
39
|
+
// The v2 request digest: the shared digest core under the BAP2-REQUEST\0 separator.
|
|
40
|
+
function computeRequestDigest(operation, castArguments, b) {
|
|
41
|
+
return digestWithPrefix(REQUEST_PREFIX, operation, castArguments, b);
|
|
42
|
+
}
|
|
43
|
+
// The all-zero 32-byte hash: sequence-1 predecessor + sequence-0 anchor chain hash (ADR 0004).
|
|
44
|
+
const DEFAULT_HASH = new Uint8Array(32);
|
|
45
|
+
const SELECTOR_KINDS = new Set(["all", "equals", "one_of", "lte", "gte"]);
|
|
46
|
+
const SELECTOR_MEMBER_SETS = new Set(["kind", "kind,path,value", "kind,path,values"]);
|
|
47
|
+
// Numeric-tag domain: a range bound must be integer- or float-tagged at decode (the
|
|
48
|
+
// reference's numeric_bound?/1 — a non-numeric bound is a decode rejection, not a no-match).
|
|
49
|
+
function isNumericTag(v) {
|
|
50
|
+
return v.t === "int" || v.t === "float";
|
|
51
|
+
}
|
|
52
|
+
// Validate + parse a v2 selector from a decoded tagged object: the three ADR-0021 member
|
|
53
|
+
// sets, the extended kind dispatch, and the numeric-bound gate on lte/gte.
|
|
54
|
+
export function parseSelector(obj, bounds = MAXIMUM_BOUNDS) {
|
|
55
|
+
if (obj.t !== "object")
|
|
56
|
+
fail("selector: object");
|
|
57
|
+
const kindV = obj.v.get("kind");
|
|
58
|
+
if (!kindV || kindV.t !== "string")
|
|
59
|
+
fail("selector: kind");
|
|
60
|
+
const kind = utf8Str(kindV.v);
|
|
61
|
+
if (!SELECTOR_KINDS.has(kind))
|
|
62
|
+
fail("selector: kind closed set");
|
|
63
|
+
const memberSet = [...obj.v.keys()].sort().join(",");
|
|
64
|
+
if (!SELECTOR_MEMBER_SETS.has(memberSet))
|
|
65
|
+
fail("selector: member set");
|
|
66
|
+
// kind:"all" is recognized on any of the three member sets (ADR 0021; path/value(s) inert).
|
|
67
|
+
if (kind === "all")
|
|
68
|
+
return { kind: "all" };
|
|
69
|
+
if (kind === "equals") {
|
|
70
|
+
if (obj.v.size !== 3)
|
|
71
|
+
fail("selector: equals members");
|
|
72
|
+
const path = parseSelectorPath(obj.v.get("path"), bounds);
|
|
73
|
+
const value = obj.v.get("value") ?? fail("selector: value");
|
|
74
|
+
validateSelectorValue(value, bounds);
|
|
75
|
+
return { kind: "equals", path, value };
|
|
76
|
+
}
|
|
77
|
+
if (kind === "one_of") {
|
|
78
|
+
if (obj.v.size !== 3)
|
|
79
|
+
fail("selector: one_of members");
|
|
80
|
+
const path = parseSelectorPath(obj.v.get("path"), bounds);
|
|
81
|
+
const valuesV = obj.v.get("values") ?? fail("selector: values");
|
|
82
|
+
if (valuesV.t !== "array")
|
|
83
|
+
fail("selector: values array");
|
|
84
|
+
if (valuesV.v.length < 1 || valuesV.v.length > resolve(bounds, "one_of_values")) {
|
|
85
|
+
fail("selector: values count");
|
|
86
|
+
}
|
|
87
|
+
const values = valuesV.v.map((v) => { validateSelectorValue(v, bounds); return v; });
|
|
88
|
+
return { kind: "one_of", path, values };
|
|
89
|
+
}
|
|
90
|
+
// lte / gte: {kind, path, value} with a numeric bound.
|
|
91
|
+
if (obj.v.size !== 3)
|
|
92
|
+
fail("selector: range members");
|
|
93
|
+
const path = parseSelectorPath(obj.v.get("path"), bounds);
|
|
94
|
+
const bound = obj.v.get("value") ?? fail("selector: value");
|
|
95
|
+
if (!isNumericTag(bound))
|
|
96
|
+
fail("selector: numeric bound");
|
|
97
|
+
validateSelectorValue(bound, bounds);
|
|
98
|
+
return kind === "lte" ? { kind: "lte", path, value: bound } : { kind: "gte", path, value: bound };
|
|
99
|
+
}
|
|
100
|
+
function parseSelectorPath(pathV, bounds) {
|
|
101
|
+
if (!pathV || pathV.t !== "array")
|
|
102
|
+
fail("selector: path array");
|
|
103
|
+
if (pathV.v.length < 1 || pathV.v.length > resolve(bounds, "path_segments")) {
|
|
104
|
+
fail("selector: path length");
|
|
105
|
+
}
|
|
106
|
+
const names = [];
|
|
107
|
+
for (const seg of pathV.v) {
|
|
108
|
+
if (seg.t !== "string")
|
|
109
|
+
fail("selector: path segment string");
|
|
110
|
+
const b = seg.v;
|
|
111
|
+
if (b.length < 1 || b.length > resolve(bounds, "key_bytes"))
|
|
112
|
+
fail("selector: path segment bytes");
|
|
113
|
+
names.push(utf8Str(b));
|
|
114
|
+
}
|
|
115
|
+
return names;
|
|
116
|
+
}
|
|
117
|
+
// Same-tag INCLUSIVE comparison (ADR 0028 §2): each arm pairs identical tags, so a cross-tag
|
|
118
|
+
// or non-numeric operand pair falls through to false rather than comparing numerically.
|
|
119
|
+
function rangeMatches(target, bound, kind) {
|
|
120
|
+
if (target.t === "int" && bound.t === "int")
|
|
121
|
+
return kind === "lte" ? target.v <= bound.v : target.v >= bound.v;
|
|
122
|
+
if (target.t === "float" && bound.t === "float")
|
|
123
|
+
return kind === "lte" ? target.v <= bound.v : target.v >= bound.v;
|
|
124
|
+
return false; // cross-tag or non-numeric target: fail closed
|
|
125
|
+
}
|
|
126
|
+
// Traverse a path over OBJECTS only (paths never index arrays). Returns the value or undefined.
|
|
127
|
+
function traversePath(root, path) {
|
|
128
|
+
let cur = root;
|
|
129
|
+
for (const name of path) {
|
|
130
|
+
if (!cur || cur.t !== "object")
|
|
131
|
+
return undefined;
|
|
132
|
+
cur = cur.v.get(name);
|
|
133
|
+
}
|
|
134
|
+
return cur;
|
|
135
|
+
}
|
|
136
|
+
// Does this v2 selector match the cast_arguments? equals/one_of compare semantic identity
|
|
137
|
+
// (JCS of the typed projection — the int/float distinction survives); lte/gte apply the
|
|
138
|
+
// same-tag inclusive comparison to the traversed value. Missing path → no match
|
|
139
|
+
// (REQ1-SELECTOR-path-required).
|
|
140
|
+
export function selectorMatches(sel, castArguments) {
|
|
141
|
+
if (sel.kind === "all")
|
|
142
|
+
return true;
|
|
143
|
+
const target = traversePath(castArguments, sel.path);
|
|
144
|
+
if (target === undefined)
|
|
145
|
+
return false; // path required
|
|
146
|
+
if (sel.kind === "equals")
|
|
147
|
+
return bytesEqual(semanticIdentity(sel.value), semanticIdentity(target));
|
|
148
|
+
if (sel.kind === "one_of") {
|
|
149
|
+
const targetId = semanticIdentity(target);
|
|
150
|
+
for (const v of sel.values) {
|
|
151
|
+
if (bytesEqual(semanticIdentity(v), targetId))
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return rangeMatches(target, sel.value, sel.kind);
|
|
157
|
+
}
|
|
158
|
+
// --- shared closed-header / claim validators (derived from spec/bap-v2.md + RFCs) ---
|
|
159
|
+
// Parse a protected header object; validate the closed member set + alg + typ + kid.
|
|
160
|
+
function parseGrantHeader(seg, bounds) {
|
|
161
|
+
const h = jsonDecode(seg.protectedBytes, bounds);
|
|
162
|
+
requireObjectExact(h, ["alg", "typ", "kid"], "grant header");
|
|
163
|
+
requireStringLit(h, "alg", ALG, "grant header alg");
|
|
164
|
+
requireStringLit(h, "typ", GRANT_TYP, "grant header typ");
|
|
165
|
+
const kid = requireKid(h, bounds);
|
|
166
|
+
return { kid };
|
|
167
|
+
}
|
|
168
|
+
function parseProofHeader(seg, bounds) {
|
|
169
|
+
const h = jsonDecode(seg.protectedBytes, bounds);
|
|
170
|
+
requireObjectExact(h, ["alg", "typ", "jwk"], "proof header");
|
|
171
|
+
requireStringLit(h, "alg", ALG, "proof header alg");
|
|
172
|
+
requireStringLit(h, "typ", PROOF_TYP, "proof header typ");
|
|
173
|
+
const jwkV = h.v.get("jwk");
|
|
174
|
+
if (jwkV.t !== "object")
|
|
175
|
+
fail("proof header: jwk object");
|
|
176
|
+
// Closed OKP members {crv, kty, x}; reject any extra member (incl. private d) — REQ1-HEADER-no-private-jwk.
|
|
177
|
+
requireObjectExact(jwkV, ["crv", "kty", "x"], "proof jwk");
|
|
178
|
+
requireStringLit(jwkV, "crv", "Ed25519", "proof jwk crv");
|
|
179
|
+
requireStringLit(jwkV, "kty", "OKP", "proof jwk kty");
|
|
180
|
+
const xV = jwkV.v.get("x");
|
|
181
|
+
if (xV.t !== "string")
|
|
182
|
+
fail("proof jwk: x string");
|
|
183
|
+
const rawKey = base64urlDecode(xV.v);
|
|
184
|
+
if (rawKey.length !== 32)
|
|
185
|
+
fail("proof jwk: x width");
|
|
186
|
+
const tp = thumbprintRaw(jwkFromPublicKey(rawKey));
|
|
187
|
+
return { holderThumbprint: tp, holderKey: rawKey };
|
|
188
|
+
}
|
|
189
|
+
function parseAnchorHeader(seg, bounds) {
|
|
190
|
+
const h = jsonDecode(seg.protectedBytes, bounds);
|
|
191
|
+
requireObjectExact(h, ["alg", "typ", "kid"], "anchor header");
|
|
192
|
+
requireStringLit(h, "alg", ALG, "anchor header alg");
|
|
193
|
+
requireStringLit(h, "typ", ANCHOR_TYP, "anchor header typ");
|
|
194
|
+
const kid = requireKid(h, bounds);
|
|
195
|
+
// Canonical form: the protected segment must be the exact JCS encoding (boundary_anchor_codec.ex:95-96).
|
|
196
|
+
if (!bytesEqual(jcsEncode(h, bounds), seg.protectedBytes))
|
|
197
|
+
fail("anchor header: canonical");
|
|
198
|
+
return { kid };
|
|
199
|
+
}
|
|
200
|
+
function parseTransitionHeader(seg, bounds) {
|
|
201
|
+
const h = jsonDecode(seg.protectedBytes, bounds);
|
|
202
|
+
requireObjectExact(h, ["alg", "typ", "kid"], "transition header");
|
|
203
|
+
requireStringLit(h, "alg", ALG, "transition header alg");
|
|
204
|
+
requireStringLit(h, "typ", TRANSITION_TYP, "transition header typ");
|
|
205
|
+
const kid = requireKid(h, bounds);
|
|
206
|
+
// Canonical form: the protected segment must be the exact JCS encoding (key_transition_codec.ex:127-128).
|
|
207
|
+
if (!bytesEqual(jcsEncode(h, bounds), seg.protectedBytes))
|
|
208
|
+
fail("transition header: canonical");
|
|
209
|
+
return { kid };
|
|
210
|
+
}
|
|
211
|
+
function requireKid(h, bounds) {
|
|
212
|
+
const kidV = h.v.get("kid");
|
|
213
|
+
if (kidV.t !== "string")
|
|
214
|
+
fail("header: kid string");
|
|
215
|
+
const b = kidV.v;
|
|
216
|
+
if (b.length < 1 || b.length > resolve(bounds, "kid_bytes"))
|
|
217
|
+
fail("header: kid bytes");
|
|
218
|
+
const s = utf8Str(b);
|
|
219
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(s))
|
|
220
|
+
fail("header: kid charset");
|
|
221
|
+
return s;
|
|
222
|
+
}
|
|
223
|
+
function requireObjectExact(v, keys, ctx) {
|
|
224
|
+
if (v.t !== "object")
|
|
225
|
+
fail(`${ctx}: object`);
|
|
226
|
+
const got = [...v.v.keys()].sort().join(",");
|
|
227
|
+
const want = [...keys].sort().join(",");
|
|
228
|
+
if (got !== want)
|
|
229
|
+
fail(`${ctx}: closed members`);
|
|
230
|
+
}
|
|
231
|
+
function requireStringLit(obj, key, lit, ctx) {
|
|
232
|
+
const v = obj.v.get(key);
|
|
233
|
+
if (!v || v.t !== "string" || utf8Str(v.v) !== lit)
|
|
234
|
+
fail(`${ctx}: ${key}=${lit}`);
|
|
235
|
+
}
|
|
236
|
+
// Well-formed UTF-8 string check (mirrors the official String.valid?). Lone surrogates from a
|
|
237
|
+
// \uXXXX escape survive JSON.parse and the JCS round-trip, so byte-length checks alone do not catch
|
|
238
|
+
// them; the official rejects such strings (corpus_independent.mjs:1732).
|
|
239
|
+
function isWellFormed(s) {
|
|
240
|
+
const anyStr = s;
|
|
241
|
+
return typeof anyStr.isWellFormed === "function"
|
|
242
|
+
? anyStr.isWellFormed()
|
|
243
|
+
: !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
|
|
244
|
+
}
|
|
245
|
+
// StringOrURI (RFC 7519 §2; mirrors the official valid_uri? / StringOrURI). A bare string with no
|
|
246
|
+
// ':' is always valid; an opaque scheme `a:b` (no `//`) is valid; a `//` authority is structurally
|
|
247
|
+
// validated. This is REQUIRED to reject the corpus's `ht tp://x` and `http://a:b` cases.
|
|
248
|
+
function isStringOrUri(s) {
|
|
249
|
+
if (!isWellFormed(s))
|
|
250
|
+
return false;
|
|
251
|
+
const colon = s.indexOf(":");
|
|
252
|
+
if (colon === -1)
|
|
253
|
+
return true; // bare string: always a StringOrURI
|
|
254
|
+
const scheme = s.slice(0, colon);
|
|
255
|
+
if (!/^[A-Za-z][A-Za-z0-9+\-.]*$/.test(scheme))
|
|
256
|
+
return false;
|
|
257
|
+
// uri_bytes shape: unreserved + reserved punctuation, or a %HH escape.
|
|
258
|
+
if (!/^(?:%[0-9A-Fa-f]{2}|[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=])*$/.test(s))
|
|
259
|
+
return false;
|
|
260
|
+
const rest = s.slice(colon + 1);
|
|
261
|
+
if (!rest.startsWith("//"))
|
|
262
|
+
return true; // opaque / path-rootless: no authority to validate.
|
|
263
|
+
return validUriAuthority(rest.slice(2).split(/[/?#]/, 1)[0]);
|
|
264
|
+
}
|
|
265
|
+
// RFC 3986 authority validation matching URI.new for the cases the profile can produce.
|
|
266
|
+
function validUriAuthority(authority) {
|
|
267
|
+
const at = authority.indexOf("@");
|
|
268
|
+
const hostport = at === -1 ? authority : authority.slice(at + 1);
|
|
269
|
+
if (hostport.includes("@"))
|
|
270
|
+
return false; // a second @ lands in the host — invalid.
|
|
271
|
+
if (hostport.startsWith("[")) {
|
|
272
|
+
const close = hostport.indexOf("]");
|
|
273
|
+
if (close === -1)
|
|
274
|
+
return false; // unterminated IPv6 literal.
|
|
275
|
+
if (!isIpv6(hostport.slice(1, close)))
|
|
276
|
+
return false;
|
|
277
|
+
const suffix = hostport.slice(close + 1);
|
|
278
|
+
return suffix === "" || /^:\d*$/.test(suffix);
|
|
279
|
+
}
|
|
280
|
+
if (hostport.includes("[") || hostport.includes("]"))
|
|
281
|
+
return false; // stray bracket in host.
|
|
282
|
+
if ((hostport.match(/:/g) ?? []).length > 1)
|
|
283
|
+
return false; // host/port ambiguity.
|
|
284
|
+
const sep = hostport.lastIndexOf(":");
|
|
285
|
+
return sep === -1 || /^\d*$/.test(hostport.slice(sep + 1));
|
|
286
|
+
}
|
|
287
|
+
function isIpv6(literal) {
|
|
288
|
+
// node:net isIP accepts only a valid IPv6 literal in brackets (matches Erlang :uri_string).
|
|
289
|
+
// Avoid importing node:net in the library path (the purity gate bans it); a structural check is
|
|
290
|
+
// sufficient for the StringOrURI authority gate (the corpus has no bracketed-IPv6 StringOrURI).
|
|
291
|
+
return /^[0-9A-Fa-f:.]+$/.test(literal);
|
|
292
|
+
}
|
|
293
|
+
// StringOrURI claim: non-empty, ≤ identifier_bytes, well-formed, valid StringOrURI.
|
|
294
|
+
function requireStringOrUri(v, key, bounds) {
|
|
295
|
+
if (!v || v.t !== "string")
|
|
296
|
+
fail(`claim: ${key} string`);
|
|
297
|
+
const s = utf8Str(v.v);
|
|
298
|
+
const len = strUtf8(s).length;
|
|
299
|
+
if (len < 1 || len > resolve(bounds, "identifier_bytes"))
|
|
300
|
+
fail(`claim: ${key} bytes`);
|
|
301
|
+
if (!isStringOrUri(s))
|
|
302
|
+
fail(`claim: ${key} string-or-uri`);
|
|
303
|
+
return s;
|
|
304
|
+
}
|
|
305
|
+
function requireInt(v, key) {
|
|
306
|
+
if (!v || v.t !== "int")
|
|
307
|
+
fail(`claim: ${key} integer`);
|
|
308
|
+
return v.v;
|
|
309
|
+
}
|
|
310
|
+
// Lowercase RFC 4122 UUID.
|
|
311
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
312
|
+
function requireUuid(v, key) {
|
|
313
|
+
if (!v || v.t !== "string")
|
|
314
|
+
fail(`claim: ${key} uuid string`);
|
|
315
|
+
const s = utf8Str(v.v);
|
|
316
|
+
if (!UUID_RE.test(s))
|
|
317
|
+
fail(`claim: ${key} uuid`);
|
|
318
|
+
return s;
|
|
319
|
+
}
|
|
320
|
+
// Canonical base64url string of exactly N bytes → returns the raw bytes.
|
|
321
|
+
function requireB64urlN(v, key, n) {
|
|
322
|
+
if (!v || v.t !== "string")
|
|
323
|
+
fail(`claim: ${key} b64url string`);
|
|
324
|
+
const raw = base64urlDecode(v.v);
|
|
325
|
+
if (raw.length !== n)
|
|
326
|
+
fail(`claim: ${key} width`);
|
|
327
|
+
return raw;
|
|
328
|
+
}
|
|
329
|
+
// Printable-ASCII operation name 1..operation_bytes (REQ1-CLAIM-operation-shape, valid_operation?).
|
|
330
|
+
function requireOperation(v, key, bounds) {
|
|
331
|
+
if (!v || v.t !== "string")
|
|
332
|
+
fail(`claim: ${key} operation string`);
|
|
333
|
+
const b = v.v;
|
|
334
|
+
if (b.length < 1 || b.length > resolve(bounds, "operation_bytes"))
|
|
335
|
+
fail(`claim: ${key} operation bytes`);
|
|
336
|
+
const s = utf8Str(b);
|
|
337
|
+
if (!/^[\x20-\x7e]+$/.test(s))
|
|
338
|
+
fail(`claim: ${key} operation printable ASCII`);
|
|
339
|
+
return s;
|
|
340
|
+
}
|
|
341
|
+
// RFC 9110 method token 1..method_bytes, ASCII token chars, byte-for-byte (no case-fold).
|
|
342
|
+
function requireMethod(v, key, bounds) {
|
|
343
|
+
if (!v || v.t !== "string")
|
|
344
|
+
fail(`claim: ${key} method string`);
|
|
345
|
+
const b = v.v;
|
|
346
|
+
if (b.length < 1 || b.length > resolve(bounds, "method_bytes"))
|
|
347
|
+
fail(`claim: ${key} method bytes`);
|
|
348
|
+
const s = utf8Str(b);
|
|
349
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(s))
|
|
350
|
+
fail(`claim: ${key} method token`);
|
|
351
|
+
return s;
|
|
352
|
+
}
|
|
353
|
+
// Normalized HTTPS URI claim (≤ uri_bytes; must already equal Uri.normalize — checked by re-normalizing).
|
|
354
|
+
function requireNormalizedUri(v, key, bounds) {
|
|
355
|
+
if (!v || v.t !== "string")
|
|
356
|
+
fail(`claim: ${key} uri string`);
|
|
357
|
+
const b = v.v;
|
|
358
|
+
if (b.length < 1 || b.length > resolve(bounds, "uri_bytes"))
|
|
359
|
+
fail(`claim: ${key} uri bytes`);
|
|
360
|
+
const s = utf8Str(b);
|
|
361
|
+
const norm = uriNormalize(b, bounds);
|
|
362
|
+
if (!norm.ok)
|
|
363
|
+
fail(`claim: ${key} uri normalized`);
|
|
364
|
+
if (utf8Str(norm.value) !== s)
|
|
365
|
+
fail(`claim: ${key} uri pre-normalized`);
|
|
366
|
+
return s;
|
|
367
|
+
}
|
|
368
|
+
// Validate the closed grant payload members + operation structure. operations[] (NOT ba_req).
|
|
369
|
+
function validateGrantPayload(p, bounds) {
|
|
370
|
+
requireObjectExact(p, ["v", "iss", "jti", "aud", "iat", "nbf", "exp", "cnf", "operations"], "grant payload");
|
|
371
|
+
const vV = p.v.get("v");
|
|
372
|
+
if (vV.t !== "int" || vV.v !== VERSION)
|
|
373
|
+
fail("grant: v=2");
|
|
374
|
+
const opsV = p.v.get("operations");
|
|
375
|
+
if (opsV.t !== "array")
|
|
376
|
+
fail("grant: operations array");
|
|
377
|
+
if (opsV.v.length < 1 || opsV.v.length > resolve(bounds, "operations"))
|
|
378
|
+
fail("grant: operations count");
|
|
379
|
+
const names = new Set();
|
|
380
|
+
for (const op of opsV.v) {
|
|
381
|
+
if (op.t !== "object")
|
|
382
|
+
fail("grant: operation object");
|
|
383
|
+
const opObj = op;
|
|
384
|
+
requireObjectExact(opObj, ["name", "selectors"], "grant operation");
|
|
385
|
+
const name = requireOperation(opObj.v.get("name"), "operation name", bounds);
|
|
386
|
+
if (names.has(name))
|
|
387
|
+
fail("grant: operation name unique");
|
|
388
|
+
names.add(name);
|
|
389
|
+
const sels = opObj.v.get("selectors");
|
|
390
|
+
if (sels.t !== "array")
|
|
391
|
+
fail("grant: selectors array");
|
|
392
|
+
if (sels.v.length < 1 || sels.v.length > resolve(bounds, "selectors"))
|
|
393
|
+
fail("grant: selectors count");
|
|
394
|
+
for (const s of sels.v)
|
|
395
|
+
parseSelector(s, bounds); // validate each selector's closed shape (thread caller bounds — selector/2 in the reference enforces path_segments, one_of_values, selector value node bounds)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function extractAudience(v, bounds) {
|
|
399
|
+
if (!v)
|
|
400
|
+
fail("claim: aud");
|
|
401
|
+
if (v.t === "string") {
|
|
402
|
+
const s = utf8Str(v.v);
|
|
403
|
+
const len = strUtf8(s).length;
|
|
404
|
+
if (len < 1 || len > resolve(bounds, "identifier_bytes"))
|
|
405
|
+
fail("claim: aud bytes");
|
|
406
|
+
if (!isStringOrUri(s))
|
|
407
|
+
fail("claim: aud string-or-uri");
|
|
408
|
+
return [s];
|
|
409
|
+
}
|
|
410
|
+
if (v.t === "array") {
|
|
411
|
+
if (v.v.length < 1 || v.v.length > resolve(bounds, "audiences"))
|
|
412
|
+
fail("claim: aud count");
|
|
413
|
+
const seen = new Set();
|
|
414
|
+
const out = [];
|
|
415
|
+
for (const a of v.v) {
|
|
416
|
+
if (a.t !== "string")
|
|
417
|
+
fail("claim: aud string");
|
|
418
|
+
const s = utf8Str(a.v);
|
|
419
|
+
const len = strUtf8(s).length;
|
|
420
|
+
if (len < 1 || len > resolve(bounds, "identifier_bytes"))
|
|
421
|
+
fail("claim: aud member bytes");
|
|
422
|
+
if (!isStringOrUri(s))
|
|
423
|
+
fail("claim: aud member string-or-uri");
|
|
424
|
+
if (seen.has(s))
|
|
425
|
+
fail("claim: aud unique");
|
|
426
|
+
seen.add(s);
|
|
427
|
+
out.push(s);
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
fail("claim: aud shape");
|
|
432
|
+
}
|
|
433
|
+
function validateProofPayload(p, bounds) {
|
|
434
|
+
if (p.t !== "object")
|
|
435
|
+
fail("proof payload: object");
|
|
436
|
+
const hasNonce = p.v.has("nonce");
|
|
437
|
+
const keys = hasNonce
|
|
438
|
+
? ["v", "jti", "htm", "htu", "iat", "ba_inv", "ba_op", "ath", "ba_req", "nonce"]
|
|
439
|
+
: ["v", "jti", "htm", "htu", "iat", "ba_inv", "ba_op", "ath", "ba_req"];
|
|
440
|
+
requireObjectExact(p, keys, "proof payload");
|
|
441
|
+
const vV = p.v.get("v");
|
|
442
|
+
if (vV.t !== "int" || vV.v !== VERSION)
|
|
443
|
+
fail("proof: v=2");
|
|
444
|
+
requireStringOrUri(p.v.get("jti"), "jti", bounds);
|
|
445
|
+
requireMethod(p.v.get("htm"), "htm", bounds);
|
|
446
|
+
requireNormalizedUri(p.v.get("htu"), "htu", bounds);
|
|
447
|
+
requireInt(p.v.get("iat"), "iat");
|
|
448
|
+
requireUuid(p.v.get("ba_inv"), "ba_inv");
|
|
449
|
+
requireOperation(p.v.get("ba_op"), "ba_op", bounds);
|
|
450
|
+
requireB64urlN(p.v.get("ath"), "ath", 32);
|
|
451
|
+
requireB64urlN(p.v.get("ba_req"), "ba_req", 32);
|
|
452
|
+
if (hasNonce) {
|
|
453
|
+
const n = p.v.get("nonce");
|
|
454
|
+
if (n.t !== "string")
|
|
455
|
+
fail("proof: nonce string");
|
|
456
|
+
const ns = utf8Str(n.v);
|
|
457
|
+
if (!isWellFormed(ns))
|
|
458
|
+
fail("proof: nonce well-formed");
|
|
459
|
+
const len = strUtf8(ns).length;
|
|
460
|
+
if (len < 1 || len > resolve(bounds, "nonce_bytes"))
|
|
461
|
+
fail("proof: nonce bytes");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// Validate the closed anchor payload (ADR 0004 § Boundary anchors).
|
|
465
|
+
function validateAnchorPayload(p, payloadBytes, bounds) {
|
|
466
|
+
requireObjectExact(p, ["anchor_id", "anchored_at", "chain_hash", "chain_id", "key_fingerprint", "sequence", "v"], "anchor payload");
|
|
467
|
+
const vV = p.v.get("v");
|
|
468
|
+
if (vV.t !== "int" || vV.v !== VERSION)
|
|
469
|
+
fail("anchor: v=2");
|
|
470
|
+
requireStringOrUri(p.v.get("anchor_id"), "anchor_id", bounds);
|
|
471
|
+
requireInt(p.v.get("anchored_at"), "anchored_at");
|
|
472
|
+
requireStringOrUri(p.v.get("chain_id"), "chain_id", bounds);
|
|
473
|
+
requireInt(p.v.get("sequence"), "sequence");
|
|
474
|
+
requireB64urlN(p.v.get("chain_hash"), "chain_hash", 32);
|
|
475
|
+
requireB64urlN(p.v.get("key_fingerprint"), "key_fingerprint", 32);
|
|
476
|
+
// Genesis binding (boundary_anchor_codec.ex:189): sequence 0 carries the all-zero chain
|
|
477
|
+
// hash (the canonical base64url of 32 zero bytes is 43 "A" characters).
|
|
478
|
+
const seqV = p.v.get("sequence");
|
|
479
|
+
const hashV = p.v.get("chain_hash");
|
|
480
|
+
if (seqV.t === "int" && seqV.v === 0 && (hashV.t !== "string" || utf8Str(hashV.v) !== "A".repeat(43))) {
|
|
481
|
+
fail("anchor payload: genesis");
|
|
482
|
+
}
|
|
483
|
+
// Canonical form: the payload segment must be the exact JCS encoding (boundary_anchor_codec.ex:118-119).
|
|
484
|
+
if (!bytesEqual(jcsEncode(p, bounds), payloadBytes))
|
|
485
|
+
fail("anchor payload: canonical");
|
|
486
|
+
}
|
|
487
|
+
// Validate the closed key-transition payload (ADR 0004 § Authenticated key transitions).
|
|
488
|
+
function validateTransitionPayload(p, payloadBytes, bounds) {
|
|
489
|
+
requireObjectExact(p, ["chain_id", "effective_at", "from_key_fingerprint", "to_key_fingerprint", "to_key_id", "transition_id", "v"], "transition payload");
|
|
490
|
+
const vV = p.v.get("v");
|
|
491
|
+
if (vV.t !== "int" || vV.v !== VERSION)
|
|
492
|
+
fail("transition: v=2");
|
|
493
|
+
requireStringOrUri(p.v.get("transition_id"), "transition_id", bounds);
|
|
494
|
+
requireStringOrUri(p.v.get("chain_id"), "chain_id", bounds);
|
|
495
|
+
requireInt(p.v.get("effective_at"), "effective_at");
|
|
496
|
+
requireB64urlN(p.v.get("from_key_fingerprint"), "from_key_fingerprint", 32);
|
|
497
|
+
requireB64urlN(p.v.get("to_key_fingerprint"), "to_key_fingerprint", 32);
|
|
498
|
+
// to_key_id: a key id (kid charset + bytes), not a generic StringOrURI.
|
|
499
|
+
const toKeyId = p.v.get("to_key_id");
|
|
500
|
+
if (toKeyId.t !== "string")
|
|
501
|
+
fail("transition: to_key_id string");
|
|
502
|
+
const s = utf8Str(toKeyId.v);
|
|
503
|
+
if (s.length < 1 || s.length > resolve(bounds, "kid_bytes"))
|
|
504
|
+
fail("transition: to_key_id bytes");
|
|
505
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(s))
|
|
506
|
+
fail("transition: to_key_id charset");
|
|
507
|
+
// Canonical form: the payload segment must be the exact JCS encoding (key_transition_codec.ex:151-152).
|
|
508
|
+
if (!bytesEqual(jcsEncode(p, bounds), payloadBytes))
|
|
509
|
+
fail("transition payload: canonical");
|
|
510
|
+
}
|
|
511
|
+
// inWindow: valid_from <= time && (valid_before null/unbounded OR time < valid_before).
|
|
512
|
+
function inWindow(time, key) {
|
|
513
|
+
return key.validFrom <= time && (key.validBefore === null || time < key.validBefore);
|
|
514
|
+
}
|
|
515
|
+
function bytesEqual(a, b) {
|
|
516
|
+
if (a.length !== b.length)
|
|
517
|
+
return false;
|
|
518
|
+
let diff = 0;
|
|
519
|
+
for (let i = 0; i < a.length; i++)
|
|
520
|
+
diff |= a[i] ^ b[i];
|
|
521
|
+
return diff === 0;
|
|
522
|
+
}
|
|
523
|
+
const SHAPE_BOUNDS_OPT = { opt: "object" };
|
|
524
|
+
const SHAPE_HIST_KEY = { fields: { keyId: "str", publicKey: "bytes", validFrom: "int", validBefore: { opt: "int" } } };
|
|
525
|
+
const SHAPE_PROOF_PRODUCER = { fields: { holderPublicKey: "bytes", proofId: "str", method: "str", targetUri: "str", issuedAt: "int", invocationId: "str", operation: "str", grantCompact: "bytes", castArguments: "tagged", nonce: { opt: "str" } } };
|
|
526
|
+
const SHAPE_TRANSITION_PRODUCER = { fields: { transitionId: "str", chainId: "str", effectiveAt: "int", currentKeyId: "str", currentPublicKey: "bytes", nextKeyId: "str", nextPublicKey: "bytes" } };
|
|
527
|
+
const SHAPE_EXPORT_INPUT = { fields: { rows: { seq: "bytes" }, startAnchor: "bytes", endAnchor: "bytes", transitions: { seq: "bytes" }, chainId: "str", firstSequence: "int", lastSequence: "int", rowCount: "int", previousHash: "bytes", lastHash: "bytes" } };
|
|
528
|
+
const SHAPE_EXPECTED_CHAIN = { fields: { chainId: "str", firstSequence: "int", lastSequence: "int", rowCount: "int", previousHash: "bytes", lastHash: "bytes", bounds: SHAPE_BOUNDS_OPT } };
|
|
529
|
+
const SHAPE_EXPECTED_ANCHOR = { fields: { anchorId: "str", anchoredAt: "int", chainId: "str", sequence: "int", chainHash: "bytes", keyId: "str", keyFingerprint: "bytes", bounds: SHAPE_BOUNDS_OPT } };
|
|
530
|
+
const SHAPE_EXPECTED_TRANSITION = { fields: { transitionId: "str", chainId: "str", effectiveAt: "int", currentKeyId: "str", currentKeyFingerprint: "bytes", nextKeyId: "str", nextKeyFingerprint: "bytes", bounds: SHAPE_BOUNDS_OPT } };
|
|
531
|
+
// Cross-vendor round 18 (codex, blocking): the nested members are FULLY specified, not
|
|
532
|
+
// opaque "object" — a malformed nested struct (missing field, wrong type) must reject at
|
|
533
|
+
// the gate, never reach a deref inside the hoist or body.
|
|
534
|
+
const SHAPE_EXPECTED_EXPORT = { fields: { chain: SHAPE_EXPECTED_CHAIN, startAnchor: SHAPE_EXPECTED_ANCHOR, endAnchor: SHAPE_EXPECTED_ANCHOR, transitions: { seq: SHAPE_EXPECTED_TRANSITION }, bounds: SHAPE_BOUNDS_OPT } };
|
|
535
|
+
// The verify-side shape adds the two mandatory anchored members (digest + object_version).
|
|
536
|
+
const SHAPE_EXPECTED_ANCHORED_EXPORT = { fields: { chain: SHAPE_EXPECTED_CHAIN, digest: "bytes", startAnchor: SHAPE_EXPECTED_ANCHOR, endAnchor: SHAPE_EXPECTED_ANCHOR, transitions: { seq: SHAPE_EXPECTED_TRANSITION }, objectVersion: "str", bounds: SHAPE_BOUNDS_OPT } };
|
|
537
|
+
function shapeOk(value, shape) {
|
|
538
|
+
if (shape === "bytes")
|
|
539
|
+
return value instanceof Uint8Array;
|
|
540
|
+
if (shape === "str")
|
|
541
|
+
return typeof value === "string";
|
|
542
|
+
if (shape === "int")
|
|
543
|
+
return Number.isInteger(value); // rejects null/undefined/bool/float
|
|
544
|
+
if (shape === "object")
|
|
545
|
+
return typeof value === "object" && value !== null;
|
|
546
|
+
if (shape === "tagged")
|
|
547
|
+
return typeof value === "object" && value !== null && typeof value.t === "string";
|
|
548
|
+
if ("seq" in shape)
|
|
549
|
+
return Array.isArray(value) && value.every((item) => shapeOk(item, shape.seq));
|
|
550
|
+
if ("opt" in shape)
|
|
551
|
+
return value === undefined || value === null || shapeOk(value, shape.opt);
|
|
552
|
+
if (typeof value !== "object" || value === null)
|
|
553
|
+
return false;
|
|
554
|
+
for (const [name, sub] of Object.entries(shape.fields)) {
|
|
555
|
+
if (!shapeOk(value[name], sub))
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
// Accepted margin (cross-vendor round 18, claude note): the gate walks caller sequences
|
|
561
|
+
// element-by-element BEFORE the bodies' count ceilings run — inherent to entry-position
|
|
562
|
+
// gating; the per-element cost is a typeof check only, and constructing an oversized
|
|
563
|
+
// caller list costs the caller more than the walk costs us.
|
|
564
|
+
function closedShape(args, shapes) {
|
|
565
|
+
for (let i = 0; i < shapes.length; i++) {
|
|
566
|
+
if (!shapeOk(args[i], shapes[i]))
|
|
567
|
+
fail(`shape: argument ${i}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// 1. untrusted_key_locator (spec/bap-v2.md § Untrusted key locator).
|
|
571
|
+
export function untrustedKeyLocator(compact, bounds) {
|
|
572
|
+
return trying(() => {
|
|
573
|
+
closedShape([compact, bounds], ["bytes", SHAPE_BOUNDS_OPT]);
|
|
574
|
+
// Cross-vendor #13: the reference (v2.ex delegates to the v1 façade, v1.ex:21-34) decodes ONLY the protected segment — payload and
|
|
575
|
+
// signature are NOT decoded, interpreted, or independently size-checked. parseCompact decodes all
|
|
576
|
+
// three, so a compact with a valid protected grant header but non-canonical payload/signature
|
|
577
|
+
// bytes wrongly rejected. Mirror the reference: split into exactly 3 segments, decode protected
|
|
578
|
+
// only, validate the grant header + kid. (An invalid payload/signature does not affect the kid.)
|
|
579
|
+
const b = bounds ?? MAXIMUM_BOUNDS;
|
|
580
|
+
if (compact.length > resolve(b, "compact_bytes"))
|
|
581
|
+
fail("key_locator: compact bound");
|
|
582
|
+
// Exactly 3 segments on '.' (a 2- or 4-segment input fails the closed shape).
|
|
583
|
+
const dots = [];
|
|
584
|
+
for (let i = 0; i < compact.length; i++)
|
|
585
|
+
if (compact[i] === DOT)
|
|
586
|
+
dots.push(i);
|
|
587
|
+
if (dots.length !== 2)
|
|
588
|
+
fail("key_locator: three segments");
|
|
589
|
+
const d0 = dots[0];
|
|
590
|
+
// Cross-vendor (key-locator empty segments): the reference (v1.ex:24, shared by the v2 façade) only requires exactly 3
|
|
591
|
+
// segments and decodes the PROTECTED segment alone — payload and signature are bound to _ and
|
|
592
|
+
// never decoded or size-checked, so empty payload/signature segments are ACCEPTED (e.g.
|
|
593
|
+
// "<protected>.." yields kid=<protected's>). Only an empty PROTECTED segment (d0 === 0) is
|
|
594
|
+
// invalid (it must base64url-decode to a header). Do NOT reject empty payload/signature.
|
|
595
|
+
if (d0 === 0)
|
|
596
|
+
fail("key_locator: empty protected segment");
|
|
597
|
+
const protectedText = compact.subarray(0, d0);
|
|
598
|
+
if (protectedText.length > resolve(b, "encoded_segment_bytes"))
|
|
599
|
+
fail("key_locator: protected bound");
|
|
600
|
+
const protectedBytes = base64urlDecode(protectedText, resolve(b, "decoded_segment_bytes"));
|
|
601
|
+
// Cross-vendor re-review Finding 3: thread the caller-resolved bounds into the JSON decode
|
|
602
|
+
// (reference v1.ex:27 Json.decode, shared by the v2 façade (header_bytes, bounds) — depth/total_nodes limits honor bounds).
|
|
603
|
+
const h = jsonDecode(protectedBytes, b);
|
|
604
|
+
requireObjectExact(h, ["alg", "typ", "kid"], "grant header");
|
|
605
|
+
requireStringLit(h, "alg", ALG, "grant header alg");
|
|
606
|
+
requireStringLit(h, "typ", GRANT_TYP, "grant header typ");
|
|
607
|
+
const kidV = h.v.get("kid");
|
|
608
|
+
if (kidV.t !== "string")
|
|
609
|
+
fail("header: kid string");
|
|
610
|
+
if (kidV.v.length < 1 || kidV.v.length > resolve(b, "kid_bytes"))
|
|
611
|
+
fail("header: kid bytes");
|
|
612
|
+
const kid = utf8Str(kidV.v);
|
|
613
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(kid))
|
|
614
|
+
fail("header: kid charset");
|
|
615
|
+
return { keyId: kid, trust: "not_evaluated" };
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
// 2. decode_grant (REQ1-VERIFY-decode-not-evaluated).
|
|
619
|
+
export function decodeGrant(compact, bounds) {
|
|
620
|
+
return trying(() => {
|
|
621
|
+
closedShape([compact, bounds], ["bytes", SHAPE_BOUNDS_OPT]);
|
|
622
|
+
const b = bounds ?? MAXIMUM_BOUNDS;
|
|
623
|
+
const seg = parseCompact(compact, b);
|
|
624
|
+
const { kid } = parseGrantHeader(seg, b);
|
|
625
|
+
const p = jsonDecode(seg.payloadBytes, b);
|
|
626
|
+
validateGrantPayload(p, b);
|
|
627
|
+
if (p.t !== "object")
|
|
628
|
+
fail("decode_grant: payload object");
|
|
629
|
+
const pobj = p;
|
|
630
|
+
const iss = requireStringOrUri(pobj.v.get("iss"), "iss", b);
|
|
631
|
+
const jti = requireStringOrUri(pobj.v.get("jti"), "jti", b);
|
|
632
|
+
const aud = extractAudience(pobj.v.get("aud"), b);
|
|
633
|
+
const iat = requireInt(pobj.v.get("iat"), "iat");
|
|
634
|
+
const nbf = requireInt(pobj.v.get("nbf"), "nbf");
|
|
635
|
+
const exp = requireInt(pobj.v.get("exp"), "exp");
|
|
636
|
+
if (!(iat < exp) || !(nbf < exp))
|
|
637
|
+
fail("grant: times coherent");
|
|
638
|
+
const cnf = pobj.v.get("cnf");
|
|
639
|
+
requireObjectExact(cnf, ["jkt"], "grant cnf");
|
|
640
|
+
const jkt = requireB64urlN(cnf.v.get("jkt"), "jkt", 32);
|
|
641
|
+
return {
|
|
642
|
+
keyId: kid, issuer: iss, grantId: jti, audiences: aud,
|
|
643
|
+
issuedAt: iat, notBefore: nbf, expiresAt: exp,
|
|
644
|
+
holderThumbprint: jkt, verification: "not_evaluated",
|
|
645
|
+
};
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
// 3. decode_proof.
|
|
649
|
+
export function decodeProof(compact, bounds) {
|
|
650
|
+
return trying(() => {
|
|
651
|
+
closedShape([compact, bounds], ["bytes", SHAPE_BOUNDS_OPT]);
|
|
652
|
+
const b = bounds ?? MAXIMUM_BOUNDS;
|
|
653
|
+
const seg = parseCompact(compact, b);
|
|
654
|
+
const { holderThumbprint } = parseProofHeader(seg, b);
|
|
655
|
+
const p = jsonDecode(seg.payloadBytes, b);
|
|
656
|
+
validateProofPayload(p, b);
|
|
657
|
+
const jti = requireStringOrUri(p.v.get("jti"), "jti", b);
|
|
658
|
+
return { proofId: jti, holderThumbprint, verification: "not_evaluated" };
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
// 4. verify_grant (REQ1-VERIFY-grant-exact, grant-times, no-iat-nbf-order).
|
|
662
|
+
export function verifyGrant(compact, trusted, expected) {
|
|
663
|
+
return trying(() => {
|
|
664
|
+
closedShape([compact, trusted, expected], ["bytes", "object", "object"]);
|
|
665
|
+
// Cross-vendor #22 (fail-closed shallow): the reference pattern-matches %TrustedIssuer{} and
|
|
666
|
+
// returns {:error, :invalid} for any malformed context struct (runtime.ex:181,196). A null OR a
|
|
667
|
+
// struct missing publicKey/keyId must fail closed — not throw a native TypeError that escapes the
|
|
668
|
+
// Result contract. Validate the trusted issuer's shape before dereferencing its fields.
|
|
669
|
+
if (trusted === null || trusted === undefined)
|
|
670
|
+
fail("verify_grant: trusted issuer required");
|
|
671
|
+
if (!(trusted.publicKey instanceof Uint8Array) || trusted.publicKey.length !== 32)
|
|
672
|
+
fail("verify_grant: issuer key width");
|
|
673
|
+
if (typeof trusted.keyId !== "string")
|
|
674
|
+
fail("verify_grant: issuer key id");
|
|
675
|
+
// Cross-vendor #19: the reference requires is_integer(evaluation_time) and is_integer(clock_skew)
|
|
676
|
+
// (>= 0) — runtime.ex:522-523. A range-only `< 0` check accepts fractional times.
|
|
677
|
+
if (!Number.isInteger(expected.evaluationTime))
|
|
678
|
+
fail("verify_grant: integer evaluation time");
|
|
679
|
+
// BAP-09 #10/#11: the reference resolves Bounds.coerce(expected.bounds) once (runtime.ex:186) and
|
|
680
|
+
// threads it into validate_expected_grant (clock_skew <= bounds.clock_skew) + every bound-sensitive
|
|
681
|
+
// check below. A caller tightening via expected.bounds now actually takes effect.
|
|
682
|
+
const b = coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
683
|
+
if (!Number.isInteger(expected.clockSkew) || expected.clockSkew < 0 || expected.clockSkew > resolve(b, "clock_skew"))
|
|
684
|
+
fail("verify_grant: skew");
|
|
685
|
+
const seg = parseCompact(compact, b);
|
|
686
|
+
const { kid } = parseGrantHeader(seg, b);
|
|
687
|
+
if (kid !== trusted.keyId)
|
|
688
|
+
fail("verify_grant: kid exact");
|
|
689
|
+
const p = jsonDecode(seg.payloadBytes, b);
|
|
690
|
+
validateGrantPayload(p, b);
|
|
691
|
+
if (p.t !== "object")
|
|
692
|
+
fail("verify_grant: payload object");
|
|
693
|
+
const pobj = p;
|
|
694
|
+
const iss = requireStringOrUri(pobj.v.get("iss"), "iss", b);
|
|
695
|
+
if (iss !== expected.issuer)
|
|
696
|
+
fail("verify_grant: issuer exact");
|
|
697
|
+
const aud = extractAudience(pobj.v.get("aud"), b);
|
|
698
|
+
if (!aud.includes(expected.audience))
|
|
699
|
+
fail("verify_grant: audience match");
|
|
700
|
+
const iat = requireInt(pobj.v.get("iat"), "iat");
|
|
701
|
+
const nbf = requireInt(pobj.v.get("nbf"), "nbf");
|
|
702
|
+
const exp = requireInt(pobj.v.get("exp"), "exp");
|
|
703
|
+
if (!(iat < exp) || !(nbf < exp))
|
|
704
|
+
fail("verify_grant: times coherent");
|
|
705
|
+
if (!(iat <= expected.evaluationTime + expected.clockSkew))
|
|
706
|
+
fail("verify_grant: iat window");
|
|
707
|
+
if (!(nbf <= expected.evaluationTime + expected.clockSkew))
|
|
708
|
+
fail("verify_grant: nbf window");
|
|
709
|
+
if (!(exp > expected.evaluationTime - expected.clockSkew))
|
|
710
|
+
fail("verify_grant: exp window");
|
|
711
|
+
const cnf = pobj.v.get("cnf");
|
|
712
|
+
requireObjectExact(cnf, ["jkt"], "grant cnf");
|
|
713
|
+
const jkt = requireB64urlN(cnf.v.get("jkt"), "jkt", 32);
|
|
714
|
+
const fp = thumbprintRaw(jwkFromPublicKey(trusted.publicKey));
|
|
715
|
+
const key = importPublicKey(trusted.publicKey, utf8Str(base64urlEncode(fp)));
|
|
716
|
+
if (!ed25519Verify(seg.signingInput, seg.signature, key))
|
|
717
|
+
fail("verify_grant: signature");
|
|
718
|
+
return {
|
|
719
|
+
version: VERSION, issuer: iss, grantId: requireStringOrUri(p.v.get("jti"), "jti", b),
|
|
720
|
+
issuerKeyFingerprint: fp, holderThumbprint: jkt, matchedAudience: expected.audience,
|
|
721
|
+
issuedAt: iat, notBefore: nbf, expiresAt: exp, authorization: "not_evaluated",
|
|
722
|
+
};
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
// 5. check_envelope (REQ1-VERIFY-envelope-binding).
|
|
726
|
+
export function checkEnvelope(grantCompact, proofCompact, expected) {
|
|
727
|
+
return trying(() => {
|
|
728
|
+
closedShape([grantCompact, proofCompact, expected], ["bytes", "bytes", "object"]);
|
|
729
|
+
const t = expected.trustedIssuer;
|
|
730
|
+
// Cross-vendor #22 (fail-closed shallow): a null/wrong-typed trustedIssuer (or any structured-
|
|
731
|
+
// input field) must fail closed as InvalidError, not propagate a native TypeError from the deref
|
|
732
|
+
// below. Validate the context shape before touching it. The reference returns {:error,:invalid}
|
|
733
|
+
// for all malformed input.
|
|
734
|
+
if (t === null || t === undefined)
|
|
735
|
+
fail("check_envelope: trusted issuer required");
|
|
736
|
+
if (!(t.publicKey instanceof Uint8Array) || t.publicKey.length !== 32)
|
|
737
|
+
fail("check_envelope: issuer key width");
|
|
738
|
+
if (typeof t.keyId !== "string")
|
|
739
|
+
fail("check_envelope: issuer key id");
|
|
740
|
+
// Cross-vendor #19: the reference requires is_integer(evaluation_time), is_integer(clock_skew)
|
|
741
|
+
// (>= 0), and proof_max_age > 0 (strictly positive) — runtime.ex:522-523,550-551. A range-only
|
|
742
|
+
// `< 0` check accepts fractional times and proofMaxAge=0. The signed-time boundary is exact.
|
|
743
|
+
if (!Number.isInteger(expected.evaluationTime))
|
|
744
|
+
fail("check_envelope: integer evaluation time");
|
|
745
|
+
// BAP-09 #10/#11: the reference resolves Bounds.coerce(expected.bounds) once (runtime.ex:204) and
|
|
746
|
+
// threads it into validate_expected_request (clock_skew, proof_max_age) + parse_grant + parse_proof
|
|
747
|
+
// + every bound-sensitive claim check below. A caller tightening via expected.bounds now takes
|
|
748
|
+
// effect across both the grant and the proof.
|
|
749
|
+
const b = coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
750
|
+
if (!Number.isInteger(expected.clockSkew) || expected.clockSkew < 0 || expected.clockSkew > resolve(b, "clock_skew"))
|
|
751
|
+
fail("check_envelope: skew");
|
|
752
|
+
if (!Number.isInteger(expected.proofMaxAge) || expected.proofMaxAge <= 0 || expected.proofMaxAge > resolve(b, "proof_max_age"))
|
|
753
|
+
fail("check_envelope: proof_max_age");
|
|
754
|
+
const expectedNonce = expected.nonce;
|
|
755
|
+
if (typeof expectedNonce !== "object" || expectedNonce === null)
|
|
756
|
+
fail("check_envelope: nonce shape");
|
|
757
|
+
const nonceKind = expectedNonce.kind;
|
|
758
|
+
if (nonceKind !== "not_required" && nonceKind !== "required")
|
|
759
|
+
fail("check_envelope: nonce kind");
|
|
760
|
+
if (nonceKind === "required" && typeof expectedNonce.value !== "string") {
|
|
761
|
+
fail("check_envelope: nonce value");
|
|
762
|
+
}
|
|
763
|
+
const expectedUri = uriNormalize(strUtf8(expected.targetUri), b);
|
|
764
|
+
if (!expectedUri.ok || utf8Str(expectedUri.value) !== expected.targetUri)
|
|
765
|
+
fail("check_envelope: expected target_uri");
|
|
766
|
+
// --- verify grant (issuer signature + context) ---
|
|
767
|
+
const gseg = parseCompact(grantCompact, b);
|
|
768
|
+
const { kid: gkid } = parseGrantHeader(gseg, b);
|
|
769
|
+
if (gkid !== t.keyId)
|
|
770
|
+
fail("check_envelope: grant kid");
|
|
771
|
+
const gp = jsonDecode(gseg.payloadBytes, b);
|
|
772
|
+
validateGrantPayload(gp, b);
|
|
773
|
+
if (gp.t !== "object")
|
|
774
|
+
fail("check_envelope: grant payload");
|
|
775
|
+
const gobj = gp;
|
|
776
|
+
const giss = requireStringOrUri(gobj.v.get("iss"), "iss", b);
|
|
777
|
+
if (giss !== expected.issuer)
|
|
778
|
+
fail("check_envelope: issuer");
|
|
779
|
+
const gaud = extractAudience(gobj.v.get("aud"), b);
|
|
780
|
+
if (!gaud.includes(expected.audience))
|
|
781
|
+
fail("check_envelope: audience");
|
|
782
|
+
const giat = requireInt(gobj.v.get("iat"), "iat");
|
|
783
|
+
const gnbf = requireInt(gobj.v.get("nbf"), "nbf");
|
|
784
|
+
const gexp = requireInt(gobj.v.get("exp"), "exp");
|
|
785
|
+
// Cross-vendor #4: checkEnvelope must enforce grant-time coherence (iat<exp, nbf<exp), mirroring
|
|
786
|
+
// the reference's coherent_times? (runtime.ex:872-875) which fires at parse time. decodeGrant and
|
|
787
|
+
// verifyGrant already check this; checkEnvelope had its own inline path that omitted it.
|
|
788
|
+
if (!(giat < gexp) || !(gnbf < gexp))
|
|
789
|
+
fail("check_envelope: grant times coherent");
|
|
790
|
+
if (!(giat <= expected.evaluationTime + expected.clockSkew))
|
|
791
|
+
fail("check_envelope: grant iat");
|
|
792
|
+
if (!(gnbf <= expected.evaluationTime + expected.clockSkew))
|
|
793
|
+
fail("check_envelope: grant nbf");
|
|
794
|
+
if (!(gexp > expected.evaluationTime - expected.clockSkew))
|
|
795
|
+
fail("check_envelope: grant exp");
|
|
796
|
+
const gfp = thumbprintRaw(jwkFromPublicKey(t.publicKey));
|
|
797
|
+
const gkey = importPublicKey(t.publicKey, utf8Str(base64urlEncode(gfp)));
|
|
798
|
+
if (!ed25519Verify(gseg.signingInput, gseg.signature, gkey))
|
|
799
|
+
fail("check_envelope: grant signature");
|
|
800
|
+
// --- verify proof (holder signature) ---
|
|
801
|
+
const pseg = parseCompact(proofCompact, b);
|
|
802
|
+
const { holderThumbprint, holderKey } = parseProofHeader(pseg, b);
|
|
803
|
+
const pp = jsonDecode(pseg.payloadBytes, b);
|
|
804
|
+
validateProofPayload(pp, b);
|
|
805
|
+
const hkey = importPublicKey(holderKey, utf8Str(base64urlEncode(holderThumbprint)));
|
|
806
|
+
if (!ed25519Verify(pseg.signingInput, pseg.signature, hkey))
|
|
807
|
+
fail("check_envelope: proof signature");
|
|
808
|
+
if (pp.t !== "object")
|
|
809
|
+
fail("check_envelope: proof payload");
|
|
810
|
+
// ath = SHA-256(ASCII grant compact), gated by scan (shape+size, not canonicity) — mirrors
|
|
811
|
+
// CompactJws.hash (compact_jws.ex:60-66 scan then hash). The grant was already parsed above, so
|
|
812
|
+
// this scan is redundant for verify but matches the reference's hash gate exactly.
|
|
813
|
+
scanCompact(grantCompact, b);
|
|
814
|
+
const athRaw = sha256(grantCompact);
|
|
815
|
+
const athB64 = utf8Str(base64urlEncode(athRaw));
|
|
816
|
+
const ppAth = pp.v.get("ath");
|
|
817
|
+
if (ppAth.t !== "string" || utf8Str(ppAth.v) !== athB64)
|
|
818
|
+
fail("check_envelope: ath");
|
|
819
|
+
// Method / URI / invocation / operation bindings.
|
|
820
|
+
const htm = requireMethod(pp.v.get("htm"), "htm", b);
|
|
821
|
+
if (htm !== expected.method)
|
|
822
|
+
fail("check_envelope: method");
|
|
823
|
+
const htu = requireNormalizedUri(pp.v.get("htu"), "htu", b);
|
|
824
|
+
if (htu !== expected.targetUri)
|
|
825
|
+
fail("check_envelope: target_uri");
|
|
826
|
+
const baInv = requireUuid(pp.v.get("ba_inv"), "ba_inv");
|
|
827
|
+
if (baInv !== expected.invocationId)
|
|
828
|
+
fail("check_envelope: invocation_id");
|
|
829
|
+
const baOp = requireOperation(pp.v.get("ba_op"), "ba_op", b);
|
|
830
|
+
if (baOp !== expected.operation)
|
|
831
|
+
fail("check_envelope: operation");
|
|
832
|
+
// ba_req = request_digest(operation, cast_arguments) (base64url).
|
|
833
|
+
const baReqRaw = computeRequestDigest(baOp, expected.castArguments, b);
|
|
834
|
+
const baReqB64 = utf8Str(base64urlEncode(baReqRaw));
|
|
835
|
+
const ppBaReq = pp.v.get("ba_req");
|
|
836
|
+
if (ppBaReq.t !== "string" || utf8Str(ppBaReq.v) !== baReqB64)
|
|
837
|
+
fail("check_envelope: ba_req");
|
|
838
|
+
// Proof time window (REQ1-VERIFY-envelope-binding).
|
|
839
|
+
const piat = requireInt(pp.v.get("iat"), "iat");
|
|
840
|
+
if (!(piat >= expected.evaluationTime - expected.proofMaxAge - expected.clockSkew))
|
|
841
|
+
fail("check_envelope: proof iat min");
|
|
842
|
+
if (!(piat <= expected.evaluationTime + expected.clockSkew))
|
|
843
|
+
fail("check_envelope: proof iat max");
|
|
844
|
+
// Nonce binding.
|
|
845
|
+
const ppNonce = pp.v.get("nonce");
|
|
846
|
+
if (expected.nonce.kind === "not_required") {
|
|
847
|
+
if (ppNonce !== undefined)
|
|
848
|
+
fail("check_envelope: nonce must be absent");
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
if (!ppNonce || ppNonce.t !== "string" || utf8Str(ppNonce.v) !== expected.nonce.value)
|
|
852
|
+
fail("check_envelope: nonce mismatch");
|
|
853
|
+
}
|
|
854
|
+
// Holder thumbprint must match grant cnf.jkt.
|
|
855
|
+
const cnf = gobj.v.get("cnf");
|
|
856
|
+
requireObjectExact(cnf, ["jkt"], "grant cnf");
|
|
857
|
+
const jkt = requireB64urlN(cnf.v.get("jkt"), "jkt", 32);
|
|
858
|
+
if (!bytesEqual(jkt, holderThumbprint))
|
|
859
|
+
fail("check_envelope: holder thumbprint");
|
|
860
|
+
// The requested operation must be unique + every selector conjunctively matches.
|
|
861
|
+
const opsV = gobj.v.get("operations");
|
|
862
|
+
if (!opsV || opsV.t !== "array")
|
|
863
|
+
fail("check_envelope: operations");
|
|
864
|
+
const matching = opsV.v.filter((op) => {
|
|
865
|
+
if (op.t !== "object")
|
|
866
|
+
return false;
|
|
867
|
+
const nameV = op.v.get("name");
|
|
868
|
+
return nameV !== undefined && nameV.t === "string" && utf8Str(nameV.v) === expected.operation;
|
|
869
|
+
});
|
|
870
|
+
if (matching.length !== 1)
|
|
871
|
+
fail("check_envelope: unique operation");
|
|
872
|
+
const matchOp = matching[0];
|
|
873
|
+
const selsV = matchOp.v.get("selectors");
|
|
874
|
+
if (!selsV || selsV.t !== "array")
|
|
875
|
+
fail("check_envelope: selectors");
|
|
876
|
+
for (const s of selsV.v) {
|
|
877
|
+
const sel = parseSelector(s, b);
|
|
878
|
+
if (!selectorMatches(sel, expected.castArguments))
|
|
879
|
+
fail("check_envelope: selector");
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
version: VERSION, issuer: giss,
|
|
883
|
+
grantId: requireStringOrUri(gobj.v.get("jti"), "jti", b),
|
|
884
|
+
issuerKeyFingerprint: gfp, holderThumbprint, matchedAudience: expected.audience,
|
|
885
|
+
grantIssuedAt: giat, grantNotBefore: gnbf, grantExpiresAt: gexp,
|
|
886
|
+
proofId: requireStringOrUri(pp.v.get("jti"), "jti", b),
|
|
887
|
+
invocationId: baInv, operation: baOp, uri: htu,
|
|
888
|
+
grantHash: athRaw, requestHash: baReqRaw, proofIssuedAt: piat,
|
|
889
|
+
authorization: "not_evaluated",
|
|
890
|
+
};
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
// 6. request_digest (the façade; returns Ok<raw 32-byte digest> | Err — cross-vendor #21: mirror
|
|
894
|
+
// the Elixir {:ok, binary} | {:error, :invalid} and the other 15 façade functions).
|
|
895
|
+
export function requestDigest(operation, castArguments, bounds) {
|
|
896
|
+
return trying(() => {
|
|
897
|
+
closedShape([operation, castArguments, bounds], ["str", "tagged", SHAPE_BOUNDS_OPT]);
|
|
898
|
+
return computeRequestDigest(operation, castArguments, bounds ?? MAXIMUM_BOUNDS);
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
export function encodeConsumptionEntry(entry, bounds) {
|
|
902
|
+
return trying(() => {
|
|
903
|
+
closedShape([entry, bounds], [{ fields: { chainId: "str", sequence: "int", previousHash: "bytes", commitment: "bytes" } }, SHAPE_BOUNDS_OPT]);
|
|
904
|
+
const b = bounds ?? MAXIMUM_BOUNDS;
|
|
905
|
+
if (!Number.isInteger(entry.sequence) || entry.sequence < 1)
|
|
906
|
+
fail("encode_consumption_entry: positive sequence");
|
|
907
|
+
assert(entry.previousHash.length === 32, "encode_consumption_entry: previous_hash width");
|
|
908
|
+
assert(entry.commitment.length === 32, "encode_consumption_entry: commitment width");
|
|
909
|
+
const chainIdBytes = strUtf8(entry.chainId);
|
|
910
|
+
if (chainIdBytes.length < 1 || chainIdBytes.length > resolve(b, "identifier_bytes"))
|
|
911
|
+
fail("encode_consumption_entry: chain_id bytes");
|
|
912
|
+
if (!isStringOrUri(entry.chainId))
|
|
913
|
+
fail("encode_consumption_entry: chain_id string-or-uri");
|
|
914
|
+
// Genesis invariant (consumption_chain.ex:123 validate_entry): sequence 1 requires the
|
|
915
|
+
// all-zero predecessor. The verifier re-checks this, but the producer must reject pre-signing.
|
|
916
|
+
if (entry.sequence === 1 && !bytesEqual(entry.previousHash, DEFAULT_HASH))
|
|
917
|
+
fail("encode_consumption_entry: genesis predecessor");
|
|
918
|
+
const rowBytes = canonicalRowBytesFromId(chainIdBytes, entry.sequence, entry.previousHash, entry.commitment, b);
|
|
919
|
+
if (rowBytes.length > resolve(b, "chain_row_bytes"))
|
|
920
|
+
fail("encode_consumption_entry: chain_row_bytes");
|
|
921
|
+
const hash = sha256(ROW_PREFIX, rowBytes);
|
|
922
|
+
return { bytes: rowBytes, hash };
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
// The canonical row bytes shared by the producer and the verifier (so the verifier's re-encode
|
|
926
|
+
// produces EXACTLY the bytes the producer emits and the chain hash is computed over). Two entry
|
|
927
|
+
// points: the producer works from the chain_id UTF-8 bytes it already validated; the verifier works
|
|
928
|
+
// from the decoded chain_id string (re-encoding it to bytes). Both must agree byte-for-byte.
|
|
929
|
+
function canonicalRowBytesFromId(chainIdBytes, sequence, previousHash, commitment, b) {
|
|
930
|
+
const members = new Map([
|
|
931
|
+
["chain_id", { t: "string", v: chainIdBytes }],
|
|
932
|
+
["commitment", { t: "string", v: strUtf8(utf8Str(base64urlEncode(commitment))) }],
|
|
933
|
+
["previous", { t: "string", v: strUtf8(utf8Str(base64urlEncode(previousHash))) }],
|
|
934
|
+
["sequence", { t: "int", v: sequence }],
|
|
935
|
+
["v", { t: "int", v: VERSION }],
|
|
936
|
+
]);
|
|
937
|
+
return jcsEncode({ t: "object", v: members }, b);
|
|
938
|
+
}
|
|
939
|
+
function canonicalRowBytes(chainId, sequence, previousHash, commitment, b = MAXIMUM_BOUNDS) {
|
|
940
|
+
return canonicalRowBytesFromId(strUtf8(chainId), sequence, previousHash, commitment, b);
|
|
941
|
+
}
|
|
942
|
+
// 8. check_chain (ADR 0004 § Consumption rows; REQ1-CHAIN-raw-rows-bounds).
|
|
943
|
+
export function checkChain(chain, expected) {
|
|
944
|
+
return trying(() => {
|
|
945
|
+
closedShape([chain, expected], [{ fields: { rows: { seq: "bytes" }, chainId: "str", firstSequence: "int", lastSequence: "int", rowCount: "int", previousHash: "bytes", lastHash: "bytes" } }, { fields: { chainId: "str", firstSequence: "int", lastSequence: "int", rowCount: "int", previousHash: "bytes", lastHash: "bytes", bounds: SHAPE_BOUNDS_OPT } }]);
|
|
946
|
+
// BAP-09 #10/#11: the reference resolves Bounds.coerce(expected.bounds) once (consumption_chain.ex
|
|
947
|
+
// check_chain) and threads it into the row-count bound + every parse_row (chain_row_bytes). A
|
|
948
|
+
// caller tightening via expected.bounds now takes effect.
|
|
949
|
+
const b = coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
950
|
+
if (typeof chain.chainId !== "string" || !isStringOrUri(chain.chainId) || expected.chainId !== chain.chainId)
|
|
951
|
+
fail("check_chain: chain_id");
|
|
952
|
+
if (expected.firstSequence !== chain.firstSequence)
|
|
953
|
+
fail("check_chain: first_sequence");
|
|
954
|
+
if (expected.lastSequence !== chain.lastSequence)
|
|
955
|
+
fail("check_chain: last_sequence");
|
|
956
|
+
if (expected.rowCount !== chain.rowCount)
|
|
957
|
+
fail("check_chain: row_count");
|
|
958
|
+
if (chain.rowCount !== chain.rows.length || chain.rowCount < 1)
|
|
959
|
+
fail("check_chain: row count");
|
|
960
|
+
if (chain.rowCount > resolve(b, "chain_rows"))
|
|
961
|
+
fail("check_chain: chain_rows bound");
|
|
962
|
+
if (chain.lastSequence !== chain.firstSequence + chain.rowCount - 1)
|
|
963
|
+
fail("check_chain: range");
|
|
964
|
+
// Genesis: firstSequence === 1 requires the all-zero predecessor.
|
|
965
|
+
if (chain.firstSequence === 1) {
|
|
966
|
+
if (!bytesEqual(expected.previousHash, DEFAULT_HASH))
|
|
967
|
+
fail("check_chain: genesis predecessor");
|
|
968
|
+
}
|
|
969
|
+
// Cross-vendor F4: the reference's check_chain takes ChainInput{rows} + ExpectedChain and uses
|
|
970
|
+
// expected.previous_hash as BOTH the validation seed and the returned fact; the SDK's ChainInput
|
|
971
|
+
// also carries a previousHash that must equal the expected value (the non-genesis row walk would
|
|
972
|
+
// otherwise seed from expected while the input field flows unchecked into the returned facts).
|
|
973
|
+
// Validate equality in BOTH cases so chain.previousHash is never an unverified echo.
|
|
974
|
+
if (!bytesEqual(expected.previousHash, chain.previousHash))
|
|
975
|
+
fail("check_chain: previous_hash");
|
|
976
|
+
let previous = expected.previousHash;
|
|
977
|
+
let sequence = chain.firstSequence;
|
|
978
|
+
for (let i = 0; i < chain.rows.length; i++) {
|
|
979
|
+
const rowBytes = chain.rows[i];
|
|
980
|
+
if (rowBytes.length > resolve(b, "chain_row_bytes"))
|
|
981
|
+
fail(`check_chain: row ${i} bytes`);
|
|
982
|
+
const row = jsonDecode(rowBytes, b);
|
|
983
|
+
requireObjectExact(row, ["v", "chain_id", "sequence", "previous", "commitment"], `check_chain row ${i}`);
|
|
984
|
+
const vV = row.v.get("v");
|
|
985
|
+
if (vV.t !== "int" || vV.v !== VERSION)
|
|
986
|
+
fail(`check_chain row ${i}: v`);
|
|
987
|
+
const cidV = row.v.get("chain_id");
|
|
988
|
+
if (cidV.t !== "string" || utf8Str(cidV.v) !== chain.chainId)
|
|
989
|
+
fail(`check_chain row ${i}: chain_id`);
|
|
990
|
+
const seqV = row.v.get("sequence");
|
|
991
|
+
if (seqV.t !== "int" || seqV.v !== sequence)
|
|
992
|
+
fail(`check_chain row ${i}: sequence`);
|
|
993
|
+
// valid_sequence?: sequence must be strictly positive (> 0). The encode_consumption_entry
|
|
994
|
+
// producer already rejects sequence < 1, but the raw row stream is untrusted input here, so
|
|
995
|
+
// reject sequence 0 at verify time too (mirrors consumption_chain.ex:163 valid_sequence?).
|
|
996
|
+
if (seqV.v < 1)
|
|
997
|
+
fail(`check_chain row ${i}: sequence positive`);
|
|
998
|
+
const prevRaw = requireB64urlN(row.v.get("previous"), "previous", 32);
|
|
999
|
+
if (!bytesEqual(prevRaw, previous))
|
|
1000
|
+
fail(`check_chain row ${i}: previous link`);
|
|
1001
|
+
const commitmentRaw = requireB64urlN(row.v.get("commitment"), "commitment", 32);
|
|
1002
|
+
// Canonical re-encode: the input row bytes MUST byte-equal the canonical re-encoded form
|
|
1003
|
+
// (mirrors consumption_chain.ex:96 parse_row `encode(entry).bytes == ^bytes`). This rejects
|
|
1004
|
+
// whitespace drift and member-order drift that would otherwise hash to a different chain link.
|
|
1005
|
+
const reEncoded = canonicalRowBytes(chain.chainId, seqV.v, prevRaw, commitmentRaw, b);
|
|
1006
|
+
if (!bytesEqual(reEncoded, rowBytes))
|
|
1007
|
+
fail(`check_chain row ${i}: canonical`);
|
|
1008
|
+
previous = sha256(ROW_PREFIX, rowBytes);
|
|
1009
|
+
sequence++;
|
|
1010
|
+
}
|
|
1011
|
+
if (!bytesEqual(previous, expected.lastHash))
|
|
1012
|
+
fail("check_chain: head");
|
|
1013
|
+
return {
|
|
1014
|
+
version: 2, chainId: chain.chainId, firstSequence: chain.firstSequence,
|
|
1015
|
+
lastSequence: chain.lastSequence, rowCount: chain.rowCount,
|
|
1016
|
+
// Cross-vendor re-review F3 + F4: copy the VERIFIED expected.previousHash (not the caller's
|
|
1017
|
+
// chain.previousHash input) into a fresh Uint8Array so a later mutation of either input buffer
|
|
1018
|
+
// does not change the returned fact (the reference's Elixir binaries are immutable; TS arrays
|
|
1019
|
+
// are not). The reference returns expected.previous_hash; lastHash is freshly computed.
|
|
1020
|
+
previousHash: new Uint8Array(expected.previousHash), lastHash: previous,
|
|
1021
|
+
verification: "boundary_consistent", trust: "not_evaluated",
|
|
1022
|
+
};
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
// 9. grant_signing_input (the deterministic producer; REQ1-SIGNING-deterministic-produce).
|
|
1026
|
+
export function grantSigningInput(grant, bounds) {
|
|
1027
|
+
return trying(() => {
|
|
1028
|
+
closedShape([grant, bounds], ["object", SHAPE_BOUNDS_OPT]);
|
|
1029
|
+
const b = bounds ?? MAXIMUM_BOUNDS;
|
|
1030
|
+
const keyIdBytes = strUtf8(grant.keyId);
|
|
1031
|
+
if (keyIdBytes.length < 1 || keyIdBytes.length > resolve(b, "kid_bytes"))
|
|
1032
|
+
fail("grant_signing_input: key_id bytes");
|
|
1033
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(grant.keyId))
|
|
1034
|
+
fail("grant_signing_input: key_id charset");
|
|
1035
|
+
if (!isStringOrUri(grant.issuer))
|
|
1036
|
+
fail("grant_signing_input: issuer");
|
|
1037
|
+
if (!isStringOrUri(grant.grantId))
|
|
1038
|
+
fail("grant_signing_input: grant_id");
|
|
1039
|
+
if (grant.audiences.length < 1 || grant.audiences.length > resolve(b, "audiences"))
|
|
1040
|
+
fail("grant_signing_input: audiences count");
|
|
1041
|
+
for (const a of grant.audiences) {
|
|
1042
|
+
const ab = strUtf8(a);
|
|
1043
|
+
if (ab.length < 1 || ab.length > resolve(b, "identifier_bytes"))
|
|
1044
|
+
fail("grant_signing_input: audience bytes");
|
|
1045
|
+
if (!isStringOrUri(a))
|
|
1046
|
+
fail("grant_signing_input: audience string-or-uri");
|
|
1047
|
+
}
|
|
1048
|
+
if (!Number.isInteger(grant.issuedAt) || !Number.isInteger(grant.notBefore) || !Number.isInteger(grant.expiresAt))
|
|
1049
|
+
fail("grant_signing_input: integer times");
|
|
1050
|
+
const jktRaw = base64urlDecode(strUtf8(grant.holderThumbprint));
|
|
1051
|
+
if (jktRaw.length !== 32)
|
|
1052
|
+
fail("grant_signing_input: holder_thumbprint width");
|
|
1053
|
+
if (grant.operations.length < 1 || grant.operations.length > resolve(b, "operations"))
|
|
1054
|
+
fail("grant_signing_input: operations count");
|
|
1055
|
+
const header = new Map([
|
|
1056
|
+
["alg", { t: "string", v: strUtf8(ALG) }],
|
|
1057
|
+
["kid", { t: "string", v: keyIdBytes }],
|
|
1058
|
+
["typ", { t: "string", v: strUtf8(GRANT_TYP) }],
|
|
1059
|
+
]);
|
|
1060
|
+
const payload = buildGrantPayload(grant, b);
|
|
1061
|
+
return {
|
|
1062
|
+
kind: "grant",
|
|
1063
|
+
protectedSegment: strUtf8(utf8Str(base64urlEncode(jcsEncode({ t: "object", v: header }, b)))),
|
|
1064
|
+
payloadSegment: strUtf8(utf8Str(base64urlEncode(jcsEncode(payload, b)))),
|
|
1065
|
+
};
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
function buildGrantPayload(grant, b) {
|
|
1069
|
+
const audMembers = grant.audiences.map((a) => ({ t: "string", v: strUtf8(a) }));
|
|
1070
|
+
const opsMembers = grant.operations.map((op) => {
|
|
1071
|
+
const nameBytes = strUtf8(op.name);
|
|
1072
|
+
if (nameBytes.length < 1 || nameBytes.length > resolve(b, "operation_bytes"))
|
|
1073
|
+
fail("grant_signing_input: operation name bytes");
|
|
1074
|
+
if (!/^[\x20-\x7e]+$/.test(op.name))
|
|
1075
|
+
fail("grant_signing_input: operation name charset");
|
|
1076
|
+
if (op.selectors.length < 1 || op.selectors.length > resolve(b, "selectors"))
|
|
1077
|
+
fail("grant_signing_input: selectors count");
|
|
1078
|
+
const sels = op.selectors.map((s) => selectorToTagged(s, b));
|
|
1079
|
+
const opMembers = new Map([
|
|
1080
|
+
["name", { t: "string", v: nameBytes }],
|
|
1081
|
+
["selectors", { t: "array", v: sels }],
|
|
1082
|
+
]);
|
|
1083
|
+
return { t: "object", v: opMembers };
|
|
1084
|
+
});
|
|
1085
|
+
const cnfMembers = new Map([["jkt", { t: "string", v: strUtf8(grant.holderThumbprint) }]]);
|
|
1086
|
+
const payload = new Map([
|
|
1087
|
+
["aud", { t: "array", v: audMembers }],
|
|
1088
|
+
["cnf", { t: "object", v: cnfMembers }],
|
|
1089
|
+
["exp", { t: "int", v: grant.expiresAt }],
|
|
1090
|
+
["iat", { t: "int", v: grant.issuedAt }],
|
|
1091
|
+
["iss", { t: "string", v: strUtf8(grant.issuer) }],
|
|
1092
|
+
["jti", { t: "string", v: strUtf8(grant.grantId) }],
|
|
1093
|
+
["nbf", { t: "int", v: grant.notBefore }],
|
|
1094
|
+
["operations", { t: "array", v: opsMembers }],
|
|
1095
|
+
["v", { t: "int", v: VERSION }],
|
|
1096
|
+
]);
|
|
1097
|
+
return { t: "object", v: payload };
|
|
1098
|
+
}
|
|
1099
|
+
// Normalize a selector input (bare "all" string or object) to the tagged form for JCS.
|
|
1100
|
+
function selectorToTagged(s, b) {
|
|
1101
|
+
if (s === "all" || (typeof s === "object" && s.kind === "all")) {
|
|
1102
|
+
return { t: "object", v: new Map([["kind", { t: "string", v: strUtf8("all") }]]) };
|
|
1103
|
+
}
|
|
1104
|
+
if (typeof s === "object" && s.kind === "equals") {
|
|
1105
|
+
const path = validatePath(s.path, b);
|
|
1106
|
+
validateSelectorValue(s.value, b);
|
|
1107
|
+
const members = new Map([
|
|
1108
|
+
["kind", { t: "string", v: strUtf8("equals") }],
|
|
1109
|
+
["path", path],
|
|
1110
|
+
["value", s.value],
|
|
1111
|
+
]);
|
|
1112
|
+
return { t: "object", v: members };
|
|
1113
|
+
}
|
|
1114
|
+
if (typeof s === "object" && s.kind === "one_of") {
|
|
1115
|
+
const path = validatePath(s.path, b);
|
|
1116
|
+
if (s.values.length < 1 || s.values.length > resolve(b, "one_of_values"))
|
|
1117
|
+
fail("selector: values count");
|
|
1118
|
+
for (const v of s.values)
|
|
1119
|
+
validateSelectorValue(v, b);
|
|
1120
|
+
const members = new Map([
|
|
1121
|
+
["kind", { t: "string", v: strUtf8("one_of") }],
|
|
1122
|
+
["path", path],
|
|
1123
|
+
["values", { t: "array", v: s.values }],
|
|
1124
|
+
]);
|
|
1125
|
+
return { t: "object", v: members };
|
|
1126
|
+
}
|
|
1127
|
+
// lte / gte: the bound must be numeric-tagged before it can be minted (mirrors the
|
|
1128
|
+
// reference encode_selector numeric_bound? gate — the producer rejects pre-signing).
|
|
1129
|
+
if (typeof s === "object" && (s.kind === "lte" || s.kind === "gte")) {
|
|
1130
|
+
const path = validatePath(s.path, b);
|
|
1131
|
+
if (!isNumericTag(s.value))
|
|
1132
|
+
fail("selector: numeric bound");
|
|
1133
|
+
validateSelectorValue(s.value, b);
|
|
1134
|
+
const members = new Map([
|
|
1135
|
+
["kind", { t: "string", v: strUtf8(s.kind) }],
|
|
1136
|
+
["path", path],
|
|
1137
|
+
["value", s.value],
|
|
1138
|
+
]);
|
|
1139
|
+
return { t: "object", v: members };
|
|
1140
|
+
}
|
|
1141
|
+
fail("selector: shape");
|
|
1142
|
+
}
|
|
1143
|
+
function validatePath(path, b) {
|
|
1144
|
+
if (path.length < 1 || path.length > resolve(b, "path_segments"))
|
|
1145
|
+
fail("selector: path length");
|
|
1146
|
+
const segs = [];
|
|
1147
|
+
for (const seg of path) {
|
|
1148
|
+
const sb = strUtf8(seg);
|
|
1149
|
+
if (sb.length < 1 || sb.length > resolve(b, "key_bytes"))
|
|
1150
|
+
fail("selector: path segment bytes");
|
|
1151
|
+
segs.push({ t: "string", v: sb });
|
|
1152
|
+
}
|
|
1153
|
+
return { t: "array", v: segs };
|
|
1154
|
+
}
|
|
1155
|
+
function validateSelectorValue(v, b) {
|
|
1156
|
+
checkNode(v, 1, b);
|
|
1157
|
+
}
|
|
1158
|
+
function checkNode(v, depth, b) {
|
|
1159
|
+
if (depth > resolve(b, "depth"))
|
|
1160
|
+
fail("selector: value depth");
|
|
1161
|
+
switch (v.t) {
|
|
1162
|
+
case "string":
|
|
1163
|
+
if (v.v.length > resolve(b, "string_bytes"))
|
|
1164
|
+
fail("selector: string bytes");
|
|
1165
|
+
return;
|
|
1166
|
+
case "int":
|
|
1167
|
+
if (Math.abs(v.v) > resolve(b, "integer_magnitude"))
|
|
1168
|
+
fail("selector: int magnitude");
|
|
1169
|
+
return;
|
|
1170
|
+
case "float":
|
|
1171
|
+
if (Math.abs(v.v) > resolve(b, "float_magnitude"))
|
|
1172
|
+
fail("selector: float magnitude");
|
|
1173
|
+
return;
|
|
1174
|
+
case "array": {
|
|
1175
|
+
if (v.v.length > resolve(b, "array_items"))
|
|
1176
|
+
fail("selector: array items");
|
|
1177
|
+
for (const item of v.v)
|
|
1178
|
+
checkNode(item, depth + 1, b);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
case "object": {
|
|
1182
|
+
if (v.v.size > resolve(b, "object_members"))
|
|
1183
|
+
fail("selector: object members");
|
|
1184
|
+
for (const [, val] of v.v)
|
|
1185
|
+
checkNode(val, depth + 1, b);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
default: return;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
// 10. proof_signing_input (REQ1-SIGNING-deterministic-produce).
|
|
1192
|
+
export function proofSigningInput(proof, bounds) {
|
|
1193
|
+
return trying(() => {
|
|
1194
|
+
closedShape([proof, bounds], [SHAPE_PROOF_PRODUCER, SHAPE_BOUNDS_OPT]);
|
|
1195
|
+
const b = bounds ?? MAXIMUM_BOUNDS;
|
|
1196
|
+
assert(proof.holderPublicKey.length === 32, "proof_signing_input: holder key width");
|
|
1197
|
+
const proofIdBytes = strUtf8(proof.proofId);
|
|
1198
|
+
if (!isStringOrUri(proof.proofId))
|
|
1199
|
+
fail("proof_signing_input: proof_id");
|
|
1200
|
+
const methodBytes = strUtf8(proof.method);
|
|
1201
|
+
if (methodBytes.length < 1 || methodBytes.length > resolve(b, "method_bytes"))
|
|
1202
|
+
fail("proof_signing_input: method bytes");
|
|
1203
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(proof.method))
|
|
1204
|
+
fail("proof_signing_input: method token");
|
|
1205
|
+
// htu normalized + pre-normalized.
|
|
1206
|
+
const htuNorm = uriNormalize(strUtf8(proof.targetUri), b);
|
|
1207
|
+
if (!htuNorm.ok)
|
|
1208
|
+
fail("proof_signing_input: htu");
|
|
1209
|
+
if (utf8Str(htuNorm.value) !== proof.targetUri)
|
|
1210
|
+
fail("proof_signing_input: htu pre-normalized");
|
|
1211
|
+
if (!Number.isInteger(proof.issuedAt))
|
|
1212
|
+
fail("proof_signing_input: integer iat");
|
|
1213
|
+
if (!UUID_RE.test(proof.invocationId))
|
|
1214
|
+
fail("proof_signing_input: invocation_id");
|
|
1215
|
+
const opBytes = strUtf8(proof.operation);
|
|
1216
|
+
if (opBytes.length < 1 || opBytes.length > resolve(b, "operation_bytes"))
|
|
1217
|
+
fail("proof_signing_input: operation bytes");
|
|
1218
|
+
if (!/^[\x20-\x7e]+$/.test(proof.operation))
|
|
1219
|
+
fail("proof_signing_input: operation charset");
|
|
1220
|
+
if (proof.nonce !== undefined) {
|
|
1221
|
+
if (!isWellFormed(proof.nonce))
|
|
1222
|
+
fail("proof_signing_input: nonce well-formed");
|
|
1223
|
+
const nb = strUtf8(proof.nonce);
|
|
1224
|
+
if (nb.length < 1 || nb.length > resolve(b, "nonce_bytes"))
|
|
1225
|
+
fail("proof_signing_input: nonce bytes");
|
|
1226
|
+
}
|
|
1227
|
+
const jwk = jwkFromPublicKey(proof.holderPublicKey);
|
|
1228
|
+
const headerMembers = new Map([
|
|
1229
|
+
["alg", { t: "string", v: strUtf8(ALG) }],
|
|
1230
|
+
["jwk", jwkToTagged(jwk)],
|
|
1231
|
+
["typ", { t: "string", v: strUtf8(PROOF_TYP) }],
|
|
1232
|
+
]);
|
|
1233
|
+
// Producer ath: gate the grant compact by scan (shape+size, NOT base64url canonicity) before
|
|
1234
|
+
// hashing it into `ath` — mirrors CompactJws.ath (compact_jws.ex:53-58 scan then hash). A
|
|
1235
|
+
// caller-supplied non-compact grant must not be embedded as sha256(garbage) in the proof.
|
|
1236
|
+
scanCompact(proof.grantCompact, b);
|
|
1237
|
+
const athRaw = sha256(proof.grantCompact);
|
|
1238
|
+
const baReqRaw = computeRequestDigest(proof.operation, proof.castArguments, b);
|
|
1239
|
+
const payloadMembers = new Map([
|
|
1240
|
+
["ath", { t: "string", v: strUtf8(utf8Str(base64urlEncode(athRaw))) }],
|
|
1241
|
+
["ba_inv", { t: "string", v: strUtf8(proof.invocationId) }],
|
|
1242
|
+
["ba_op", { t: "string", v: opBytes }],
|
|
1243
|
+
["ba_req", { t: "string", v: strUtf8(utf8Str(base64urlEncode(baReqRaw))) }],
|
|
1244
|
+
["htm", { t: "string", v: methodBytes }],
|
|
1245
|
+
["htu", { t: "string", v: strUtf8(proof.targetUri) }],
|
|
1246
|
+
["iat", { t: "int", v: proof.issuedAt }],
|
|
1247
|
+
["jti", { t: "string", v: proofIdBytes }],
|
|
1248
|
+
["v", { t: "int", v: VERSION }],
|
|
1249
|
+
]);
|
|
1250
|
+
if (proof.nonce !== undefined)
|
|
1251
|
+
payloadMembers.set("nonce", { t: "string", v: strUtf8(proof.nonce) });
|
|
1252
|
+
return {
|
|
1253
|
+
kind: "proof",
|
|
1254
|
+
protectedSegment: strUtf8(utf8Str(base64urlEncode(jcsEncode({ t: "object", v: headerMembers }, b)))),
|
|
1255
|
+
payloadSegment: strUtf8(utf8Str(base64urlEncode(jcsEncode({ t: "object", v: payloadMembers }, b)))),
|
|
1256
|
+
};
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
function jwkToTagged(jwk) {
|
|
1260
|
+
const members = new Map([
|
|
1261
|
+
["crv", { t: "string", v: strUtf8(jwk.crv) }],
|
|
1262
|
+
["kty", { t: "string", v: strUtf8(jwk.kty) }],
|
|
1263
|
+
["x", { t: "string", v: strUtf8(jwk.x) }],
|
|
1264
|
+
]);
|
|
1265
|
+
return { t: "object", v: members };
|
|
1266
|
+
}
|
|
1267
|
+
// 11. assemble_compact (REQ1-VERIFY-no-signer-callback; public /2 contract, spec/bap-v2.md § Public verification contract).
|
|
1268
|
+
// Mirrors runtime.ex:147-155 assemble_compact: assemble via the low-level assembler, then
|
|
1269
|
+
// validate_assembled_compact (runtime.ex:754-780) re-parses the composed compact per kind. The
|
|
1270
|
+
// signing-input gates (kind↔typ, segment bounds, base64url payload, compact_bytes) come from
|
|
1271
|
+
// CompactJws.assemble's valid_signing_input? (compact_jws.ex:36,80-101). The public contract
|
|
1272
|
+
// carries no caller bounds, so the profile maximum (MAXIMUM_BOUNDS) is used. A mislabeled kind
|
|
1273
|
+
// (typ ≠ kind), oversized segment, non-base64url payload, or malformed payload content fails
|
|
1274
|
+
// closed — the producer must not mint bytes its own consumer (verify) would reject.
|
|
1275
|
+
export function assembleCompact(input, signature, bounds) {
|
|
1276
|
+
return trying(() => {
|
|
1277
|
+
closedShape([input, signature, bounds], [{ fields: { kind: "str", protectedSegment: "bytes", payloadSegment: "bytes" } }, "bytes", SHAPE_BOUNDS_OPT]);
|
|
1278
|
+
// ADR 0018 divergence closed (2026-08-18): the reference takes limits at assemble
|
|
1279
|
+
// (runtime.ex:147-155 → CompactJws.assemble:34-48 — encoded segment bounds, signature
|
|
1280
|
+
// width ≤ signature_bytes, compact_bytes, all against Bounds.coerce(limits)); the SDK
|
|
1281
|
+
// previously hardcoded maximum. Absent bounds = maximum (backward compatible).
|
|
1282
|
+
const b = coerceBounds(bounds ?? MAXIMUM_BOUNDS);
|
|
1283
|
+
if (input.protectedSegment.length > resolve(b, "encoded_segment_bytes") || input.payloadSegment.length > resolve(b, "encoded_segment_bytes"))
|
|
1284
|
+
fail("assemble_compact: segment bound");
|
|
1285
|
+
// (signature_bytes needs no gate: it is a FIXED-WIDTH key rejected at boundsNew
|
|
1286
|
+
// unless 64 — the reference's assemble-time check is subsumed.)
|
|
1287
|
+
const assembled = assembleSegments(input, signature);
|
|
1288
|
+
if (!assembled.ok)
|
|
1289
|
+
fail("assemble_compact: signing input");
|
|
1290
|
+
const compact = assembled.value;
|
|
1291
|
+
if (compact.length > resolve(b, "compact_bytes"))
|
|
1292
|
+
fail("assemble_compact: compact_bytes");
|
|
1293
|
+
// Re-parse the composed compact per kind (validate_assembled_compact). parseCompact enforces the
|
|
1294
|
+
// segment bounds + base64url decode; parseXxxHeader enforces kind↔typ; the payload validators
|
|
1295
|
+
// enforce the full payload structure. The GRANT arm uses decodeGrant (the full decoder) because
|
|
1296
|
+
// validateGrantPayload is structural-only — it does not validate iss/jti/aud/times/cnf, which
|
|
1297
|
+
// decodeGrant extracts + validates (mirrors reference parse_grant → decode_grant_fields).
|
|
1298
|
+
const seg = parseCompact(compact, b);
|
|
1299
|
+
const payload = jsonDecode(seg.payloadBytes, b);
|
|
1300
|
+
switch (input.kind) {
|
|
1301
|
+
case "grant": {
|
|
1302
|
+
const r = decodeGrant(compact, b);
|
|
1303
|
+
if (!r.ok)
|
|
1304
|
+
fail("assemble_compact: grant re-parse");
|
|
1305
|
+
break;
|
|
1306
|
+
}
|
|
1307
|
+
case "proof":
|
|
1308
|
+
parseProofHeader(seg, b);
|
|
1309
|
+
validateProofPayload(payload, b);
|
|
1310
|
+
break;
|
|
1311
|
+
case "boundary_anchor":
|
|
1312
|
+
parseAnchorHeader(seg, b);
|
|
1313
|
+
validateAnchorPayload(payload, seg.payloadBytes, b);
|
|
1314
|
+
break;
|
|
1315
|
+
case "key_transition":
|
|
1316
|
+
parseTransitionHeader(seg, b);
|
|
1317
|
+
validateTransitionPayload(payload, seg.payloadBytes, b);
|
|
1318
|
+
break;
|
|
1319
|
+
default: fail("assemble_compact: kind");
|
|
1320
|
+
}
|
|
1321
|
+
return compact;
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
// 12. boundary_anchor_signing_input (ADR 0004 § Boundary anchors).
|
|
1325
|
+
export function boundaryAnchorSigningInput(anchor, bounds) {
|
|
1326
|
+
return trying(() => {
|
|
1327
|
+
closedShape([anchor, bounds], ["object", SHAPE_BOUNDS_OPT]);
|
|
1328
|
+
const b = bounds ?? MAXIMUM_BOUNDS;
|
|
1329
|
+
const keyIdBytes = strUtf8(anchor.keyId);
|
|
1330
|
+
if (keyIdBytes.length < 1 || keyIdBytes.length > resolve(b, "kid_bytes"))
|
|
1331
|
+
fail("anchor_signing_input: key_id bytes");
|
|
1332
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(anchor.keyId))
|
|
1333
|
+
fail("anchor_signing_input: key_id charset");
|
|
1334
|
+
if (!isStringOrUri(anchor.anchorId))
|
|
1335
|
+
fail("anchor_signing_input: anchor_id");
|
|
1336
|
+
if (!isStringOrUri(anchor.chainId))
|
|
1337
|
+
fail("anchor_signing_input: chain_id");
|
|
1338
|
+
if (!Number.isInteger(anchor.anchoredAt))
|
|
1339
|
+
fail("anchor_signing_input: integer anchored_at");
|
|
1340
|
+
if (!Number.isInteger(anchor.sequence) || anchor.sequence < 0)
|
|
1341
|
+
fail("anchor_signing_input: non-negative sequence");
|
|
1342
|
+
assert(anchor.chainHash.length === 32, "anchor_signing_input: chain_hash width");
|
|
1343
|
+
assert(anchor.publicKey.length === 32, "anchor_signing_input: public_key width");
|
|
1344
|
+
// Genesis invariant (boundary_anchor_codec.ex:185-189 valid_anchor_binding?): sequence 0 is the
|
|
1345
|
+
// chain root and requires the all-zero chain_hash. The verifier re-checks this; the producer
|
|
1346
|
+
// rejects pre-signing so a mis-bound genesis anchor cannot be minted.
|
|
1347
|
+
if (anchor.sequence === 0 && !bytesEqual(anchor.chainHash, DEFAULT_HASH))
|
|
1348
|
+
fail("anchor_signing_input: genesis chain_hash");
|
|
1349
|
+
const header = new Map([
|
|
1350
|
+
["alg", { t: "string", v: strUtf8(ALG) }],
|
|
1351
|
+
["kid", { t: "string", v: keyIdBytes }],
|
|
1352
|
+
["typ", { t: "string", v: strUtf8(ANCHOR_TYP) }],
|
|
1353
|
+
]);
|
|
1354
|
+
const fp = thumbprintRaw(jwkFromPublicKey(anchor.publicKey));
|
|
1355
|
+
const payload = new Map([
|
|
1356
|
+
["anchor_id", { t: "string", v: strUtf8(anchor.anchorId) }],
|
|
1357
|
+
["anchored_at", { t: "int", v: anchor.anchoredAt }],
|
|
1358
|
+
["chain_hash", { t: "string", v: strUtf8(utf8Str(base64urlEncode(anchor.chainHash))) }],
|
|
1359
|
+
["chain_id", { t: "string", v: strUtf8(anchor.chainId) }],
|
|
1360
|
+
["key_fingerprint", { t: "string", v: strUtf8(utf8Str(base64urlEncode(fp))) }],
|
|
1361
|
+
["sequence", { t: "int", v: anchor.sequence }],
|
|
1362
|
+
["v", { t: "int", v: VERSION }],
|
|
1363
|
+
]);
|
|
1364
|
+
return {
|
|
1365
|
+
kind: "boundary_anchor",
|
|
1366
|
+
protectedSegment: strUtf8(utf8Str(base64urlEncode(jcsEncode({ t: "object", v: header }, b)))),
|
|
1367
|
+
payloadSegment: strUtf8(utf8Str(base64urlEncode(jcsEncode({ t: "object", v: payload }, b)))),
|
|
1368
|
+
};
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
// 13. key_transition_signing_input (ADR 0004 § Authenticated key transitions).
|
|
1372
|
+
export function keyTransitionSigningInput(t, bounds) {
|
|
1373
|
+
return trying(() => {
|
|
1374
|
+
closedShape([t, bounds], [SHAPE_TRANSITION_PRODUCER, SHAPE_BOUNDS_OPT]);
|
|
1375
|
+
const b = bounds ?? MAXIMUM_BOUNDS;
|
|
1376
|
+
assert(t.currentPublicKey.length === 32 && t.nextPublicKey.length === 32, "transition_signing_input: key width");
|
|
1377
|
+
if (bytesEqual(t.currentPublicKey, t.nextPublicKey))
|
|
1378
|
+
fail("transition_signing_input: distinct keys");
|
|
1379
|
+
const currentKeyIdBytes = strUtf8(t.currentKeyId);
|
|
1380
|
+
if (currentKeyIdBytes.length < 1 || currentKeyIdBytes.length > resolve(b, "kid_bytes"))
|
|
1381
|
+
fail("transition_signing_input: current_key_id bytes");
|
|
1382
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(t.currentKeyId))
|
|
1383
|
+
fail("transition_signing_input: current_key_id charset");
|
|
1384
|
+
const nextKeyIdBytes = strUtf8(t.nextKeyId);
|
|
1385
|
+
if (nextKeyIdBytes.length < 1 || nextKeyIdBytes.length > resolve(b, "kid_bytes"))
|
|
1386
|
+
fail("transition_signing_input: next_key_id bytes");
|
|
1387
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(t.nextKeyId))
|
|
1388
|
+
fail("transition_signing_input: next_key_id charset");
|
|
1389
|
+
if (!isStringOrUri(t.transitionId))
|
|
1390
|
+
fail("transition_signing_input: transition_id");
|
|
1391
|
+
if (!isStringOrUri(t.chainId))
|
|
1392
|
+
fail("transition_signing_input: chain_id");
|
|
1393
|
+
if (!Number.isInteger(t.effectiveAt))
|
|
1394
|
+
fail("transition_signing_input: integer effective_at");
|
|
1395
|
+
const header = new Map([
|
|
1396
|
+
["alg", { t: "string", v: strUtf8(ALG) }],
|
|
1397
|
+
["kid", { t: "string", v: currentKeyIdBytes }],
|
|
1398
|
+
["typ", { t: "string", v: strUtf8(TRANSITION_TYP) }],
|
|
1399
|
+
]);
|
|
1400
|
+
const fromFp = thumbprintRaw(jwkFromPublicKey(t.currentPublicKey));
|
|
1401
|
+
const toFp = thumbprintRaw(jwkFromPublicKey(t.nextPublicKey));
|
|
1402
|
+
const payload = new Map([
|
|
1403
|
+
["chain_id", { t: "string", v: strUtf8(t.chainId) }],
|
|
1404
|
+
["effective_at", { t: "int", v: t.effectiveAt }],
|
|
1405
|
+
["from_key_fingerprint", { t: "string", v: strUtf8(utf8Str(base64urlEncode(fromFp))) }],
|
|
1406
|
+
["to_key_fingerprint", { t: "string", v: strUtf8(utf8Str(base64urlEncode(toFp))) }],
|
|
1407
|
+
["to_key_id", { t: "string", v: nextKeyIdBytes }],
|
|
1408
|
+
["transition_id", { t: "string", v: strUtf8(t.transitionId) }],
|
|
1409
|
+
["v", { t: "int", v: VERSION }],
|
|
1410
|
+
]);
|
|
1411
|
+
return {
|
|
1412
|
+
kind: "key_transition",
|
|
1413
|
+
protectedSegment: strUtf8(utf8Str(base64urlEncode(jcsEncode({ t: "object", v: header }, b)))),
|
|
1414
|
+
payloadSegment: strUtf8(utf8Str(base64urlEncode(jcsEncode({ t: "object", v: payload }, b)))),
|
|
1415
|
+
};
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
export function encodeAnchoredExport(input, expected) {
|
|
1419
|
+
return trying(() => {
|
|
1420
|
+
closedShape([input, expected], [SHAPE_EXPORT_INPUT, SHAPE_EXPECTED_EXPORT]);
|
|
1421
|
+
// Validate inputs BEFORE framing (mirrors anchored_export_codec.ex:33-57 encode →
|
|
1422
|
+
// validate_expected_export + parse_expected_transitions + validate_expected_key_path). The
|
|
1423
|
+
// parser would reject the bytes a too-large input would produce; the producer rejects earlier.
|
|
1424
|
+
// BAP-09 #10/#11: resolve expected.bounds once and thread it through the encode-time bounds
|
|
1425
|
+
// checks so a caller tightening via expected.bounds takes effect (matches verify_anchored_export).
|
|
1426
|
+
const b = coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
1427
|
+
validateExportInputs(input, expected, b);
|
|
1428
|
+
// Row chain re-check (anchored_export_codec.ex:37-39 — ConsumptionChain.check):
|
|
1429
|
+
// the rows must verify against the expected boundaries BEFORE framing, under the
|
|
1430
|
+
// caller's OUTER bounds (the reference threads the outer bounds into the row walk,
|
|
1431
|
+
// %{expected.chain | bounds: bounds}) — and the chain's nested bounds, when present,
|
|
1432
|
+
// must coerce-equal the outer (anchored_export_codec.ex:352-354). Correctness-lens F1:
|
|
1433
|
+
// resolving only the chain's own bounds let a tightened outer wrong-ACCEPT rows the
|
|
1434
|
+
// reference rejects.
|
|
1435
|
+
requireBoundsEqual(expected.chain.bounds, b, "encode_anchored_export: chain bounds");
|
|
1436
|
+
requireBoundsEqual(expected.startAnchor.bounds, b, "encode_anchored_export: start anchor bounds");
|
|
1437
|
+
requireBoundsEqual(expected.endAnchor.bounds, b, "encode_anchored_export: end anchor bounds");
|
|
1438
|
+
for (let i = 0; i < expected.transitions.length; i++) {
|
|
1439
|
+
requireBoundsEqual(expected.transitions[i].bounds, b, `encode_anchored_export: transition ${i} bounds`);
|
|
1440
|
+
}
|
|
1441
|
+
const chainRes = checkChain({ rows: input.rows, chainId: expected.chain.chainId, firstSequence: expected.chain.firstSequence, lastSequence: expected.chain.lastSequence, rowCount: expected.chain.rowCount, previousHash: expected.chain.previousHash, lastHash: expected.chain.lastHash }, { ...expected.chain, bounds: b });
|
|
1442
|
+
if (!chainRes.ok)
|
|
1443
|
+
fail("encode_anchored_export: rows chain");
|
|
1444
|
+
// Gated parses + full signed-field matches (anchored_export_codec.ex:40-52): the
|
|
1445
|
+
// start anchor, the end anchor, and every transition go through the width+canonical
|
|
1446
|
+
// gated decode and match their expected values field-by-field.
|
|
1447
|
+
parseAndMatchAnchor(input.startAnchor, expected.startAnchor, "start", b);
|
|
1448
|
+
parseAndMatchAnchor(input.endAnchor, expected.endAnchor, "end", b);
|
|
1449
|
+
for (let i = 0; i < input.transitions.length; i++) {
|
|
1450
|
+
parseAndMatchTransition(input.transitions[i], expected.transitions[i], i, b);
|
|
1451
|
+
}
|
|
1452
|
+
const headerBytes = buildArchiveHeader(input, expected.chain, b);
|
|
1453
|
+
// Build the framed chunk list, then validate count + bytes BEFORE materializing the joined
|
|
1454
|
+
// archive (mirrors reference validate_chunks on the chunk list, anchored_export_codec.ex:69 — not
|
|
1455
|
+
// on a concatenated binary, so an over-bound input rejects before the allocation). Loop-build
|
|
1456
|
+
// avoids spreading the chunk list past V8's ~65534 call-arg ceiling (archive_chunks ≤ 65796).
|
|
1457
|
+
const parts = [ARCHIVE_PREFIX, frame(headerBytes), frame(input.startAnchor)];
|
|
1458
|
+
for (const t of input.transitions)
|
|
1459
|
+
parts.push(frame(t));
|
|
1460
|
+
for (const r of input.rows)
|
|
1461
|
+
parts.push(frame(r));
|
|
1462
|
+
parts.push(frame(input.endAnchor));
|
|
1463
|
+
if (parts.length > resolve(b, "archive_chunks"))
|
|
1464
|
+
fail("encode_anchored_export: archive_chunks");
|
|
1465
|
+
let total = 0;
|
|
1466
|
+
for (const p of parts)
|
|
1467
|
+
total += p.length;
|
|
1468
|
+
if (total > resolve(b, "archive_bytes"))
|
|
1469
|
+
fail("encode_anchored_export: archive_bytes");
|
|
1470
|
+
const archive = new Uint8Array(total);
|
|
1471
|
+
let off = 0;
|
|
1472
|
+
for (const p of parts) {
|
|
1473
|
+
archive.set(p, off);
|
|
1474
|
+
off += p.length;
|
|
1475
|
+
}
|
|
1476
|
+
return { archive, digest: sha256(archive) };
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
// Gated parse + full 7-field match of an anchor compact against its expected values
|
|
1480
|
+
// (reference BoundaryAnchorCodec.parse + anchor_matches?, anchored_export_codec.ex:40-42/:485-492).
|
|
1481
|
+
// Encode never verifies a signature (a producer, not an authority) — it mirrors the
|
|
1482
|
+
// reference's structural gates (width, canonical form) and the signed-field match.
|
|
1483
|
+
function parseAndMatchAnchor(compact, expected, which, b) {
|
|
1484
|
+
// The reference codec bounds every anchor compact at anchor_bytes (boundary_anchor_codec.ex:82,
|
|
1485
|
+
// from the encode with-chain :40-42) — stricter than parseCompact's whole-input compact_bytes
|
|
1486
|
+
// ceiling; check it BEFORE the parse (cross-vendor round 2: the producer minted archives its
|
|
1487
|
+
// own verifier rejects at the frame read).
|
|
1488
|
+
if (compact.length > resolve(b, "anchor_bytes"))
|
|
1489
|
+
fail(`encode_anchored_export: ${which} anchor anchor_bytes`);
|
|
1490
|
+
const seg = parseCompact(compact, b);
|
|
1491
|
+
const { kid } = parseAnchorHeader(seg, b);
|
|
1492
|
+
if (kid !== expected.keyId)
|
|
1493
|
+
fail(`encode_anchored_export: ${which} anchor kid`);
|
|
1494
|
+
const p = jsonDecode(seg.payloadBytes, b);
|
|
1495
|
+
validateAnchorPayload(p, seg.payloadBytes, b);
|
|
1496
|
+
if (p.t !== "object")
|
|
1497
|
+
fail(`encode_anchored_export: ${which} anchor payload`);
|
|
1498
|
+
if (requireStringOrUri(p.v.get("anchor_id"), "anchor_id", b) !== expected.anchorId)
|
|
1499
|
+
fail(`encode_anchored_export: ${which} anchor_id`);
|
|
1500
|
+
if (requireInt(p.v.get("anchored_at"), "anchored_at") !== expected.anchoredAt)
|
|
1501
|
+
fail(`encode_anchored_export: ${which} anchored_at`);
|
|
1502
|
+
if (requireStringOrUri(p.v.get("chain_id"), "chain_id", b) !== expected.chainId)
|
|
1503
|
+
fail(`encode_anchored_export: ${which} chain_id`);
|
|
1504
|
+
if (requireInt(p.v.get("sequence"), "sequence") !== expected.sequence)
|
|
1505
|
+
fail(`encode_anchored_export: ${which} sequence`);
|
|
1506
|
+
if (!bytesEqual(requireB64urlN(p.v.get("chain_hash"), "chain_hash", 32), expected.chainHash))
|
|
1507
|
+
fail(`encode_anchored_export: ${which} chain_hash`);
|
|
1508
|
+
if (!bytesEqual(requireB64urlN(p.v.get("key_fingerprint"), "key_fingerprint", 32), expected.keyFingerprint))
|
|
1509
|
+
fail(`encode_anchored_export: ${which} key_fingerprint`);
|
|
1510
|
+
}
|
|
1511
|
+
// Gated parse + full 7-field match of a transition compact (reference
|
|
1512
|
+
// KeyTransitionCodec.parse + transition_matches?, anchored_export_codec.ex:443-504).
|
|
1513
|
+
function parseAndMatchTransition(compact, expected, i, b) {
|
|
1514
|
+
// Same anchor_bytes ceiling the reference enforces on transitions (key_transition_codec.ex:114).
|
|
1515
|
+
if (compact.length > resolve(b, "anchor_bytes"))
|
|
1516
|
+
fail(`encode_anchored_export: transition ${i} anchor_bytes`);
|
|
1517
|
+
const seg = parseCompact(compact, b);
|
|
1518
|
+
const { kid } = parseTransitionHeader(seg, b);
|
|
1519
|
+
if (kid !== expected.currentKeyId)
|
|
1520
|
+
fail(`encode_anchored_export: transition ${i} kid`);
|
|
1521
|
+
const p = jsonDecode(seg.payloadBytes, b);
|
|
1522
|
+
validateTransitionPayload(p, seg.payloadBytes, b);
|
|
1523
|
+
if (p.t !== "object")
|
|
1524
|
+
fail(`encode_anchored_export: transition ${i} payload`);
|
|
1525
|
+
if (requireStringOrUri(p.v.get("transition_id"), "transition_id", b) !== expected.transitionId)
|
|
1526
|
+
fail(`encode_anchored_export: transition ${i} transition_id`);
|
|
1527
|
+
if (requireStringOrUri(p.v.get("chain_id"), "chain_id", b) !== expected.chainId)
|
|
1528
|
+
fail(`encode_anchored_export: transition ${i} chain_id`);
|
|
1529
|
+
if (requireInt(p.v.get("effective_at"), "effective_at") !== expected.effectiveAt)
|
|
1530
|
+
fail(`encode_anchored_export: transition ${i} effective_at`);
|
|
1531
|
+
if (!bytesEqual(requireB64urlN(p.v.get("from_key_fingerprint"), "from_key_fingerprint", 32), expected.currentKeyFingerprint))
|
|
1532
|
+
fail(`encode_anchored_export: transition ${i} from_key_fingerprint`);
|
|
1533
|
+
if (!bytesEqual(requireB64urlN(p.v.get("to_key_fingerprint"), "to_key_fingerprint", 32), expected.nextKeyFingerprint))
|
|
1534
|
+
fail(`encode_anchored_export: transition ${i} to_key_fingerprint`);
|
|
1535
|
+
const toKeyIdTagged = p.v.get("to_key_id");
|
|
1536
|
+
if (toKeyIdTagged === undefined || toKeyIdTagged.t !== "string" || utf8Str(toKeyIdTagged.v) !== expected.nextKeyId)
|
|
1537
|
+
fail(`encode_anchored_export: transition ${i} to_key_id`);
|
|
1538
|
+
}
|
|
1539
|
+
// Encode-time input validation (mirrors anchored_export_codec.ex validate_expected_export +
|
|
1540
|
+
// validate_chunks): the transition count, chain range coherence, anchor bindings, and the chunk
|
|
1541
|
+
// list shape must hold before framing. The parser enforces all of this at verify time; the producer
|
|
1542
|
+
// must not mint bytes its own consumer would reject.
|
|
1543
|
+
function validateExportInputs(input, expected, b) {
|
|
1544
|
+
const chain = expected.chain;
|
|
1545
|
+
// Transition count bound (anchored_export_codec.ex:360 transition_count <= bounds.key_transitions).
|
|
1546
|
+
if (!Number.isInteger(input.transitions.length) || input.transitions.length > resolve(b, "key_transitions")) {
|
|
1547
|
+
fail("encode_anchored_export: transition_count bound");
|
|
1548
|
+
}
|
|
1549
|
+
if (expected.transitions.length !== input.transitions.length)
|
|
1550
|
+
fail("encode_anchored_export: transition count");
|
|
1551
|
+
// Anchor bindings (anchored_export_codec.ex:364-371): start spans first_sequence-1 with the
|
|
1552
|
+
// chain's previous_hash; end spans last_sequence with the chain's last_hash.
|
|
1553
|
+
if (expected.startAnchor.sequence !== chain.firstSequence - 1)
|
|
1554
|
+
fail("encode_anchored_export: start sequence");
|
|
1555
|
+
if (!bytesEqual(expected.startAnchor.chainHash, chain.previousHash))
|
|
1556
|
+
fail("encode_anchored_export: start chain_hash");
|
|
1557
|
+
if (expected.endAnchor.sequence !== chain.lastSequence)
|
|
1558
|
+
fail("encode_anchored_export: end sequence");
|
|
1559
|
+
if (!bytesEqual(expected.endAnchor.chainHash, chain.lastHash))
|
|
1560
|
+
fail("encode_anchored_export: end chain_hash");
|
|
1561
|
+
// All transitions + both anchors carry the chain_id (anchored_export_codec.ex:361-363).
|
|
1562
|
+
for (let i = 0; i < expected.transitions.length; i++) {
|
|
1563
|
+
if (expected.transitions[i].chainId !== chain.chainId)
|
|
1564
|
+
fail(`encode_anchored_export: transition ${i} chain_id`);
|
|
1565
|
+
}
|
|
1566
|
+
if (expected.startAnchor.chainId !== chain.chainId)
|
|
1567
|
+
fail("encode_anchored_export: start chain_id");
|
|
1568
|
+
if (expected.endAnchor.chainId !== chain.chainId)
|
|
1569
|
+
fail("encode_anchored_export: end chain_id");
|
|
1570
|
+
// Key-path invariants (validate_expected_key_path): the no-transition path requires start==end key
|
|
1571
|
+
// identity with a chronologically-non-decreasing end anchor; the transition path requires strictly
|
|
1572
|
+
// increasing effective_at with no fingerprint cycle. Mirrored by validateKeyPath.
|
|
1573
|
+
validateKeyPath(expected.startAnchor, expected.transitions, expected.endAnchor);
|
|
1574
|
+
}
|
|
1575
|
+
function buildArchiveHeader(input, chain, b) {
|
|
1576
|
+
if (chain.chainId !== input.chainId)
|
|
1577
|
+
fail("encode_anchored_export: chain_id");
|
|
1578
|
+
if (chain.firstSequence !== input.firstSequence)
|
|
1579
|
+
fail("encode_anchored_export: first_sequence");
|
|
1580
|
+
if (chain.lastSequence !== input.lastSequence)
|
|
1581
|
+
fail("encode_anchored_export: last_sequence");
|
|
1582
|
+
if (chain.rowCount !== input.rowCount)
|
|
1583
|
+
fail("encode_anchored_export: row_count");
|
|
1584
|
+
const members = new Map([
|
|
1585
|
+
["chain_id", { t: "string", v: strUtf8(chain.chainId) }],
|
|
1586
|
+
["first_sequence", { t: "int", v: chain.firstSequence }],
|
|
1587
|
+
["last_hash", { t: "string", v: strUtf8(utf8Str(base64urlEncode(chain.lastHash))) }],
|
|
1588
|
+
["last_sequence", { t: "int", v: chain.lastSequence }],
|
|
1589
|
+
["previous_hash", { t: "string", v: strUtf8(utf8Str(base64urlEncode(chain.previousHash))) }],
|
|
1590
|
+
["row_count", { t: "int", v: chain.rowCount }],
|
|
1591
|
+
["transition_count", { t: "int", v: input.transitions.length }],
|
|
1592
|
+
["v", { t: "int", v: VERSION }],
|
|
1593
|
+
]);
|
|
1594
|
+
const headerBytes = jcsEncode({ t: "object", v: members }, b);
|
|
1595
|
+
if (headerBytes.length > resolve(b, "archive_header_bytes"))
|
|
1596
|
+
fail("encode_anchored_export: header bytes");
|
|
1597
|
+
return headerBytes;
|
|
1598
|
+
}
|
|
1599
|
+
// UINT32_BE(len) || bytes — the archive framing (ADR 0004 § Anchored export).
|
|
1600
|
+
function frame(bytes) {
|
|
1601
|
+
if (bytes.length === 0)
|
|
1602
|
+
fail("archive: zero-length frame");
|
|
1603
|
+
const out = new Uint8Array(4 + bytes.length);
|
|
1604
|
+
const v = bytes.length;
|
|
1605
|
+
out[0] = (v >>> 24) & 0xff;
|
|
1606
|
+
out[1] = (v >>> 16) & 0xff;
|
|
1607
|
+
out[2] = (v >>> 8) & 0xff;
|
|
1608
|
+
out[3] = v & 0xff;
|
|
1609
|
+
out.set(bytes, 4);
|
|
1610
|
+
return out;
|
|
1611
|
+
}
|
|
1612
|
+
// 15. verify_historical_anchor (ADR 0004 § Boundary anchors; spec/bap-v2.md § Historical anchor).
|
|
1613
|
+
export function verifyHistoricalAnchor(compact, key, expected) {
|
|
1614
|
+
return trying(() => {
|
|
1615
|
+
closedShape([compact, key, expected], ["bytes", SHAPE_HIST_KEY, "object"]);
|
|
1616
|
+
assert(key.publicKey.length === 32, "verify_historical_anchor: key width");
|
|
1617
|
+
// BAP-09 #10/#11: thread expected.bounds (resolved once) through every bound-sensitive check, as
|
|
1618
|
+
// the reference does (boundary_anchor_codec.ex parses the compact + payload under bounds).
|
|
1619
|
+
const b = coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
1620
|
+
// Key-window endpoints magnitude-bounded under the resolved bounds
|
|
1621
|
+
// (context_validation.ex valid_time? — the Rust round-4 parity fix; without
|
|
1622
|
+
// this the SDKs diverge: an in-window anchored_at with a bounded
|
|
1623
|
+
// valid_before of 2^62 verified in TS and rejected in Rust/reference).
|
|
1624
|
+
const mag = resolve(b, "integer_magnitude");
|
|
1625
|
+
// Number.isInteger: fractional/NaN endpoints fail closed (cross-vendor — a
|
|
1626
|
+
// real signed-anchor probe with validFrom 0.5 verified without it).
|
|
1627
|
+
if (!Number.isInteger(key.validFrom) || Math.abs(key.validFrom) > mag)
|
|
1628
|
+
fail("verify_historical_anchor: valid_from magnitude");
|
|
1629
|
+
if (key.validBefore !== null && (!Number.isInteger(key.validBefore) || Math.abs(key.validBefore) > mag))
|
|
1630
|
+
fail("verify_historical_anchor: valid_before magnitude");
|
|
1631
|
+
if (key.validBefore !== null && key.validBefore <= key.validFrom)
|
|
1632
|
+
fail("verify_historical_anchor: valid_before ordering");
|
|
1633
|
+
if (compact.length > resolve(b, "anchor_bytes"))
|
|
1634
|
+
fail("verify_historical_anchor: anchor_bytes");
|
|
1635
|
+
const seg = parseCompact(compact, b);
|
|
1636
|
+
const { kid } = parseAnchorHeader(seg, b);
|
|
1637
|
+
if (kid !== key.keyId)
|
|
1638
|
+
fail("verify_historical_anchor: kid");
|
|
1639
|
+
if (expected.keyId !== key.keyId)
|
|
1640
|
+
fail("verify_historical_anchor: expected key id");
|
|
1641
|
+
const p = jsonDecode(seg.payloadBytes, b);
|
|
1642
|
+
validateAnchorPayload(p, seg.payloadBytes, b);
|
|
1643
|
+
if (p.t !== "object")
|
|
1644
|
+
fail("verify_historical_anchor: payload object");
|
|
1645
|
+
const anchorId = requireStringOrUri(p.v.get("anchor_id"), "anchor_id", b);
|
|
1646
|
+
if (anchorId !== expected.anchorId)
|
|
1647
|
+
fail("verify_historical_anchor: anchor_id");
|
|
1648
|
+
const anchoredAt = requireInt(p.v.get("anchored_at"), "anchored_at");
|
|
1649
|
+
if (anchoredAt !== expected.anchoredAt)
|
|
1650
|
+
fail("verify_historical_anchor: anchored_at");
|
|
1651
|
+
const chainId = requireStringOrUri(p.v.get("chain_id"), "chain_id", b);
|
|
1652
|
+
if (chainId !== expected.chainId)
|
|
1653
|
+
fail("verify_historical_anchor: chain_id");
|
|
1654
|
+
const sequence = requireInt(p.v.get("sequence"), "sequence");
|
|
1655
|
+
if (sequence !== expected.sequence)
|
|
1656
|
+
fail("verify_historical_anchor: sequence");
|
|
1657
|
+
const chainHash = requireB64urlN(p.v.get("chain_hash"), "chain_hash", 32);
|
|
1658
|
+
if (!bytesEqual(chainHash, expected.chainHash))
|
|
1659
|
+
fail("verify_historical_anchor: chain_hash");
|
|
1660
|
+
const keyFpRaw = requireB64urlN(p.v.get("key_fingerprint"), "key_fingerprint", 32);
|
|
1661
|
+
if (!bytesEqual(keyFpRaw, expected.keyFingerprint))
|
|
1662
|
+
fail("verify_historical_anchor: key_fingerprint");
|
|
1663
|
+
// Genesis: sequence 0 requires the all-zero chain hash.
|
|
1664
|
+
if (expected.sequence === 0 && !bytesEqual(expected.chainHash, DEFAULT_HASH))
|
|
1665
|
+
fail("verify_historical_anchor: genesis hash");
|
|
1666
|
+
// Derived fingerprint must equal expected.
|
|
1667
|
+
const derivedFp = thumbprintRaw(jwkFromPublicKey(key.publicKey));
|
|
1668
|
+
if (!bytesEqual(derivedFp, expected.keyFingerprint))
|
|
1669
|
+
fail("verify_historical_anchor: fingerprint");
|
|
1670
|
+
if (!inWindow(anchoredAt, key))
|
|
1671
|
+
fail("verify_historical_anchor: window");
|
|
1672
|
+
const pk = importPublicKey(key.publicKey, utf8Str(base64urlEncode(derivedFp)));
|
|
1673
|
+
if (!ed25519Verify(seg.signingInput, seg.signature, pk))
|
|
1674
|
+
fail("verify_historical_anchor: signature");
|
|
1675
|
+
return {
|
|
1676
|
+
version: 2, anchorId, anchoredAt, chainId, sequence, chainHash,
|
|
1677
|
+
keyFingerprint: keyFpRaw, verification: "signature_and_window",
|
|
1678
|
+
trust: "not_evaluated",
|
|
1679
|
+
};
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
// 16. verify_key_transition (ADR 0004 § Authenticated key transitions).
|
|
1683
|
+
export function verifyKeyTransition(compact, oldKey, newKey, expected) {
|
|
1684
|
+
return trying(() => {
|
|
1685
|
+
closedShape([compact, oldKey, newKey, expected], ["bytes", SHAPE_HIST_KEY, SHAPE_HIST_KEY, "object"]);
|
|
1686
|
+
assert(oldKey.publicKey.length === 32 && newKey.publicKey.length === 32, "verify_key_transition: key width");
|
|
1687
|
+
if (bytesEqual(oldKey.publicKey, newKey.publicKey))
|
|
1688
|
+
fail("verify_key_transition: distinct keys");
|
|
1689
|
+
// BAP-09 #10/#11: thread expected.bounds (resolved once) through every bound-sensitive check.
|
|
1690
|
+
const b = coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
1691
|
+
// Key-window endpoints magnitude-bounded (both keys — the Rust parity).
|
|
1692
|
+
const mag = resolve(b, "integer_magnitude");
|
|
1693
|
+
if (!Number.isInteger(oldKey.validFrom) || !Number.isInteger(newKey.validFrom) || Math.abs(oldKey.validFrom) > mag || Math.abs(newKey.validFrom) > mag)
|
|
1694
|
+
fail("verify_key_transition: valid_from magnitude");
|
|
1695
|
+
if ((oldKey.validBefore !== null && (!Number.isInteger(oldKey.validBefore) || Math.abs(oldKey.validBefore) > mag)) || (newKey.validBefore !== null && (!Number.isInteger(newKey.validBefore) || Math.abs(newKey.validBefore) > mag)))
|
|
1696
|
+
fail("verify_key_transition: valid_before magnitude");
|
|
1697
|
+
if ((oldKey.validBefore !== null && oldKey.validBefore <= oldKey.validFrom) || (newKey.validBefore !== null && newKey.validBefore <= newKey.validFrom))
|
|
1698
|
+
fail("verify_key_transition: valid_before ordering");
|
|
1699
|
+
if (compact.length > resolve(b, "anchor_bytes"))
|
|
1700
|
+
fail("verify_key_transition: anchor_bytes");
|
|
1701
|
+
const seg = parseCompact(compact, b);
|
|
1702
|
+
const { kid } = parseTransitionHeader(seg, b);
|
|
1703
|
+
if (kid !== oldKey.keyId)
|
|
1704
|
+
fail("verify_key_transition: kid");
|
|
1705
|
+
if (expected.currentKeyId !== oldKey.keyId)
|
|
1706
|
+
fail("verify_key_transition: current key id");
|
|
1707
|
+
if (expected.nextKeyId !== newKey.keyId)
|
|
1708
|
+
fail("verify_key_transition: next key id");
|
|
1709
|
+
const p = jsonDecode(seg.payloadBytes, b);
|
|
1710
|
+
validateTransitionPayload(p, seg.payloadBytes, b);
|
|
1711
|
+
if (p.t !== "object")
|
|
1712
|
+
fail("verify_key_transition: payload object");
|
|
1713
|
+
const transitionId = requireStringOrUri(p.v.get("transition_id"), "transition_id", b);
|
|
1714
|
+
if (transitionId !== expected.transitionId)
|
|
1715
|
+
fail("verify_key_transition: transition_id");
|
|
1716
|
+
const chainId = requireStringOrUri(p.v.get("chain_id"), "chain_id", b);
|
|
1717
|
+
if (chainId !== expected.chainId)
|
|
1718
|
+
fail("verify_key_transition: chain_id");
|
|
1719
|
+
const effectiveAt = requireInt(p.v.get("effective_at"), "effective_at");
|
|
1720
|
+
if (effectiveAt !== expected.effectiveAt)
|
|
1721
|
+
fail("verify_key_transition: effective_at");
|
|
1722
|
+
const fromFpRaw = requireB64urlN(p.v.get("from_key_fingerprint"), "from_key_fingerprint", 32);
|
|
1723
|
+
if (!bytesEqual(fromFpRaw, expected.currentKeyFingerprint))
|
|
1724
|
+
fail("verify_key_transition: from fp");
|
|
1725
|
+
const toFpRaw = requireB64urlN(p.v.get("to_key_fingerprint"), "to_key_fingerprint", 32);
|
|
1726
|
+
if (!bytesEqual(toFpRaw, expected.nextKeyFingerprint))
|
|
1727
|
+
fail("verify_key_transition: to fp");
|
|
1728
|
+
const toKeyIdV = p.v.get("to_key_id");
|
|
1729
|
+
if (toKeyIdV.t !== "string" || utf8Str(toKeyIdV.v) !== expected.nextKeyId)
|
|
1730
|
+
fail("verify_key_transition: to_key_id");
|
|
1731
|
+
// Derived fingerprints must equal expected.
|
|
1732
|
+
const derivedFrom = thumbprintRaw(jwkFromPublicKey(oldKey.publicKey));
|
|
1733
|
+
if (!bytesEqual(derivedFrom, expected.currentKeyFingerprint))
|
|
1734
|
+
fail("verify_key_transition: current fp");
|
|
1735
|
+
const derivedTo = thumbprintRaw(jwkFromPublicKey(newKey.publicKey));
|
|
1736
|
+
if (!bytesEqual(derivedTo, expected.nextKeyFingerprint))
|
|
1737
|
+
fail("verify_key_transition: next fp");
|
|
1738
|
+
if (!inWindow(effectiveAt, oldKey))
|
|
1739
|
+
fail("verify_key_transition: current window");
|
|
1740
|
+
if (!inWindow(effectiveAt, newKey))
|
|
1741
|
+
fail("verify_key_transition: next window");
|
|
1742
|
+
const pk = importPublicKey(oldKey.publicKey, utf8Str(base64urlEncode(derivedFrom)));
|
|
1743
|
+
if (!ed25519Verify(seg.signingInput, seg.signature, pk))
|
|
1744
|
+
fail("verify_key_transition: signature");
|
|
1745
|
+
return {
|
|
1746
|
+
version: 2, transitionId, chainId, effectiveAt,
|
|
1747
|
+
currentKeyFingerprint: fromFpRaw, nextKeyFingerprint: toFpRaw,
|
|
1748
|
+
verification: "authenticated_transition", trust: "not_evaluated",
|
|
1749
|
+
};
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
// 17. verify_anchored_export (ADR 0004 § Anchored export; REQ1-EXPORT-complete-scan).
|
|
1753
|
+
export function verifyAnchoredExport(archived, keyChain, expected) {
|
|
1754
|
+
return trying(() => {
|
|
1755
|
+
closedShape([archived, keyChain, expected], [{ fields: { chunks: { seq: "bytes" }, version: "str" } }, { fields: { keys: { seq: SHAPE_HIST_KEY } } }, SHAPE_EXPECTED_ANCHORED_EXPORT]);
|
|
1756
|
+
// The count ceiling FIRST (cross-vendor: even the static-bindings walk ran
|
|
1757
|
+
// unbounded caller input before it).
|
|
1758
|
+
const vb0 = coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
1759
|
+
if (expected.transitions.length > resolve(vb0, "key_transitions"))
|
|
1760
|
+
fail("verify_anchored_export: transition count bound");
|
|
1761
|
+
// Key-window validity BEFORE chunk processing/hashing (the reference validates
|
|
1762
|
+
// key shapes at :91 before validate_chunks).
|
|
1763
|
+
if (keyChain.keys.length !== expected.transitions.length + 1)
|
|
1764
|
+
fail("verify_anchored_export: key count bound");
|
|
1765
|
+
// Key ID/public-key shape before the digest (cross-vendor round 14).
|
|
1766
|
+
for (const k of keyChain.keys) {
|
|
1767
|
+
if (typeof k.keyId !== "string" || k.keyId.length === 0 || k.keyId.length > resolve(vb0, "kid_bytes") || new TextEncoder().encode(k.keyId).length > resolve(vb0, "kid_bytes"))
|
|
1768
|
+
fail("verify_anchored_export: key id shape");
|
|
1769
|
+
// the reference's ASCII-unreserved key_id class, pre-hash (round 15).
|
|
1770
|
+
if (!/^[A-Za-z0-9._~-]*$/.test(k.keyId))
|
|
1771
|
+
fail("verify_anchored_export: key id charset"); // ASCII-unreserved class
|
|
1772
|
+
if (!(k.publicKey instanceof Uint8Array) || k.publicKey.length !== 32)
|
|
1773
|
+
fail("verify_anchored_export: key width");
|
|
1774
|
+
}
|
|
1775
|
+
{
|
|
1776
|
+
const mag0 = resolve(vb0, "integer_magnitude");
|
|
1777
|
+
for (const k of keyChain.keys) {
|
|
1778
|
+
if (!Number.isInteger(k.validFrom) || Math.abs(k.validFrom) > mag0)
|
|
1779
|
+
fail("verify_anchored_export: key valid_from magnitude");
|
|
1780
|
+
if (k.validBefore !== null && (!Number.isInteger(k.validBefore) || Math.abs(k.validBefore) > mag0))
|
|
1781
|
+
fail("verify_anchored_export: key valid_before magnitude");
|
|
1782
|
+
if (k.validBefore !== null && k.validBefore <= k.validFrom)
|
|
1783
|
+
fail("verify_anchored_export: key valid_before ordering");
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
// ADR 0017 clause-3 hoist: expected-struct well-formedness BEFORE the digest
|
|
1787
|
+
// (2026-08-18, exception 2 closed). The reference validates chain + both anchors +
|
|
1788
|
+
// transitions (ContextValidation.expected_chain/expected_anchor/expected_transition via
|
|
1789
|
+
// validate_expected_export, anchored_export_codec.ex:88-98/:345-371) before key shapes,
|
|
1790
|
+
// chunks, and hash_chunks; the SDK previously ran these gates only post-digest
|
|
1791
|
+
// (verdict-invariant by subsumption — the WORK ordering is the contract; the Python
|
|
1792
|
+
// battery's sha256-call-count leg carries the behavioral proof, this ordering is
|
|
1793
|
+
// pinned structurally by the TS battery).
|
|
1794
|
+
const bpre = coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
1795
|
+
const mpre = resolve(bpre, "integer_magnitude");
|
|
1796
|
+
const preIdentifier = (v, label) => {
|
|
1797
|
+
if (typeof v !== "string" || v.length === 0 || new TextEncoder().encode(v).length > resolve(bpre, "identifier_bytes") || !isStringOrUri(v))
|
|
1798
|
+
fail(label);
|
|
1799
|
+
};
|
|
1800
|
+
preIdentifier(expected.chain.chainId, "verify_anchored_export: chain chain_id");
|
|
1801
|
+
if (expected.chain.firstSequence < 1 || expected.chain.firstSequence > mpre || expected.chain.lastSequence < 1 || expected.chain.lastSequence > mpre || expected.chain.firstSequence > expected.chain.lastSequence)
|
|
1802
|
+
fail("verify_anchored_export: chain range");
|
|
1803
|
+
if (expected.chain.rowCount < 1 || expected.chain.rowCount > resolve(bpre, "chain_rows") || expected.chain.rowCount !== expected.chain.lastSequence - expected.chain.firstSequence + 1)
|
|
1804
|
+
fail("verify_anchored_export: chain count");
|
|
1805
|
+
if (expected.chain.previousHash.length !== 32 || expected.chain.lastHash.length !== 32)
|
|
1806
|
+
fail("verify_anchored_export: chain hash width");
|
|
1807
|
+
if (expected.chain.firstSequence === 1 && !bytesEqual(expected.chain.previousHash, DEFAULT_HASH))
|
|
1808
|
+
fail("verify_anchored_export: chain genesis");
|
|
1809
|
+
for (const [anch, which] of [[expected.startAnchor, "start"], [expected.endAnchor, "end"]]) {
|
|
1810
|
+
preIdentifier(anch.anchorId, `verify_anchored_export: ${which} anchor_id`);
|
|
1811
|
+
preIdentifier(anch.chainId, `verify_anchored_export: ${which} chain_id`);
|
|
1812
|
+
if (!Number.isInteger(anch.anchoredAt) || Math.abs(anch.anchoredAt) > mpre)
|
|
1813
|
+
fail(`verify_anchored_export: ${which} anchored_at`);
|
|
1814
|
+
if (!Number.isInteger(anch.sequence) || anch.sequence < 0 || anch.sequence > mpre)
|
|
1815
|
+
fail(`verify_anchored_export: ${which} sequence`);
|
|
1816
|
+
if (typeof anch.keyId !== "string" || anch.keyId.length === 0 || new TextEncoder().encode(anch.keyId).length > resolve(bpre, "kid_bytes") || !/^[A-Za-z0-9._~-]+$/.test(anch.keyId))
|
|
1817
|
+
fail(`verify_anchored_export: ${which} key_id`);
|
|
1818
|
+
if (anch.chainHash.length !== 32 || anch.keyFingerprint.length !== 32)
|
|
1819
|
+
fail(`verify_anchored_export: ${which} hash width`);
|
|
1820
|
+
if (anch.sequence === 0 && !bytesEqual(anch.chainHash, DEFAULT_HASH))
|
|
1821
|
+
fail(`verify_anchored_export: ${which} genesis`);
|
|
1822
|
+
}
|
|
1823
|
+
if (expected.transitions.length > resolve(bpre, "key_transitions"))
|
|
1824
|
+
fail("verify_anchored_export: transition count bound");
|
|
1825
|
+
for (let i = 0; i < expected.transitions.length; i++) {
|
|
1826
|
+
const t = expected.transitions[i];
|
|
1827
|
+
preIdentifier(t.transitionId, `verify_anchored_export: transition ${i} id`);
|
|
1828
|
+
preIdentifier(t.chainId, `verify_anchored_export: transition ${i} chain_id`);
|
|
1829
|
+
if (!Number.isInteger(t.effectiveAt) || Math.abs(t.effectiveAt) > mpre)
|
|
1830
|
+
fail(`verify_anchored_export: transition ${i} effective_at`);
|
|
1831
|
+
for (const kid of [t.currentKeyId, t.nextKeyId]) {
|
|
1832
|
+
if (typeof kid !== "string" || kid.length === 0 || new TextEncoder().encode(kid).length > resolve(bpre, "kid_bytes") || !/^[A-Za-z0-9._~-]+$/.test(kid))
|
|
1833
|
+
fail(`verify_anchored_export: transition ${i} key_id`);
|
|
1834
|
+
}
|
|
1835
|
+
if (t.currentKeyFingerprint.length !== 32 || t.nextKeyFingerprint.length !== 32 || bytesEqual(t.currentKeyFingerprint, t.nextKeyFingerprint))
|
|
1836
|
+
fail(`verify_anchored_export: transition ${i} fingerprints`);
|
|
1837
|
+
}
|
|
1838
|
+
// Static expected-side bindings (reference validate_expected_anchored_export →
|
|
1839
|
+
// validate_expected_export, anchored_export_codec.ex:362-371, reached at verify :92/:387):
|
|
1840
|
+
// the caller's expected anchors + transitions belong to the expected chain (cross-vendor
|
|
1841
|
+
// round 2: none of the six were enforced at verify; the anchors bound only to their own
|
|
1842
|
+
// expected structs and the rows/header only to the chain).
|
|
1843
|
+
const vb = coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
1844
|
+
if (expected.startAnchor.chainId !== expected.chain.chainId)
|
|
1845
|
+
fail("verify_anchored_export: start chain_id");
|
|
1846
|
+
if (expected.endAnchor.chainId !== expected.chain.chainId)
|
|
1847
|
+
fail("verify_anchored_export: end chain_id");
|
|
1848
|
+
if (expected.startAnchor.sequence !== expected.chain.firstSequence - 1)
|
|
1849
|
+
fail("verify_anchored_export: start sequence");
|
|
1850
|
+
if (!bytesEqual(expected.startAnchor.chainHash, expected.chain.previousHash))
|
|
1851
|
+
fail("verify_anchored_export: start chain_hash");
|
|
1852
|
+
if (expected.endAnchor.sequence !== expected.chain.lastSequence)
|
|
1853
|
+
fail("verify_anchored_export: end sequence");
|
|
1854
|
+
if (!bytesEqual(expected.endAnchor.chainHash, expected.chain.lastHash))
|
|
1855
|
+
fail("verify_anchored_export: end chain_hash");
|
|
1856
|
+
for (let i = 0; i < expected.transitions.length; i++) {
|
|
1857
|
+
if (expected.transitions[i].chainId !== expected.chain.chainId)
|
|
1858
|
+
fail(`verify_anchored_export: transition ${i} chain_id`);
|
|
1859
|
+
}
|
|
1860
|
+
void vb;
|
|
1861
|
+
// BAP-09 #10/#11: the reference resolves Bounds.coerce(expected.bounds) once (anchored_export_codec.ex:84-185)
|
|
1862
|
+
// and threads it into validate_chunks (archive_chunks, archive_bytes), parse_archive (frame
|
|
1863
|
+
// reads), and every row check. The inner anchors/transitions carry their own bounds (used by
|
|
1864
|
+
// their own compact parsers). A caller tightening via expected.bounds now takes effect.
|
|
1865
|
+
const b = coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
1866
|
+
// Cross-vendor (nested bounds pinning): the reference (anchored_export_codec.ex:352-354, :404-406)
|
|
1867
|
+
// pins every nested bounds to the top-level resolved bounds via `{:ok, ^bounds} <- Bounds.coerce(x.bounds)`
|
|
1868
|
+
// — a nested value that differs (or defaults to maximum when the top is tightened) is REJECTED.
|
|
1869
|
+
// The SDK previously preferred the outer bounds and silently discarded the nested value; pin them
|
|
1870
|
+
// explicitly so a mismatch fails closed, matching the reference.
|
|
1871
|
+
requireBoundsEqual(expected.chain.bounds, b, "verify_anchored_export: chain bounds");
|
|
1872
|
+
requireBoundsEqual(expected.startAnchor.bounds, b, "verify_anchored_export: start anchor bounds");
|
|
1873
|
+
requireBoundsEqual(expected.endAnchor.bounds, b, "verify_anchored_export: end anchor bounds");
|
|
1874
|
+
for (let i = 0; i < expected.transitions.length; i++) {
|
|
1875
|
+
requireBoundsEqual(expected.transitions[i].bounds, b, `verify_anchored_export: transition ${i} bounds`);
|
|
1876
|
+
}
|
|
1877
|
+
// Validate the chunk list BEFORE concatenation (mirrors anchored_export_codec.ex:101-102,333-342
|
|
1878
|
+
// validate_chunks): each chunk nonempty, count < archive_chunks, total ≤ archive_bytes. Hashing
|
|
1879
|
+
// happens after the shape is validated.
|
|
1880
|
+
// Version shape (UTF-8 BYTES via TextEncoder) + equality BEFORE the digest
|
|
1881
|
+
// (cross-vendor round 12: malformed metadata should not force hashing).
|
|
1882
|
+
if (typeof archived.version !== "string" || archived.version.length === 0 || archived.version.length > resolve(b, "object_version_bytes") || !isWellFormed(archived.version) || typeof expected.objectVersion !== "string" || expected.objectVersion.length === 0 || expected.objectVersion.length > resolve(b, "object_version_bytes") || !isWellFormed(expected.objectVersion) || new TextEncoder().encode(archived.version).length > resolve(b, "object_version_bytes") || new TextEncoder().encode(expected.objectVersion).length > resolve(b, "object_version_bytes"))
|
|
1883
|
+
fail("verify_anchored_export: version shape");
|
|
1884
|
+
if (archived.version !== expected.objectVersion)
|
|
1885
|
+
fail("verify_anchored_export: object version");
|
|
1886
|
+
validateChunks(archived.chunks, b);
|
|
1887
|
+
// Stream the digest over the chunks WITHOUT materializing/spreading them (reference hash_chunks,
|
|
1888
|
+
// anchored_export_codec.ex:705-714 — incremental SHA-256 per chunk, no concat). sha256Concat feeds
|
|
1889
|
+
// the chunk list as an array, never spread, so a list of up to archive_chunks (65796) entries
|
|
1890
|
+
// cannot hit V8's ~65534 call-arg ceiling. An inauthentic/oversized archive is rejected at the
|
|
1891
|
+
// digest compare before the parse materializes the bytes (BAP-15 Rust precedent, v1.rs:30-40).
|
|
1892
|
+
const digest = sha256Concat(archived.chunks);
|
|
1893
|
+
if (!bytesEqual(digest, expected.digest))
|
|
1894
|
+
fail("verify_anchored_export: digest");
|
|
1895
|
+
// (the version SHAPE + equality gates ran before the digest — round 12.)
|
|
1896
|
+
// Materialize for parsing ONLY after the digest matches (caller-legitimate, bounded archive).
|
|
1897
|
+
// Loop-build (no spread) for the same V8 arg-ceiling reason.
|
|
1898
|
+
let total = 0;
|
|
1899
|
+
for (const c of archived.chunks)
|
|
1900
|
+
total += c.length;
|
|
1901
|
+
const archive = new Uint8Array(total);
|
|
1902
|
+
let off = 0;
|
|
1903
|
+
for (const c of archived.chunks) {
|
|
1904
|
+
archive.set(c, off);
|
|
1905
|
+
off += c.length;
|
|
1906
|
+
}
|
|
1907
|
+
if (archive.length <= ARCHIVE_PREFIX.length)
|
|
1908
|
+
fail("verify_anchored_export: archive too short");
|
|
1909
|
+
// Parse the archive frames.
|
|
1910
|
+
const parsed = parseArchive(archive, b);
|
|
1911
|
+
// Header canonical equality.
|
|
1912
|
+
const headerMembers = new Map([
|
|
1913
|
+
["chain_id", { t: "string", v: strUtf8(expected.chain.chainId) }],
|
|
1914
|
+
["first_sequence", { t: "int", v: expected.chain.firstSequence }],
|
|
1915
|
+
["last_hash", { t: "string", v: strUtf8(utf8Str(base64urlEncode(expected.chain.lastHash))) }],
|
|
1916
|
+
["last_sequence", { t: "int", v: expected.chain.lastSequence }],
|
|
1917
|
+
["previous_hash", { t: "string", v: strUtf8(utf8Str(base64urlEncode(expected.chain.previousHash))) }],
|
|
1918
|
+
["row_count", { t: "int", v: expected.chain.rowCount }],
|
|
1919
|
+
["transition_count", { t: "int", v: expected.transitions.length }],
|
|
1920
|
+
["v", { t: "int", v: VERSION }],
|
|
1921
|
+
]);
|
|
1922
|
+
const expectedHeaderBytes = jcsEncode({ t: "object", v: headerMembers }, b);
|
|
1923
|
+
if (!bytesEqual(parsed.headerBytes, expectedHeaderBytes))
|
|
1924
|
+
fail("verify_anchored_export: header");
|
|
1925
|
+
// Verify start + end anchors + each transition against the ordered historical key chain.
|
|
1926
|
+
// A key chain of N keys spans N-1 transitions (keys[0]→[1], ..., keys[N-2]→[N-1]); a 1-key,
|
|
1927
|
+
// 0-transition archive is the no-rollover case the reference accepts (validate_historical_key_shapes
|
|
1928
|
+
// requires keys == transitions+1, with no minimum). The exact-count check below is the gate.
|
|
1929
|
+
// Cross-vendor re-review F2: validate the key-chain length BEFORE dereferencing keys[0]/keys[N-1]
|
|
1930
|
+
// so a zero-key chain fails closed (Err) instead of raising TypeError on undefined.
|
|
1931
|
+
if (parsed.transitions.length !== expected.transitions.length)
|
|
1932
|
+
fail("verify_anchored_export: transition count");
|
|
1933
|
+
if (keyChain.keys.length !== parsed.transitions.length + 1)
|
|
1934
|
+
fail("verify_anchored_export: key chain length");
|
|
1935
|
+
verifyAnchorCompact(parsed.start, keyChain.keys[0], expected.startAnchor, "verify_anchored_export start", b);
|
|
1936
|
+
verifyAnchorCompact(parsed.end, keyChain.keys[keyChain.keys.length - 1], expected.endAnchor, "verify_anchored_export end", b);
|
|
1937
|
+
// Key-path invariants (anchored_export_codec.ex:506-572 validate_expected_key_path): the expected
|
|
1938
|
+
// transition list must form a strictly-increasing effective_at sequence with no fingerprint cycle,
|
|
1939
|
+
// and the end anchor must close the path with a chronologically-non-decreasing anchored_at.
|
|
1940
|
+
validateKeyPath(expected.startAnchor, expected.transitions, expected.endAnchor);
|
|
1941
|
+
for (let i = 0; i < parsed.transitions.length; i++) {
|
|
1942
|
+
verifyTransitionCompact(parsed.transitions[i], keyChain.keys[i], keyChain.keys[i + 1], expected.transitions[i], `verify_anchored_export transition ${i}`, b);
|
|
1943
|
+
}
|
|
1944
|
+
// Chronology over the ACTUAL anchored times (anchored_export_codec.ex:138-154 verify_transitions
|
|
1945
|
+
// + chronological_end?): each transition's effective_at must be strictly greater than the
|
|
1946
|
+
// previous anchor/transition time, and the end anchor's anchored_at must be >= the last
|
|
1947
|
+
// transition's effective_at (>= the start anchor's anchored_at for the no-transition case —
|
|
1948
|
+
// covered by validateKeyPath's chronological_end on the start anchor).
|
|
1949
|
+
let transitionTime = expected.startAnchor.anchoredAt;
|
|
1950
|
+
for (let i = 0; i < expected.transitions.length; i++) {
|
|
1951
|
+
const t = expected.transitions[i];
|
|
1952
|
+
if (!(t.effectiveAt > transitionTime))
|
|
1953
|
+
fail(`verify_anchored_export transition ${i}: chronology`);
|
|
1954
|
+
transitionTime = t.effectiveAt;
|
|
1955
|
+
}
|
|
1956
|
+
if (!(expected.endAnchor.anchoredAt >= transitionTime))
|
|
1957
|
+
fail("verify_anchored_export: end chronology");
|
|
1958
|
+
// Re-check every row (REQ1-EXPORT-complete-scan; mirrors check_chain).
|
|
1959
|
+
let previous = expected.chain.previousHash;
|
|
1960
|
+
let sequence = expected.chain.firstSequence;
|
|
1961
|
+
if (expected.chain.firstSequence === 1) {
|
|
1962
|
+
if (!bytesEqual(previous, DEFAULT_HASH))
|
|
1963
|
+
fail("verify_anchored_export: genesis predecessor");
|
|
1964
|
+
}
|
|
1965
|
+
if (parsed.rows.length !== expected.chain.rowCount)
|
|
1966
|
+
fail("verify_anchored_export: row count");
|
|
1967
|
+
for (let i = 0; i < parsed.rows.length; i++) {
|
|
1968
|
+
const rowBytes = parsed.rows[i];
|
|
1969
|
+
const row = jsonDecode(rowBytes, b);
|
|
1970
|
+
requireObjectExact(row, ["v", "chain_id", "sequence", "previous", "commitment"], `verify_anchored_export row ${i}`);
|
|
1971
|
+
const vV = row.v.get("v");
|
|
1972
|
+
if (vV.t !== "int" || vV.v !== VERSION)
|
|
1973
|
+
fail(`verify_anchored_export row ${i}: v`);
|
|
1974
|
+
const cidV = row.v.get("chain_id");
|
|
1975
|
+
if (cidV.t !== "string" || utf8Str(cidV.v) !== expected.chain.chainId)
|
|
1976
|
+
fail(`verify_anchored_export row ${i}: chain_id`);
|
|
1977
|
+
const seqV = row.v.get("sequence");
|
|
1978
|
+
if (seqV.t !== "int" || seqV.v !== sequence)
|
|
1979
|
+
fail(`verify_anchored_export row ${i}: sequence`);
|
|
1980
|
+
if (seqV.v < 1)
|
|
1981
|
+
fail(`verify_anchored_export row ${i}: sequence positive`);
|
|
1982
|
+
const prevRaw = requireB64urlN(row.v.get("previous"), "previous", 32);
|
|
1983
|
+
if (!bytesEqual(prevRaw, previous))
|
|
1984
|
+
fail(`verify_anchored_export row ${i}: previous link`);
|
|
1985
|
+
const commitmentRaw = requireB64urlN(row.v.get("commitment"), "commitment", 32);
|
|
1986
|
+
const reEncoded = canonicalRowBytes(expected.chain.chainId, seqV.v, prevRaw, commitmentRaw, b);
|
|
1987
|
+
if (!bytesEqual(reEncoded, rowBytes))
|
|
1988
|
+
fail(`verify_anchored_export row ${i}: canonical`);
|
|
1989
|
+
previous = sha256(ROW_PREFIX, rowBytes);
|
|
1990
|
+
sequence++;
|
|
1991
|
+
}
|
|
1992
|
+
if (!bytesEqual(previous, expected.chain.lastHash))
|
|
1993
|
+
fail("verify_anchored_export: head");
|
|
1994
|
+
return {
|
|
1995
|
+
version: 2, objectVersion: archived.version, chainId: expected.chain.chainId,
|
|
1996
|
+
firstSequence: expected.chain.firstSequence, lastSequence: expected.chain.lastSequence,
|
|
1997
|
+
rowCount: expected.chain.rowCount,
|
|
1998
|
+
// Cross-vendor (facts immutability): copy caller-owned Uint8Array fields into fresh buffers so a
|
|
1999
|
+
// later mutation of the input does not change the returned facts (the reference's Elixir binaries
|
|
2000
|
+
// are immutable; TS arrays are not). lastHash/digest are freshly computed. Same family as the
|
|
2001
|
+
// checkChain F3 fix.
|
|
2002
|
+
previousHash: new Uint8Array(expected.chain.previousHash),
|
|
2003
|
+
lastHash: previous, digest,
|
|
2004
|
+
startAnchorId: expected.startAnchor.anchorId, startAnchoredAt: expected.startAnchor.anchoredAt,
|
|
2005
|
+
startKeyFingerprint: new Uint8Array(expected.startAnchor.keyFingerprint),
|
|
2006
|
+
endAnchorId: expected.endAnchor.anchorId, endAnchoredAt: expected.endAnchor.anchoredAt,
|
|
2007
|
+
endKeyFingerprint: new Uint8Array(expected.endAnchor.keyFingerprint),
|
|
2008
|
+
transitionCount: expected.transitions.length,
|
|
2009
|
+
verification: "anchored_export", trust: "not_evaluated",
|
|
2010
|
+
authorization: "not_evaluated",
|
|
2011
|
+
};
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
// Anchor-compact verification (shared by verify_historical_anchor + the anchored-export path).
|
|
2015
|
+
function verifyAnchorCompact(compact, key, expected, ctx, bounds) {
|
|
2016
|
+
assert(key.publicKey.length === 32, `${ctx}: key width`);
|
|
2017
|
+
// BAP-09 #10/#11: thread expected.bounds (resolved once) through every bound-sensitive check. When
|
|
2018
|
+
// an enclosing export passes its own resolved bounds (cross-vendor re-review F1), prefer it over
|
|
2019
|
+
// the nested anchor's bounds so the top-level tightening takes effect.
|
|
2020
|
+
const b = bounds ?? coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
2021
|
+
// Key-window endpoints: integral + magnitude-bounded (the standalone path's
|
|
2022
|
+
// gates — cross-vendor round 7: the archive path skipped them).
|
|
2023
|
+
{
|
|
2024
|
+
const mag = resolve(b, "integer_magnitude");
|
|
2025
|
+
if (!Number.isInteger(key.validFrom) || Math.abs(key.validFrom) > mag)
|
|
2026
|
+
fail(`${ctx}: valid_from magnitude`);
|
|
2027
|
+
if (key.validBefore !== null && (!Number.isInteger(key.validBefore) || Math.abs(key.validBefore) > mag))
|
|
2028
|
+
fail(`${ctx}: valid_before magnitude`);
|
|
2029
|
+
if (key.validBefore !== null && key.validBefore <= key.validFrom)
|
|
2030
|
+
fail(`${ctx}: valid_before ordering`);
|
|
2031
|
+
}
|
|
2032
|
+
const seg = parseCompact(compact, b);
|
|
2033
|
+
const { kid } = parseAnchorHeader(seg, b);
|
|
2034
|
+
if (kid !== key.keyId)
|
|
2035
|
+
fail(`${ctx}: kid`);
|
|
2036
|
+
if (expected.keyId !== key.keyId)
|
|
2037
|
+
fail(`${ctx}: expected key id`);
|
|
2038
|
+
const p = jsonDecode(seg.payloadBytes, b);
|
|
2039
|
+
validateAnchorPayload(p, seg.payloadBytes, b);
|
|
2040
|
+
if (p.t !== "object")
|
|
2041
|
+
fail(`${ctx}: payload object`);
|
|
2042
|
+
const anchorId = requireStringOrUri(p.v.get("anchor_id"), "anchor_id", b);
|
|
2043
|
+
if (anchorId !== expected.anchorId)
|
|
2044
|
+
fail(`${ctx}: anchor_id`);
|
|
2045
|
+
const anchoredAt = requireInt(p.v.get("anchored_at"), "anchored_at");
|
|
2046
|
+
if (anchoredAt !== expected.anchoredAt)
|
|
2047
|
+
fail(`${ctx}: anchored_at`);
|
|
2048
|
+
const chainId = requireStringOrUri(p.v.get("chain_id"), "chain_id", b);
|
|
2049
|
+
if (chainId !== expected.chainId)
|
|
2050
|
+
fail(`${ctx}: chain_id`);
|
|
2051
|
+
const sequence = requireInt(p.v.get("sequence"), "sequence");
|
|
2052
|
+
if (sequence !== expected.sequence)
|
|
2053
|
+
fail(`${ctx}: sequence`);
|
|
2054
|
+
const chainHash = requireB64urlN(p.v.get("chain_hash"), "chain_hash", 32);
|
|
2055
|
+
if (!bytesEqual(chainHash, expected.chainHash))
|
|
2056
|
+
fail(`${ctx}: chain_hash`);
|
|
2057
|
+
const keyFpRaw = requireB64urlN(p.v.get("key_fingerprint"), "key_fingerprint", 32);
|
|
2058
|
+
if (!bytesEqual(keyFpRaw, expected.keyFingerprint))
|
|
2059
|
+
fail(`${ctx}: key_fingerprint`);
|
|
2060
|
+
if (expected.sequence === 0 && !bytesEqual(expected.chainHash, DEFAULT_HASH))
|
|
2061
|
+
fail(`${ctx}: genesis hash`);
|
|
2062
|
+
const derivedFp = thumbprintRaw(jwkFromPublicKey(key.publicKey));
|
|
2063
|
+
if (!bytesEqual(derivedFp, expected.keyFingerprint))
|
|
2064
|
+
fail(`${ctx}: fingerprint`);
|
|
2065
|
+
if (!inWindow(anchoredAt, key))
|
|
2066
|
+
fail(`${ctx}: window`);
|
|
2067
|
+
const pk = importPublicKey(key.publicKey, utf8Str(base64urlEncode(derivedFp)));
|
|
2068
|
+
if (!ed25519Verify(seg.signingInput, seg.signature, pk))
|
|
2069
|
+
fail(`${ctx}: signature`);
|
|
2070
|
+
}
|
|
2071
|
+
function verifyTransitionCompact(compact, currentKey, nextKey, expected, ctx, bounds) {
|
|
2072
|
+
assert(currentKey.publicKey.length === 32 && nextKey.publicKey.length === 32, `${ctx}: key width`);
|
|
2073
|
+
if (bytesEqual(currentKey.publicKey, nextKey.publicKey))
|
|
2074
|
+
fail(`${ctx}: distinct keys`);
|
|
2075
|
+
// BAP-09 #10/#11: thread expected.bounds (resolved once) through every bound-sensitive check. When
|
|
2076
|
+
// an enclosing export passes its own resolved bounds (cross-vendor re-review F1), prefer it over
|
|
2077
|
+
// the nested transition's bounds so the top-level tightening takes effect.
|
|
2078
|
+
const b = bounds ?? coerceBounds(expected.bounds ?? MAXIMUM_BOUNDS);
|
|
2079
|
+
// Key-window endpoints: integral + magnitude-bounded for BOTH keys (the
|
|
2080
|
+
// standalone path's gates — cross-vendor round 7).
|
|
2081
|
+
{
|
|
2082
|
+
const mag = resolve(b, "integer_magnitude");
|
|
2083
|
+
if (!Number.isInteger(currentKey.validFrom) || !Number.isInteger(nextKey.validFrom) || Math.abs(currentKey.validFrom) > mag || Math.abs(nextKey.validFrom) > mag)
|
|
2084
|
+
fail(`${ctx}: valid_from magnitude`);
|
|
2085
|
+
if ((currentKey.validBefore !== null && (!Number.isInteger(currentKey.validBefore) || Math.abs(currentKey.validBefore) > mag)) || (nextKey.validBefore !== null && (!Number.isInteger(nextKey.validBefore) || Math.abs(nextKey.validBefore) > mag)))
|
|
2086
|
+
fail(`${ctx}: valid_before magnitude`);
|
|
2087
|
+
if ((currentKey.validBefore !== null && currentKey.validBefore <= currentKey.validFrom) || (nextKey.validBefore !== null && nextKey.validBefore <= nextKey.validFrom))
|
|
2088
|
+
fail(`${ctx}: valid_before ordering`);
|
|
2089
|
+
}
|
|
2090
|
+
const seg = parseCompact(compact, b);
|
|
2091
|
+
const { kid } = parseTransitionHeader(seg, b);
|
|
2092
|
+
if (kid !== currentKey.keyId)
|
|
2093
|
+
fail(`${ctx}: kid`);
|
|
2094
|
+
if (expected.currentKeyId !== currentKey.keyId)
|
|
2095
|
+
fail(`${ctx}: current key id`);
|
|
2096
|
+
if (expected.nextKeyId !== nextKey.keyId)
|
|
2097
|
+
fail(`${ctx}: next key id`);
|
|
2098
|
+
const p = jsonDecode(seg.payloadBytes, b);
|
|
2099
|
+
validateTransitionPayload(p, seg.payloadBytes, b);
|
|
2100
|
+
if (p.t !== "object")
|
|
2101
|
+
fail(`${ctx}: payload object`);
|
|
2102
|
+
const transitionId = requireStringOrUri(p.v.get("transition_id"), "transition_id", b);
|
|
2103
|
+
if (transitionId !== expected.transitionId)
|
|
2104
|
+
fail(`${ctx}: transition_id`);
|
|
2105
|
+
const chainId = requireStringOrUri(p.v.get("chain_id"), "chain_id", b);
|
|
2106
|
+
if (chainId !== expected.chainId)
|
|
2107
|
+
fail(`${ctx}: chain_id`);
|
|
2108
|
+
const effectiveAt = requireInt(p.v.get("effective_at"), "effective_at");
|
|
2109
|
+
if (effectiveAt !== expected.effectiveAt)
|
|
2110
|
+
fail(`${ctx}: effective_at`);
|
|
2111
|
+
const fromFpRaw = requireB64urlN(p.v.get("from_key_fingerprint"), "from_key_fingerprint", 32);
|
|
2112
|
+
if (!bytesEqual(fromFpRaw, expected.currentKeyFingerprint))
|
|
2113
|
+
fail(`${ctx}: from fp`);
|
|
2114
|
+
const toFpRaw = requireB64urlN(p.v.get("to_key_fingerprint"), "to_key_fingerprint", 32);
|
|
2115
|
+
if (!bytesEqual(toFpRaw, expected.nextKeyFingerprint))
|
|
2116
|
+
fail(`${ctx}: to fp`);
|
|
2117
|
+
const toKeyIdV = p.v.get("to_key_id");
|
|
2118
|
+
if (toKeyIdV.t !== "string" || utf8Str(toKeyIdV.v) !== expected.nextKeyId)
|
|
2119
|
+
fail(`${ctx}: to_key_id`);
|
|
2120
|
+
const derivedFrom = thumbprintRaw(jwkFromPublicKey(currentKey.publicKey));
|
|
2121
|
+
if (!bytesEqual(derivedFrom, expected.currentKeyFingerprint))
|
|
2122
|
+
fail(`${ctx}: current fp`);
|
|
2123
|
+
const derivedTo = thumbprintRaw(jwkFromPublicKey(nextKey.publicKey));
|
|
2124
|
+
if (!bytesEqual(derivedTo, expected.nextKeyFingerprint))
|
|
2125
|
+
fail(`${ctx}: next fp`);
|
|
2126
|
+
if (!inWindow(effectiveAt, currentKey))
|
|
2127
|
+
fail(`${ctx}: current window`);
|
|
2128
|
+
if (!inWindow(effectiveAt, nextKey))
|
|
2129
|
+
fail(`${ctx}: next window`);
|
|
2130
|
+
const pk = importPublicKey(currentKey.publicKey, utf8Str(base64urlEncode(derivedFrom)));
|
|
2131
|
+
if (!ed25519Verify(seg.signingInput, seg.signature, pk))
|
|
2132
|
+
fail(`${ctx}: signature`);
|
|
2133
|
+
}
|
|
2134
|
+
function parseArchive(bytes, bounds) {
|
|
2135
|
+
if (!bytesEqual(bytes.subarray(0, ARCHIVE_PREFIX.length), ARCHIVE_PREFIX))
|
|
2136
|
+
fail("archive: prefix");
|
|
2137
|
+
let cursor = ARCHIVE_PREFIX.length;
|
|
2138
|
+
const headerFrame = readFrame(bytes, cursor, resolve(bounds, "archive_header_bytes"), "archive header");
|
|
2139
|
+
cursor = headerFrame.next;
|
|
2140
|
+
// The header itself is canonical JSON; transition_count + row_count drive the frame iteration.
|
|
2141
|
+
const header = jsonDecode(headerFrame.bytes, bounds);
|
|
2142
|
+
requireObjectExact(header, ["v", "chain_id", "first_sequence", "last_sequence", "row_count", "transition_count", "previous_hash", "last_hash"], "archive header");
|
|
2143
|
+
const vV = header.v.get("v");
|
|
2144
|
+
if (vV.t !== "int" || vV.v !== VERSION)
|
|
2145
|
+
fail("archive: header v");
|
|
2146
|
+
const tcV = header.v.get("transition_count");
|
|
2147
|
+
if (tcV.t !== "int" || tcV.v < 0 || tcV.v > resolve(bounds, "key_transitions"))
|
|
2148
|
+
fail("archive: transition_count");
|
|
2149
|
+
const rcV = header.v.get("row_count");
|
|
2150
|
+
if (rcV.t !== "int" || rcV.v < 1 || rcV.v > resolve(bounds, "chain_rows"))
|
|
2151
|
+
fail("archive: row_count");
|
|
2152
|
+
// Sequence-range coherence (mirrors the runner).
|
|
2153
|
+
const fsV = header.v.get("first_sequence");
|
|
2154
|
+
const lsV = header.v.get("last_sequence");
|
|
2155
|
+
if (fsV.t !== "int" || lsV.t !== "int")
|
|
2156
|
+
fail("archive: header sequences");
|
|
2157
|
+
if (!(fsV.v > 0 && lsV.v >= fsV.v && rcV.v === lsV.v - fsV.v + 1))
|
|
2158
|
+
fail("archive: row range");
|
|
2159
|
+
requireB64urlN(header.v.get("previous_hash"), "previous_hash", 32);
|
|
2160
|
+
requireB64urlN(header.v.get("last_hash"), "last_hash", 32);
|
|
2161
|
+
const startFrame = readFrame(bytes, cursor, resolve(bounds, "anchor_bytes"), "start anchor");
|
|
2162
|
+
cursor = startFrame.next;
|
|
2163
|
+
const transitions = [];
|
|
2164
|
+
for (let i = 0; i < tcV.v; i++) {
|
|
2165
|
+
const f = readFrame(bytes, cursor, resolve(bounds, "anchor_bytes"), `transition ${i}`);
|
|
2166
|
+
transitions.push(f.bytes);
|
|
2167
|
+
cursor = f.next;
|
|
2168
|
+
}
|
|
2169
|
+
const rows = [];
|
|
2170
|
+
for (let i = 0; i < rcV.v; i++) {
|
|
2171
|
+
const f = readFrame(bytes, cursor, resolve(bounds, "chain_row_bytes"), `row ${i}`);
|
|
2172
|
+
rows.push(f.bytes);
|
|
2173
|
+
cursor = f.next;
|
|
2174
|
+
}
|
|
2175
|
+
const endFrame = readFrame(bytes, cursor, resolve(bounds, "anchor_bytes"), "end anchor");
|
|
2176
|
+
cursor = endFrame.next;
|
|
2177
|
+
if (cursor !== bytes.length)
|
|
2178
|
+
fail("archive: exact EOF");
|
|
2179
|
+
return { headerBytes: headerFrame.bytes, start: startFrame.bytes, transitions, rows, end: endFrame.bytes };
|
|
2180
|
+
}
|
|
2181
|
+
function readFrame(bytes, cursor, maximum, ctx) {
|
|
2182
|
+
if (cursor + 4 > bytes.length)
|
|
2183
|
+
fail(`${ctx}: frame length`);
|
|
2184
|
+
const length = (bytes[cursor] << 24 | bytes[cursor + 1] << 16 | bytes[cursor + 2] << 8 | bytes[cursor + 3]) >>> 0;
|
|
2185
|
+
if (length === 0 || length > maximum)
|
|
2186
|
+
fail(`${ctx}: frame bound`);
|
|
2187
|
+
const start = cursor + 4;
|
|
2188
|
+
const end = start + length;
|
|
2189
|
+
if (end > bytes.length)
|
|
2190
|
+
fail(`${ctx}: complete frame`);
|
|
2191
|
+
return { bytes: bytes.subarray(start, end), next: end };
|
|
2192
|
+
}
|
|
2193
|
+
// Validate the expected key-transition path (anchored_export_codec.ex:506-572 validate_expected_key_path).
|
|
2194
|
+
// The no-transition path requires the start and end anchors to share key id + fingerprint with a
|
|
2195
|
+
// chronologically-non-decreasing end anchored_at. The transition path requires: each transition's
|
|
2196
|
+
// current key matches the running key; effective_at is strictly increasing; no next fingerprint has
|
|
2197
|
+
// appeared before (cycle rejection); the end anchor closes on the last transition's next key with a
|
|
2198
|
+
// chronologically-non-decreasing anchored_at.
|
|
2199
|
+
function validateKeyPath(start, transitions, end) {
|
|
2200
|
+
if (transitions.length === 0) {
|
|
2201
|
+
if (start.keyId !== end.keyId || !bytesEqual(start.keyFingerprint, end.keyFingerprint))
|
|
2202
|
+
fail("key path: start==end key");
|
|
2203
|
+
if (!(end.anchoredAt >= start.anchoredAt))
|
|
2204
|
+
fail("key path: end chronological");
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
let currentKeyId = start.keyId;
|
|
2208
|
+
let currentFp = start.keyFingerprint;
|
|
2209
|
+
let previousTime = start.anchoredAt;
|
|
2210
|
+
// seen is seeded with the start anchor's fingerprint (anchored_export_codec.ex:523).
|
|
2211
|
+
const seen = [start.keyFingerprint];
|
|
2212
|
+
for (let i = 0; i < transitions.length; i++) {
|
|
2213
|
+
const t = transitions[i];
|
|
2214
|
+
if (t.currentKeyId !== currentKeyId || !bytesEqual(t.currentKeyFingerprint, currentFp))
|
|
2215
|
+
fail(`key path: transition ${i} current key`);
|
|
2216
|
+
// strictly_after?(effective_at, previous_time) — strictly increasing (anchored_export_codec.ex:559,722).
|
|
2217
|
+
if (!(t.effectiveAt > previousTime))
|
|
2218
|
+
fail(`key path: transition ${i} chronology`);
|
|
2219
|
+
// No cycle: next_key_fingerprint must not be in seen (anchored_export_codec.ex:560,716-720).
|
|
2220
|
+
for (const s of seen) {
|
|
2221
|
+
if (bytesEqual(t.nextKeyFingerprint, s))
|
|
2222
|
+
fail(`key path: transition ${i} cycle`);
|
|
2223
|
+
}
|
|
2224
|
+
currentKeyId = t.nextKeyId;
|
|
2225
|
+
currentFp = t.nextKeyFingerprint;
|
|
2226
|
+
previousTime = t.effectiveAt;
|
|
2227
|
+
seen.push(t.nextKeyFingerprint);
|
|
2228
|
+
}
|
|
2229
|
+
// The end anchor must close on the last transition's next key (anchored_export_codec.ex:535-536).
|
|
2230
|
+
if (end.keyId !== currentKeyId || !bytesEqual(end.keyFingerprint, currentFp))
|
|
2231
|
+
fail("key path: end key");
|
|
2232
|
+
// chronological_end?(end.anchored_at, previous_time) — >= the last transition's effective_at.
|
|
2233
|
+
if (!(end.anchoredAt >= previousTime))
|
|
2234
|
+
fail("key path: end chronological");
|
|
2235
|
+
}
|
|
2236
|
+
// Validate the chunk list BEFORE concatenation (anchored_export_codec.ex:333-342 validate_chunks):
|
|
2237
|
+
// at least one chunk, each chunk nonempty, count < archive_chunks, running total ≤ archive_bytes.
|
|
2238
|
+
function validateChunks(chunks, bounds) {
|
|
2239
|
+
// Chunk elements must be Uint8Array (cross-vendor round 17 family symmetry:
|
|
2240
|
+
// Python gates bytes-like; a runtime str chunk hashed as UTF-8 and coerced
|
|
2241
|
+
// to zero bytes at materialization — fails closed downstream, but reject it
|
|
2242
|
+
// at the shape gate).
|
|
2243
|
+
for (const c of chunks) {
|
|
2244
|
+
if (!(c instanceof Uint8Array))
|
|
2245
|
+
fail("verify_anchored_export: chunk type");
|
|
2246
|
+
}
|
|
2247
|
+
if (chunks.length === 0)
|
|
2248
|
+
fail("archive: no chunks");
|
|
2249
|
+
// Cross-vendor re-review Finding 2: the reference's validate_chunks guard is `count < archive_chunks`
|
|
2250
|
+
// on the recursive clause (start 0), accepting up to archive_chunks INCLUSIVE. Use `>` not `>=`.
|
|
2251
|
+
if (chunks.length > resolve(bounds, "archive_chunks"))
|
|
2252
|
+
fail("archive: chunk count");
|
|
2253
|
+
let total = 0;
|
|
2254
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
2255
|
+
const c = chunks[i];
|
|
2256
|
+
if (c.length === 0)
|
|
2257
|
+
fail(`archive: empty chunk ${i}`);
|
|
2258
|
+
total += c.length;
|
|
2259
|
+
if (total > resolve(bounds, "archive_bytes"))
|
|
2260
|
+
fail("archive: chunk bytes");
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
// Cross-vendor (nested bounds pinning): the reference pins nested expected.bounds to the top-level
|
|
2264
|
+
// resolved bounds (anchored_export_codec.ex:352-354 `{:ok, ^bounds} <- Bounds.coerce(x.bounds)`).
|
|
2265
|
+
// When a nested bounds is present, it must coerce cleanly AND resolve to the same value as `top`
|
|
2266
|
+
// for every limit — otherwise a caller could tighten the top level while a nested struct widens or
|
|
2267
|
+
// drifts silently. Absent nested bounds is the documented default-to-maximum case (the caller omits
|
|
2268
|
+
// the field), which the reference also pins: Bounds.coerce(%{}) == maximum, so pin to top only when
|
|
2269
|
+
// top != maximum (a tightened top rejects the default-maximum nested). When top IS maximum, an absent
|
|
2270
|
+
// nested bounds is accepted (both resolve to maximum).
|
|
2271
|
+
function requireBoundsEqual(nested, top, ctx) {
|
|
2272
|
+
if (nested === undefined) {
|
|
2273
|
+
// Absent nested bounds: valid only if the top coerces EQUAL to maximum (no tightening).
|
|
2274
|
+
// Identity overrides (an explicit override whose value equals the maximum — boundsNew
|
|
2275
|
+
// documents them as "identity no-op") merge to the full maximum struct in the reference
|
|
2276
|
+
// (bounds.ex merge), so the pin's struct equality accepts: gate on EFFECTIVE tightening,
|
|
2277
|
+
// not override-map size (cross-vendor finding: map-size gating wrong-rejected identity).
|
|
2278
|
+
for (const [mk, v] of top.overrides) {
|
|
2279
|
+
if (v !== MAXIMA[mk])
|
|
2280
|
+
fail(`${ctx}: nested bounds absent under tightened top`);
|
|
2281
|
+
}
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
const coerced = coerceBounds(nested);
|
|
2285
|
+
for (const key of Object.keys(MAXIMA)) {
|
|
2286
|
+
if (resolve(coerced, key) !== resolve(top, key))
|
|
2287
|
+
fail(`${ctx}: nested bounds mismatch`);
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
// Re-export the major-neutral primitives the public index exposes (spec/bap-v2.md L299-309).
|
|
2291
|
+
// parseSelector/selectorMatches/Selector and the three domain separators are declared here
|
|
2292
|
+
// (v2-owned); the rest are the shared modules, imported exactly as the v1 façade imports them.
|
|
2293
|
+
export { jwkEncodePublic, jwkDecodePublic, thumbprint, sha256, base64urlDecode, base64urlEncode };
|
|
2294
|
+
export { boundsNew, boundsMaximum, MAXIMUM_BOUNDS, MAXIMA };
|
|
2295
|
+
export { uriNormalize, typedProject };
|
|
2296
|
+
void strUtf8;
|
|
2297
|
+
void _resetCensus;
|
|
2298
|
+
void resolve;
|
|
2299
|
+
void boundsNew;
|
|
2300
|
+
//# sourceMappingURL=v2.js.map
|