@agenticstechnologies/verify 0.0.0-stage → 1.0.1

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/src/merkle.mjs ADDED
@@ -0,0 +1,150 @@
1
+ // Sorted-pair Merkle, same fold as netlify/edge-functions/lib/ledger.js
2
+ // (imported by lib/gateway/proof.ts for buildBatch / foldLeafAtIndex):
3
+ // hashPair(a, b) = sha256Hex(a <= b ? a + b : b + a)
4
+ // odd node duplicated; empty leaf list → null (tree rule v1; v2 is at the end of this file)
5
+ // A proof is only the sibling list. Pair order does not depend on index.
6
+
7
+ import { sha256Hex } from "./evidence.mjs";
8
+
9
+ async function hashPair(a, b) {
10
+ return sha256Hex(a <= b ? a + b : b + a);
11
+ }
12
+
13
+ export async function merkleRoot(leaves) {
14
+ if (!leaves || !leaves.length) return null;
15
+ let level = leaves.slice();
16
+ while (level.length > 1) {
17
+ const next = [];
18
+ for (let i = 0; i < level.length; i += 2) {
19
+ next.push(await hashPair(level[i], level[i + 1] ?? level[i]));
20
+ }
21
+ level = next;
22
+ }
23
+ return level[0];
24
+ }
25
+
26
+ export async function merkleProof(leaves, index) {
27
+ if (index < 0 || index >= leaves.length) return null;
28
+ const proof = [];
29
+ let level = leaves.slice();
30
+ let idx = index;
31
+ while (level.length > 1) {
32
+ const next = [];
33
+ for (let i = 0; i < level.length; i += 2) {
34
+ const left = level[i];
35
+ const right = level[i + 1] ?? level[i];
36
+ if (i === idx) proof.push(right);
37
+ else if (i + 1 === idx) proof.push(left);
38
+ next.push(await hashPair(left, right));
39
+ }
40
+ level = next;
41
+ idx = Math.floor(idx / 2);
42
+ }
43
+ return proof;
44
+ }
45
+
46
+ export async function verifyProof(leafHash, proof, root) {
47
+ let h = leafHash;
48
+ for (const sibling of proof || []) h = await hashPair(h, sibling);
49
+ return h === root;
50
+ }
51
+
52
+ // ── Tree rule v2 (L3-16): RFC 9162 section 2.1 (RFC 6962), over the 32 bytes of each hex leaf. ──
53
+ // leaf = SHA-256(0x00 || leaf bytes), node = SHA-256(0x01 || left || right), in position order.
54
+ // An odd last node is promoted unchanged (never paired with itself); empty → null; one leaf → its leaf hash.
55
+ // A v2 proof is the RFC audit path; folding it needs the leaf index and the tree size. Batches anchored under v1
56
+ // stay v1 for ever (anchor_batches.tree_rule): a verifier picks the fold by rule and never re-folds an old batch.
57
+ export const TREE_RULE_V1 = "v1";
58
+ export const TREE_RULE_V2 = "v2";
59
+
60
+ function hexBytes(hex) {
61
+ if (typeof hex !== "string" || !/^[0-9a-f]{64}$/.test(hex)) throw new Error("merkle v2: a node must be 64 lowercase hex characters");
62
+ const out = new Uint8Array(32);
63
+ for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
64
+ return out;
65
+ }
66
+
67
+ async function prefixedHash(prefix, ...parts) {
68
+ const buf = new Uint8Array(1 + parts.length * 32);
69
+ buf[0] = prefix;
70
+ parts.forEach((p, i) => buf.set(p, 1 + i * 32));
71
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", buf));
72
+ }
73
+
74
+ const bytesHex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
75
+
76
+ /** v2 leaf hash of a 64-hex leaf: SHA-256(0x00 || bytes), as hex. */
77
+ export async function leafHashV2(leaf) {
78
+ return bytesHex(await prefixedHash(0x00, hexBytes(leaf)));
79
+ }
80
+
81
+ async function levelsV2(leaves) {
82
+ let level = [];
83
+ for (const l of leaves) level.push(await prefixedHash(0x00, hexBytes(l)));
84
+ const levels = [level];
85
+ while (level.length > 1) {
86
+ const next = [];
87
+ for (let i = 0; i + 1 < level.length; i += 2) next.push(await prefixedHash(0x01, level[i], level[i + 1]));
88
+ if (level.length % 2 === 1) next.push(level[level.length - 1]);
89
+ level = next;
90
+ levels.push(level);
91
+ }
92
+ return levels;
93
+ }
94
+
95
+ export async function merkleRootV2(leaves) {
96
+ if (!leaves || !leaves.length) return null;
97
+ const levels = await levelsV2(leaves);
98
+ return bytesHex(levels[levels.length - 1][0]);
99
+ }
100
+
101
+ /** The RFC audit path for the leaf at `index`, leaf to root, as hex. */
102
+ export async function merkleProofV2(leaves, index) {
103
+ if (!leaves || !Number.isInteger(index) || index < 0 || index >= leaves.length) return null;
104
+ const levels = await levelsV2(leaves);
105
+ const path = [];
106
+ let idx = index;
107
+ for (const level of levels.slice(0, -1)) {
108
+ const sib = idx ^ 1;
109
+ if (sib < level.length) path.push(bytesHex(level[sib]));
110
+ idx >>= 1;
111
+ }
112
+ return path;
113
+ }
114
+
115
+ /** RFC 9162 2.1.3.2: fold `proof` from leaf `index` of a tree of `size` leaves and compare with `root`. */
116
+ export async function verifyProofV2(leaf, index, size, proof, root) {
117
+ try {
118
+ if (!Number.isInteger(index) || !Number.isInteger(size) || index < 0 || index >= size) return false;
119
+ let fn = index;
120
+ let sn = size - 1;
121
+ let r = await prefixedHash(0x00, hexBytes(leaf));
122
+ for (const hex of proof || []) {
123
+ const p = hexBytes(hex);
124
+ if (sn === 0) return false;
125
+ if ((fn & 1) === 1 || fn === sn) {
126
+ r = await prefixedHash(0x01, p, r);
127
+ if ((fn & 1) === 0) {
128
+ while ((fn & 1) === 0 && fn !== 0) {
129
+ fn >>= 1;
130
+ sn >>= 1;
131
+ }
132
+ }
133
+ } else {
134
+ r = await prefixedHash(0x01, r, p);
135
+ }
136
+ fn >>= 1;
137
+ sn >>= 1;
138
+ }
139
+ return sn === 0 && bytesHex(r) === root;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /** The fold a batch's rule names. Unknown rules are refused, never folded as v1. */
146
+ export function treeRuleOf(value) {
147
+ if (value === undefined || value === null || value === "" || value === TREE_RULE_V1) return TREE_RULE_V1;
148
+ if (value === TREE_RULE_V2) return TREE_RULE_V2;
149
+ return null;
150
+ }
package/src/record.mjs ADDED
@@ -0,0 +1,440 @@
1
+ // Offline check for a signed agent record (GET /api/record/:handle/export).
2
+ //
3
+ // The 0–100 formula adapts lib/bureau/scoring.ts and matches lib/record/score.ts.
4
+ // Good = paid on time, an approval, or a recorded outcome.
5
+ // Adverse = claim paid, or a denial. A filed claim that was not paid is in
6
+ // the total only. Windows are 7-day buckets of the events themselves, newest
7
+ // first, weighted 1, 1/2, 1/4, … Stability is 1/(1+variance) of the per-window
8
+ // good rate, and it applies only when two or more windows exist.
9
+ //
10
+ // base = reliability * (1 - incident rate)
11
+ // score = round(100 * base) when one window
12
+ // score = round(100 * base * (0.5 + 0.5 * stability)) when two or more
13
+ //
14
+ // score_version 2. A self-reported event weighs SELF_REPORTED_WEIGHT. An agent
15
+ // with only self-reported scoring events cannot score above SELF_REPORTED_CAP.
16
+ // Missing provenance weighs 1, so a version 1 set of settlements and claims
17
+ // still produces the same number. Exports that omit score_version are read
18
+ // with agentRecordV1. Written form: docs/record/score.md.
19
+ //
20
+ // No scoring event → score is null, label "New, no record yet".
21
+ // The signature is EdDSA with the Bond key published at /.well-known/jwks.json.
22
+ // This file does not fetch that URL and does not trust a key embedded in the file.
23
+
24
+ import { canonicalize } from "./evidence.mjs";
25
+
26
+ export const NEW_RECORD_LABEL = "New, no record yet";
27
+ export const SCORE_VERSION = 2;
28
+ export const SELF_REPORTED_WEIGHT = 0.25;
29
+ export const SELF_REPORTED_CAP = 40;
30
+ export const RECORD_TYP = "agentics.record.v1";
31
+ export const RECORD_ISS = "https://agentics.you";
32
+ export const RECORD_JWT_TYP = "agentics-record+jwt";
33
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
34
+ const DAY_MS = 24 * 60 * 60 * 1000;
35
+ const HEX64 = /^[0-9a-f]{64}$/;
36
+ const HANDLE = /^[a-z0-9][a-z0-9-]{0,29}$/;
37
+ const V1_KINDS = new Set(["paid_on_time", "claim_filed", "claim_paid"]);
38
+ const KINDS = new Set([
39
+ "paid_on_time",
40
+ "claim_filed",
41
+ "claim_paid",
42
+ "denial",
43
+ "approval",
44
+ "outcome",
45
+ "outcome_adverse",
46
+ ]);
47
+ const PROVENANCE = new Set(["verified_by_agentics", "self_reported", "imported"]);
48
+
49
+ function clamp01(x) {
50
+ if (!Number.isFinite(x)) return 0;
51
+ if (x < 0) return 0;
52
+ if (x > 1) return 1;
53
+ return x;
54
+ }
55
+
56
+ function recencyWeight(windowIndex) {
57
+ return Math.pow(0.5, Math.max(0, windowIndex));
58
+ }
59
+
60
+ function iso(v) {
61
+ if (v instanceof Date) return Number.isFinite(v.getTime()) ? v.toISOString() : null;
62
+ if (typeof v === "string" && v.trim()) {
63
+ const t = Date.parse(v);
64
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ export function publicSeller(value) {
70
+ if (value == null) return null;
71
+ const s = String(value).trim().toLowerCase();
72
+ if (!s || s.includes("@") || /\s/.test(s) || s.length > 253) return null;
73
+ return s;
74
+ }
75
+
76
+ export function normalizeEvents(events) {
77
+ const seen = new Set();
78
+ const out = [];
79
+ for (const raw of events ?? []) {
80
+ if (!raw || typeof raw !== "object") continue;
81
+ const kind = String(raw.kind ?? "");
82
+ if (!KINDS.has(kind)) continue;
83
+ const at = iso(raw.at);
84
+ if (!at) continue;
85
+ const hash = raw.receipt_hash == null ? "" : String(raw.receipt_hash).trim().toLowerCase();
86
+ if (hash && !HEX64.test(hash)) continue;
87
+ if (hash) {
88
+ const key = `${kind}:${hash}`;
89
+ if (seen.has(key)) continue;
90
+ seen.add(key);
91
+ }
92
+ const seller = publicSeller(raw.seller);
93
+ const event = { kind, at };
94
+ if (seller) event.seller = seller;
95
+ if (hash) event.receipt_hash = hash;
96
+ const provenance = provenanceOf(raw.provenance);
97
+ if (provenance) event.provenance = provenance;
98
+ out.push(event);
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function provenanceOf(value) {
104
+ return typeof value === "string" && PROVENANCE.has(value) ? value : undefined;
105
+ }
106
+
107
+ function eventWeight(event) {
108
+ return event.provenance === "self_reported" ? SELF_REPORTED_WEIGHT : 1;
109
+ }
110
+
111
+ export function recordWindows(events) {
112
+ const buckets = new Map();
113
+ for (const event of events) {
114
+ const t = Date.parse(event.at);
115
+ if (!Number.isFinite(t)) continue;
116
+ const key = Math.floor(t / WEEK_MS);
117
+ const bucket = buckets.get(key) ?? { total: 0, good: 0, adverse: 0 };
118
+ const weight = eventWeight(event);
119
+ bucket.total += weight;
120
+ if (event.kind === "paid_on_time" || event.kind === "approval" || event.kind === "outcome") bucket.good += weight;
121
+ if (event.kind === "claim_paid" || event.kind === "denial" || event.kind === "outcome_adverse") bucket.adverse += weight;
122
+ buckets.set(key, bucket);
123
+ }
124
+ return [...buckets.keys()]
125
+ .sort((a, b) => b - a)
126
+ .map((key, index) => {
127
+ const bucket = buckets.get(key);
128
+ return { window_index: index, total: bucket.total, good: bucket.good, adverse: bucket.adverse };
129
+ });
130
+ }
131
+
132
+ function recordReliability(windows) {
133
+ let num = 0;
134
+ let den = 0;
135
+ for (const w of windows) {
136
+ const weight = recencyWeight(w.window_index);
137
+ const total = Math.max(0, Number(w.total) || 0);
138
+ num += weight * Math.max(0, Number(w.good) || 0);
139
+ den += weight * total;
140
+ }
141
+ if (den <= 0) return null;
142
+ return clamp01(num / den);
143
+ }
144
+
145
+ function recordIncidentRate(windows) {
146
+ let num = 0;
147
+ let den = 0;
148
+ for (const w of windows) {
149
+ const weight = recencyWeight(w.window_index);
150
+ const total = Math.max(0, Number(w.total) || 0);
151
+ num += weight * Math.max(0, Number(w.adverse) || 0);
152
+ den += weight * total;
153
+ }
154
+ if (den <= 0) return null;
155
+ return clamp01(num / den);
156
+ }
157
+
158
+ function recordStability(windows) {
159
+ const rates = [];
160
+ for (const w of windows) {
161
+ const total = Math.max(0, Number(w.total) || 0);
162
+ if (total <= 0) continue;
163
+ rates.push(clamp01(Math.max(0, Number(w.good) || 0) / total));
164
+ }
165
+ if (rates.length < 2) return null;
166
+ const mean = rates.reduce((a, b) => a + b, 0) / rates.length;
167
+ const ss = rates.reduce((a, b) => a + (b - mean) * (b - mean), 0);
168
+ return clamp01(1 / (1 + ss / rates.length));
169
+ }
170
+
171
+ function scoreFromWindows(windows) {
172
+ const reliability = recordReliability(windows);
173
+ const incident = recordIncidentRate(windows);
174
+ if (reliability == null || incident == null) return null;
175
+ const stability = recordStability(windows);
176
+ const base = reliability * (1 - incident);
177
+ const scaled = stability == null ? base : base * (0.5 + 0.5 * stability);
178
+ if (!Number.isFinite(scaled)) return null;
179
+ return Math.round(clamp01(scaled) * 100);
180
+ }
181
+
182
+ function selfReportedOnly(events) {
183
+ return events.length > 0 && events.every((event) => event.provenance === "self_reported");
184
+ }
185
+
186
+ export function recordScore(events) {
187
+ const kept = normalizeEvents(events);
188
+ const score = scoreFromWindows(recordWindows(kept));
189
+ if (score == null) return null;
190
+ if (selfReportedOnly(kept)) return Math.min(score, SELF_REPORTED_CAP);
191
+ return score;
192
+ }
193
+
194
+ export function agentRecord(events, asOf) {
195
+ const kept = normalizeEvents(events);
196
+ const score = kept.length ? recordScore(kept) : null;
197
+ const sellers = new Set();
198
+ let first = Infinity;
199
+ for (const event of kept) {
200
+ if (event.seller) sellers.add(event.seller);
201
+ const t = Date.parse(event.at);
202
+ if (t < first) first = t;
203
+ }
204
+ const asOfMs = asOf instanceof Date ? asOf.getTime() : Date.parse(String(asOf));
205
+ const age = kept.length && Number.isFinite(first) && Number.isFinite(asOfMs)
206
+ ? Math.max(0, Math.floor((asOfMs - first) / DAY_MS))
207
+ : null;
208
+ return {
209
+ paid_on_time: kept.filter((event) => event.kind === "paid_on_time").length,
210
+ claims_filed: kept.filter((event) => event.kind === "claim_filed").length,
211
+ claims_paid: kept.filter((event) => event.kind === "claim_paid").length,
212
+ denials: kept.filter((event) => event.kind === "denial").length,
213
+ approvals: kept.filter((event) => event.kind === "approval").length,
214
+ outcomes: kept.filter((event) => event.kind === "outcome" || event.kind === "outcome_adverse").length,
215
+ distinct_sellers: sellers.size,
216
+ age_days: age,
217
+ verified_actions: kept.filter((event) => event.provenance !== "self_reported").length,
218
+ score,
219
+ score_version: SCORE_VERSION,
220
+ label: kept.length === 0 || score == null ? NEW_RECORD_LABEL : String(score),
221
+ };
222
+ }
223
+
224
+ /** Version 1 shape. Denials, approvals, outcomes, and provenance do not count. */
225
+ export function agentRecordV1(events, asOf) {
226
+ const classic = normalizeEvents(events)
227
+ .filter((event) => V1_KINDS.has(event.kind))
228
+ .map((event) => {
229
+ const next = { kind: event.kind, at: event.at };
230
+ if (event.seller) next.seller = event.seller;
231
+ if (event.receipt_hash) next.receipt_hash = event.receipt_hash;
232
+ return next;
233
+ });
234
+ const score = classic.length ? scoreFromWindows(recordWindows(classic)) : null;
235
+ const sellers = new Set();
236
+ let first = Infinity;
237
+ for (const event of classic) {
238
+ if (event.seller) sellers.add(event.seller);
239
+ const t = Date.parse(event.at);
240
+ if (t < first) first = t;
241
+ }
242
+ const asOfMs = asOf instanceof Date ? asOf.getTime() : Date.parse(String(asOf));
243
+ const age = classic.length && Number.isFinite(first) && Number.isFinite(asOfMs)
244
+ ? Math.max(0, Math.floor((asOfMs - first) / DAY_MS))
245
+ : null;
246
+ return {
247
+ paid_on_time: classic.filter((event) => event.kind === "paid_on_time").length,
248
+ claims_filed: classic.filter((event) => event.kind === "claim_filed").length,
249
+ claims_paid: classic.filter((event) => event.kind === "claim_paid").length,
250
+ distinct_sellers: sellers.size,
251
+ age_days: age,
252
+ score,
253
+ label: classic.length === 0 || score == null ? NEW_RECORD_LABEL : String(score),
254
+ };
255
+ }
256
+
257
+ export function recordOutcomes(events) {
258
+ return normalizeEvents(events).sort((a, b) => {
259
+ if (a.at < b.at) return -1;
260
+ if (a.at > b.at) return 1;
261
+ const ah = a.receipt_hash ?? "";
262
+ const bh = b.receipt_hash ?? "";
263
+ if (ah < bh) return -1;
264
+ if (ah > bh) return 1;
265
+ if (a.kind < b.kind) return -1;
266
+ if (a.kind > b.kind) return 1;
267
+ return 0;
268
+ });
269
+ }
270
+
271
+ export function canonicalJson(value) {
272
+ return JSON.stringify(canonicalize(value));
273
+ }
274
+
275
+ function b64urlEncode(bytes) {
276
+ const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
277
+ let str = "";
278
+ for (let i = 0; i < u8.length; i++) str += String.fromCharCode(u8[i]);
279
+ return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
280
+ }
281
+
282
+ function b64urlDecode(s) {
283
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
284
+ const b64 = String(s).replace(/-/g, "+").replace(/_/g, "/") + pad;
285
+ const bin = atob(b64);
286
+ const out = new Uint8Array(bin.length);
287
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
288
+ return out;
289
+ }
290
+
291
+ async function sha256Hex(text) {
292
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
293
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
294
+ }
295
+
296
+ async function publicParts(privateJwk) {
297
+ if (!privateJwk || privateJwk.kty !== "OKP" || privateJwk.crv !== "Ed25519" || !privateJwk.x || !privateJwk.d) {
298
+ throw new Error("bond_signing_jwk_invalid");
299
+ }
300
+ const kid = privateJwk.kid || (await sha256Hex(privateJwk.x)).slice(0, 16);
301
+ return { kid, x: privateJwk.x };
302
+ }
303
+
304
+ export function recordBundle({ handle, as_of, outcomes }) {
305
+ const list = recordOutcomes(outcomes);
306
+ const asOf = typeof as_of === "string" ? new Date(as_of).toISOString() : new Date(as_of).toISOString();
307
+ return {
308
+ v: 1,
309
+ typ: RECORD_TYP,
310
+ iss: RECORD_ISS,
311
+ handle: String(handle || "").trim().toLowerCase(),
312
+ as_of: asOf,
313
+ record: agentRecord(list, asOf),
314
+ outcomes: list,
315
+ };
316
+ }
317
+
318
+ function bundleError(bundle) {
319
+ if (!bundle || typeof bundle !== "object") return "malformed";
320
+ if (bundle.v !== 1 || bundle.typ !== RECORD_TYP) return "malformed";
321
+ if (bundle.iss !== RECORD_ISS) return "bad_iss";
322
+ if (typeof bundle.handle !== "string" || !HANDLE.test(bundle.handle)) return "bad_handle";
323
+ if (typeof bundle.as_of !== "string" || !Number.isFinite(Date.parse(bundle.as_of))) return "bad_time";
324
+ if (!bundle.record || typeof bundle.record !== "object" || !Array.isArray(bundle.outcomes)) return "malformed";
325
+ return null;
326
+ }
327
+
328
+ function recordVersion(record) {
329
+ return Number(record?.score_version) === SCORE_VERSION ? SCORE_VERSION : 1;
330
+ }
331
+
332
+ /** Version 2 records use the current formula. Anything else is read as version 1. */
333
+ function expectedRecord(bundle) {
334
+ return recordVersion(bundle?.record) === SCORE_VERSION
335
+ ? agentRecord(bundle.outcomes, bundle.as_of)
336
+ : agentRecordV1(bundle.outcomes, bundle.as_of);
337
+ }
338
+
339
+ /** Sign the canonical bundle. Refuses a record that the outcomes do not produce. */
340
+ export async function signRecord(privateJwk, bundle) {
341
+ const reason = bundleError(bundle);
342
+ if (reason) throw new Error(reason);
343
+ const expected = expectedRecord(bundle);
344
+ if (canonicalJson(expected) !== canonicalJson(bundle.record)) throw new Error("record_mismatch");
345
+ return signRecordPayload(privateJwk, bundle);
346
+ }
347
+
348
+ /** Sign whatever object is passed. Used to prove a lying payload still fails. */
349
+ export async function signRecordPayload(privateJwk, bundle) {
350
+ const pub = await publicParts(privateJwk);
351
+ const header = { alg: "EdDSA", typ: RECORD_JWT_TYP, kid: pub.kid };
352
+ const payload = canonicalJson(bundle);
353
+ const signingInput = `${b64urlEncode(new TextEncoder().encode(canonicalJson(header)))}.${b64urlEncode(new TextEncoder().encode(payload))}`;
354
+ const key = await crypto.subtle.importKey(
355
+ "jwk",
356
+ { ...privateJwk, kid: pub.kid, alg: "EdDSA" },
357
+ { name: "Ed25519" },
358
+ false,
359
+ ["sign"],
360
+ );
361
+ const sig = await crypto.subtle.sign({ name: "Ed25519" }, key, new TextEncoder().encode(signingInput));
362
+ return `${signingInput}.${b64urlEncode(sig)}`;
363
+ }
364
+
365
+ // Stable `reason` codes stay as they are. `message` is the sentence a person
366
+ // can read. Clock time does not expire a record: a key verifies old exports
367
+ // for as long as it is still published.
368
+ const RECORD_MESSAGES = {
369
+ malformed: "That doesn't look like a signed record.",
370
+ bad_header: "The signature header is not an EdDSA record.",
371
+ no_key: "No published key is available to check this record.",
372
+ unknown_kid: "This record was signed by a key that is not in the published set.",
373
+ bad_iss: "This record was not issued by Agentics.",
374
+ bad_handle: "The agent handle on this record is not valid.",
375
+ bad_time: "The record time is not a real timestamp.",
376
+ bad_sig: "The signature does not match this record.",
377
+ body_mismatch: "A field in this record was changed after it was signed.",
378
+ record_mismatch: "The score does not match the outcomes in this record.",
379
+ };
380
+
381
+ function fail(reason) {
382
+ return { ok: false, reason, message: RECORD_MESSAGES[reason] || "This record did not check out." };
383
+ }
384
+
385
+ /**
386
+ * Verify a record export offline.
387
+ * `jwks` is the caller's copy of /.well-known/jwks.json. A key inside the file is ignored.
388
+ */
389
+ export async function verifyRecord(bundle, jwks) {
390
+ if (!bundle || typeof bundle !== "object") return fail("malformed");
391
+ const jws = bundle.jws;
392
+ if (typeof jws !== "string" || !jws) return fail("malformed");
393
+ const parts = jws.split(".");
394
+ if (parts.length !== 3 || parts.some((part) => !part)) return fail("malformed");
395
+ let header;
396
+ let payload;
397
+ try {
398
+ header = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[0])));
399
+ payload = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[1])));
400
+ } catch {
401
+ return fail("malformed");
402
+ }
403
+ if (!header || header.alg !== "EdDSA" || header.typ !== RECORD_JWT_TYP || !header.kid) return fail("bad_header");
404
+ const keys = Array.isArray(jwks?.keys) ? jwks.keys : [];
405
+ if (!keys.length) return fail("no_key");
406
+ const match = keys.find((key) => key && key.kid === header.kid && key.kty === "OKP" && key.crv === "Ed25519" && key.x);
407
+ if (!match) return fail("unknown_kid");
408
+ const reason = bundleError(payload);
409
+ if (reason) return fail(reason);
410
+ try {
411
+ const key = await crypto.subtle.importKey(
412
+ "jwk",
413
+ { kty: "OKP", crv: "Ed25519", x: match.x, alg: "EdDSA" },
414
+ { name: "Ed25519" },
415
+ false,
416
+ ["verify"],
417
+ );
418
+ const ok = await crypto.subtle.verify(
419
+ { name: "Ed25519" },
420
+ key,
421
+ b64urlDecode(parts[2]),
422
+ new TextEncoder().encode(`${parts[0]}.${parts[1]}`),
423
+ );
424
+ if (!ok) return fail("bad_sig");
425
+ } catch {
426
+ return fail("bad_sig");
427
+ }
428
+ const body = { ...bundle };
429
+ delete body.jws;
430
+ if (canonicalJson(body) !== canonicalJson(payload)) return fail("body_mismatch");
431
+ const expected = expectedRecord(payload);
432
+ if (canonicalJson(expected) !== canonicalJson(payload.record)) return fail("record_mismatch");
433
+ return { ok: true, handle: payload.handle, record: payload.record };
434
+ }
435
+
436
+ export function isRecordBundle(bundle) {
437
+ return !!bundle && typeof bundle === "object" && (
438
+ bundle.typ === RECORD_TYP || (typeof bundle.jws === "string" && Array.isArray(bundle.outcomes))
439
+ );
440
+ }