@intyga/verify 0.0.0-bootstrap.0 → 1.0.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/dist/index.js ADDED
@@ -0,0 +1,2236 @@
1
+ // @intyga/verify — independently confirm that a human cryptographically approved EXACTLY the action
2
+ // you are about to run. Zero runtime dependencies (node:crypto only), no network, and NO Intyga secret:
3
+ // a relying party recomputes the canonical payload from its own params, checks it byte-matches what was
4
+ // signed, and verifies the human's P-256 / WebAuthn signature. This is the "inspect-it-yourself" trust
5
+ // artifact — the whole point is that you don't have to take Intyga's word for it.
6
+ //
7
+ // The canonicalization + hashing here MUST stay byte-for-byte identical to @intyga/mcp-schemas and the
8
+ // mobile wallet, or signatures won't verify. Do not "tidy" the JSON shapes.
9
+ import crypto from "node:crypto";
10
+ function base64url(str) {
11
+ const buf = typeof str === "string" ? Buffer.from(str, "utf-8") : str;
12
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
13
+ }
14
+ function cborFail(detail) {
15
+ throw new Error(`Invalid COSE public key format: ${detail}`);
16
+ }
17
+ function requireBytes(buf, pos, len) {
18
+ if (pos + len > buf.length)
19
+ cborFail("truncated CBOR item");
20
+ }
21
+ function readHead(buf, pos) {
22
+ requireBytes(buf, pos, 1);
23
+ const initial = buf.readUInt8(pos);
24
+ const major = initial >> 5;
25
+ const info = initial & 0x1f;
26
+ let next = pos + 1;
27
+ let value;
28
+ if (info < 24)
29
+ value = info;
30
+ else if (info === 24) {
31
+ requireBytes(buf, next, 1);
32
+ value = buf.readUInt8(next);
33
+ next += 1;
34
+ }
35
+ else if (info === 25) {
36
+ requireBytes(buf, next, 2);
37
+ value = buf.readUInt16BE(next);
38
+ next += 2;
39
+ }
40
+ else if (info === 26) {
41
+ requireBytes(buf, next, 4);
42
+ value = buf.readUInt32BE(next);
43
+ next += 4;
44
+ }
45
+ else {
46
+ // 27 = 64-bit, 28-30 reserved, 31 = indefinite length. No COSE_Key needs any of them.
47
+ cborFail("unsupported CBOR length encoding");
48
+ }
49
+ return { major, value, pos: next };
50
+ }
51
+ function decodeItem(buf, pos) {
52
+ const head = readHead(buf, pos);
53
+ switch (head.major) {
54
+ case 0: // unsigned int
55
+ return { value: head.value, pos: head.pos };
56
+ case 1: // negative int — COSE labels like -1 (crv), -2 (x), -3 (y)
57
+ return { value: -1 - head.value, pos: head.pos };
58
+ case 2: // byte string
59
+ requireBytes(buf, head.pos, head.value);
60
+ return { value: buf.subarray(head.pos, head.pos + head.value), pos: head.pos + head.value };
61
+ case 3: // text string
62
+ requireBytes(buf, head.pos, head.value);
63
+ return {
64
+ value: buf.toString("utf-8", head.pos, head.pos + head.value),
65
+ pos: head.pos + head.value,
66
+ };
67
+ case 4: {
68
+ const items = [];
69
+ let cursor = head.pos;
70
+ for (let i = 0; i < head.value; i++) {
71
+ const item = decodeItem(buf, cursor);
72
+ items.push(item.value);
73
+ cursor = item.pos;
74
+ }
75
+ return { value: items, pos: cursor };
76
+ }
77
+ case 5: {
78
+ const map = new Map();
79
+ let cursor = head.pos;
80
+ for (let i = 0; i < head.value; i++) {
81
+ const key = decodeItem(buf, cursor);
82
+ const val = decodeItem(buf, key.pos);
83
+ map.set(key.value, val.value);
84
+ cursor = val.pos;
85
+ }
86
+ return { value: map, pos: cursor };
87
+ }
88
+ default:
89
+ return cborFail(`unsupported CBOR major type ${head.major}`);
90
+ }
91
+ }
92
+ /**
93
+ * Extract the P-256 coordinates from a WebAuthn COSE_Key. This walks the CBOR structure rather than
94
+ * scanning for the `0x21 0x58 0x20` / `0x22 0x58 0x20` byte patterns: a raw search can match those
95
+ * bytes *inside* another field's payload, and it cannot tell whether the 32 bytes it slices actually
96
+ * exist (a truncated buffer silently yields a short coordinate). We also pin kty/crv so a key for some
97
+ * other curve can never be reinterpreted as P-256.
98
+ */
99
+ function parseCosePublicKey(coseBuffer) {
100
+ // Decode only the leading item; trailing bytes are tolerated, as some wallets slice the COSE key out
101
+ // of attestedCredentialData without trimming what follows it.
102
+ const { value } = decodeItem(coseBuffer, 0);
103
+ if (!(value instanceof Map))
104
+ cborFail("expected a CBOR map");
105
+ const kty = value.get(1);
106
+ if (kty !== 2)
107
+ cborFail(`expected kty EC2 (2), got ${String(kty)}`);
108
+ const crv = value.get(-1);
109
+ if (crv !== 1)
110
+ cborFail(`expected crv P-256 (1), got ${String(crv)}`);
111
+ const alg = value.get(3);
112
+ if (alg !== undefined && alg !== -7)
113
+ cborFail(`expected alg ES256 (-7), got ${String(alg)}`);
114
+ const coordinate = (label, name) => {
115
+ const raw = value.get(label);
116
+ if (!Buffer.isBuffer(raw))
117
+ cborFail(`missing ${name} coordinate`);
118
+ if (raw.length !== 32)
119
+ cborFail(`${name} coordinate must be 32 bytes, got ${raw.length}`);
120
+ return raw;
121
+ };
122
+ return { x: base64url(coordinate(-2, "x")), y: base64url(coordinate(-3, "y")) };
123
+ }
124
+ /** Thrown when a value outside the JSON data model is handed to the canonicalizer. */
125
+ export class NonCanonicalValue extends Error {
126
+ }
127
+ /**
128
+ * Deterministic JSON with recursively sorted keys — identical to mcp-schemas.stableStringify.
129
+ *
130
+ * STRICT: RFC 8785 defines a mapping over JSON data, so anything outside that domain is REJECTED
131
+ * rather than coerced. The permissive version silently collapsed distinct runtime values onto one
132
+ * canonical string — `new Date(0)`, `{}`, `new Map()` and any class instance all serialized to `{}`,
133
+ * and a `toJSON` method was emitted as a `null`-valued key instead of being honoured. For a function
134
+ * whose entire job is "these bytes are exactly what was signed", quietly mapping several inputs onto
135
+ * one output is the wrong failure mode: the relying party's `expected.params` come from its own live
136
+ * runtime objects, so it is the caller most likely to hand us a Date.
137
+ *
138
+ * Exported so the shared golden vectors can pin THIS copy directly (vectors.test.ts) — every other
139
+ * port pins its canonicalizer against the committed file; the shipped relying-party verifier must
140
+ * not be the one implementation pinned only transitively.
141
+ */
142
+ export function stableStringify(value) {
143
+ if (value === null)
144
+ return "null";
145
+ const t = typeof value;
146
+ if (t === "string")
147
+ return canonicalString(value);
148
+ if (t === "boolean")
149
+ return JSON.stringify(value);
150
+ if (t === "number") {
151
+ const n = value;
152
+ if (!Number.isFinite(n))
153
+ throw new NonCanonicalValue("NaN/Infinity is not JSON");
154
+ // Cross-language portability, not just JSON validity — see isPortableNumber in @intyga/mcp-schemas.
155
+ // A number that serializes differently in the Go/Rust/Python verifiers would make a valid
156
+ // approval read as tampering there, so it is refused rather than signed over.
157
+ if (Object.is(n, -0))
158
+ throw new NonCanonicalValue("-0 does not serialize portably across verifiers");
159
+ const abs = Math.abs(n);
160
+ if (n !== 0 && abs >= 1e16) {
161
+ throw new NonCanonicalValue(`${n} is outside the portable range (|x| < 1e16)`);
162
+ }
163
+ if (n !== 0 && !Number.isInteger(n) && abs < 1e-4) {
164
+ throw new NonCanonicalValue(`${n} is outside the portable float range (1e-4 ≤ |x| < 1e16)`);
165
+ }
166
+ return JSON.stringify(n);
167
+ }
168
+ if (t !== "object") {
169
+ throw new NonCanonicalValue(`${t} cannot be canonicalized (RFC 8785 covers JSON data only)`);
170
+ }
171
+ if (Array.isArray(value)) {
172
+ const items = [];
173
+ for (let i = 0; i < value.length; i++) {
174
+ if (!Object.hasOwn(value, i))
175
+ throw new NonCanonicalValue("sparse arrays are not JSON data");
176
+ items.push(stableStringify(value[i]));
177
+ }
178
+ return `[${items.join(",")}]`;
179
+ }
180
+ // Reject Dates, Maps, Sets and class instances outright. A plain object (or a null-prototype one,
181
+ // as produced by JSON.parse with a __proto__ key) is the only shape with an unambiguous mapping.
182
+ const proto = Object.getPrototypeOf(value);
183
+ if (proto !== Object.prototype && proto !== null) {
184
+ throw new NonCanonicalValue("only plain objects can be canonicalized (got a class instance, Date, Map or Set)");
185
+ }
186
+ const obj = value;
187
+ const keys = Object.keys(obj).sort();
188
+ return `{${keys.map((k) => `${canonicalString(k)}:${stableStringify(obj[k])}`).join(",")}}`;
189
+ }
190
+ // An unpaired UTF-16 surrogate (a high surrogate with no low one after it, or a low one with no high
191
+ // one before it). Lookarounds rather than `String.prototype.isWellFormed`, which Node 18 lacks.
192
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
193
+ /**
194
+ * A string (value or member name) as RFC 8785 serializes it, refusing one that is not valid Unicode.
195
+ * RFC 8785 builds on I-JSON (RFC 7493 §2.1), which forbids unpaired surrogates. `JSON.stringify`
196
+ * would escape one as `\udXXX` — and then a receipt carrying one verified here while Go (U+FFFD),
197
+ * Rust and Python (refusal) could not agree on the same input (DIV §4.1). Mirrors mcp-schemas.
198
+ */
199
+ function canonicalString(s) {
200
+ if (LONE_SURROGATE.test(s))
201
+ throw new NonCanonicalValue("a string contains an unpaired UTF-16 surrogate, which is not I-JSON");
202
+ return JSON.stringify(s);
203
+ }
204
+ const AGENT_DIGEST = /^sha256:[0-9a-f]{64}$/;
205
+ const AGENT_DECIMAL = /^(?:0|[1-9][0-9]{0,29})(?:\.[0-9]{1,9})?$/;
206
+ const AGENT_SEQUENCE = /^[1-9][0-9]{0,17}$/;
207
+ const AGENT_CURRENCY = /^[A-Z]{3}$/;
208
+ export function validateAgentIntentContext(context, expiresAt) {
209
+ const { action, agent, session, nbf } = context;
210
+ if (!action || !["reversible", "irreversible"].includes(action.reversibility))
211
+ throw new Error("invalid agent action reversibility");
212
+ if (!agent?.label || agent.label.length > 200 || !AGENT_DIGEST.test(agent.configDigest))
213
+ throw new Error("invalid agent identity or configuration digest");
214
+ if (agent.label.normalize("NFC") !== agent.label || session?.id?.normalize("NFC") !== session?.id)
215
+ throw new Error("agent labels and session identifiers must be NFC");
216
+ if (!Object.hasOwn(agent, "delegatedBy") ||
217
+ (agent.delegatedBy !== null && !AGENT_DIGEST.test(agent.delegatedBy)))
218
+ throw new Error("invalid parent authority digest");
219
+ if (!session?.id || !AGENT_DIGEST.test(session.id) || !AGENT_SEQUENCE.test(session.seq))
220
+ throw new Error("invalid agent session identity or sequence");
221
+ if (!Object.hasOwn(session, "prev") ||
222
+ (session.seq === "1") !== (session.prev === null) ||
223
+ (session.prev !== null && !AGENT_DIGEST.test(session.prev)))
224
+ throw new Error("invalid agent session predecessor");
225
+ if (!Object.hasOwn(action, "amount") || !Object.hasOwn(session, "aggregate"))
226
+ throw new Error("invalid agent monetary amount");
227
+ for (const value of [action.amount, session.aggregate]) {
228
+ if (value && (!AGENT_DECIMAL.test(value.amount) || !AGENT_CURRENCY.test(value.currency)))
229
+ throw new Error("invalid agent monetary amount");
230
+ }
231
+ if ((action.amount === null) !== (session.aggregate === null) ||
232
+ (action.amount && session.aggregate && action.amount.currency !== session.aggregate.currency))
233
+ throw new Error("agent monetary amount and aggregate disagree");
234
+ const from = Date.parse(nbf);
235
+ const to = parseRfc3339Ms(expiresAt);
236
+ if (!Number.isFinite(from) ||
237
+ !Number.isFinite(to) ||
238
+ to <= from ||
239
+ to - from > 300_000 ||
240
+ new Date(from).toISOString() !== nbf ||
241
+ new Date(to).toISOString() !== expiresAt)
242
+ throw new Error("agent intent must use canonical UTC times within five minutes");
243
+ }
244
+ // RFC 3339 §5.6 `date-time`, strictly: four-digit year, uppercase `T`, seconds present, an optional
245
+ // fraction of 1–9 digits, and an explicit `Z` or `±hh:mm` zone. Ranges are checked separately below.
246
+ const RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/;
247
+ /**
248
+ * Milliseconds since the epoch for a signed RFC 3339 timestamp, or NaN (DIV §6.2).
249
+ *
250
+ * `Date.parse` alone is far too lenient for signed times: it accepts a bare date, a zone-less time
251
+ * (read in the HOST's timezone, so the verdict moved with the machine's TZ setting), lowercase
252
+ * separators and 30 February, which it rolls into March. Every port applies this same grammar:
253
+ * the date must exist, hours 00–23, minutes and seconds 00–59 (no leap second — Go and ECMAScript
254
+ * refuse `:60`), offset hours 00–23 and minutes 00–59.
255
+ */
256
+ function parseRfc3339Ms(value) {
257
+ if (typeof value !== "string")
258
+ return Number.NaN;
259
+ const m = RFC3339_DATE_TIME.exec(value);
260
+ if (!m)
261
+ return Number.NaN;
262
+ const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
263
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
264
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
265
+ if (days === undefined || day < 1 || day > days || hour > 23 || minute > 59 || second > 59)
266
+ return Number.NaN;
267
+ if (m[7] !== undefined && (Number(m[7]) > 23 || Number(m[8]) > 59))
268
+ return Number.NaN;
269
+ const ms = Date.parse(value);
270
+ return Number.isFinite(ms) ? ms : Number.NaN;
271
+ }
272
+ /** DIV protocol version and type discriminator — identical to mcp-schemas. */
273
+ export const DIV_VERSION = 1;
274
+ export const DIV_INTENT_TYPE = "div-intent-verification";
275
+ /**
276
+ * OFFLINE APPROVAL (docs/DIV.md §5a.2): a normal approval, signed by real humans through the normal
277
+ * quorum, but collected OUT OF BAND at incident time because the gateway is unreachable. The relying
278
+ * party builds the challenge itself, the humans review and sign it on a disconnected device, and the
279
+ * result is verified by the ordinary §5 procedure.
280
+ *
281
+ * This deliberately replaces the older pre-signed "sealed break-glass" token. Pre-signing puts a
282
+ * bearer capability on disk and captures a human judgment about a HYPOTHETICAL; moving the ceremony
283
+ * off the network instead keeps the human in the loop for the ACTUAL incident and leaves nothing at
284
+ * rest to steal. See DIV §5a.1.
285
+ *
286
+ * The distinct `type` is the single most important guardrail in the whole mechanism. It sits inside
287
+ * the signed bytes, so:
288
+ * - an offline proof can NEVER verify as a normal approval, and
289
+ * - a normal approval can NEVER be replayed as an offline one.
290
+ * Neither direction is possible even with a byte-identical action, because the reconstructed payload
291
+ * differs and the signature comparison fails. Do not "simplify" this into a flag outside the payload.
292
+ */
293
+ export const DIV_OFFLINE_INTENT_TYPE = "div-offline-intent";
294
+ /**
295
+ * DELEGATION (docs/DIV.md §5a.5): signed in advance by the ordinary quorum, it transfers the
296
+ * AUTHORITY TO APPROVE one pre-declared action to a named set of local operators.
297
+ *
298
+ * A delegation authorizes NOTHING by itself. `verifyApprovalReceipt` refuses this type outright and
299
+ * there is deliberately no opt-in flag that would let it through — see `verifyDelegation`, which is a
300
+ * separate operation for exactly that reason. A delegation that could authorize its own action would
301
+ * be the pre-signed bearer capability DIV §5a.1 rejects.
302
+ */
303
+ export const DIV_DELEGATION_TYPE = "div-delegation";
304
+ /**
305
+ * Agent authority (DIV §5b): a quorum-signed statement of STANDING SCOPE for one agent. Authorizes
306
+ * no action on its own; `verifyApprovalReceipt` refuses it outright, and `verifyAgentAuthority` is
307
+ * the only door. Mirrors mcp-schemas.
308
+ */
309
+ export const DIV_AGENT_AUTHORITY_TYPE = "div-agent-authority";
310
+ /**
311
+ * Platform hash-only intent (DIV §5c): an integrating platform's subject signs the DIGEST of the
312
+ * platform's own canonical payload. `verifyApprovalReceipt` refuses it outright;
313
+ * `verifyPlatformReceipt` is the only door. Mirrors mcp-schemas.
314
+ */
315
+ export const DIV_PLATFORM_INTENT_TYPE = "div-platform-intent";
316
+ /**
317
+ * Hard ceiling on an offline proof's validity window, enforced at verification and not only at mint.
318
+ * An offline proof is created and redeemed within one incident, so the window is minutes — it exists
319
+ * to bound a proof whose `expiresAt` was minted over-long, which is otherwise indistinguishable at
320
+ * verification time from a correct one (DIV §5a.3).
321
+ */
322
+ export const MAX_OFFLINE_WINDOW_MINUTES = 60;
323
+ /**
324
+ * Ceiling on the witness list this verifier will process. A DIV quorum is single digits — this is a
325
+ * denial-of-service bound, not a policy limit, because verification runs in the relying party's own
326
+ * process on an attacker-supplied receipt immediately before an irreversible action.
327
+ */
328
+ export const MAX_WITNESSES = 64;
329
+ /** How many per-witness failure reasons are folded into the returned `reason` string. */
330
+ const MAX_REPORTED_FAILURES = 8;
331
+ /**
332
+ * Hard ceiling on a delegation's validity window. Hours, not the 30 days the old sealed token
333
+ * allowed: a delegation cannot be revoked at an offline relying party, so the short window IS the
334
+ * revocation story (DIV §5a.6).
335
+ */
336
+ export const MAX_DELEGATION_WINDOW_HOURS = 72;
337
+ /**
338
+ * The approval policy in force for a challenge, frozen at creation and SIGNED as part of the payload.
339
+ *
340
+ * This exists because the gateway enforces a rich requirement (quorum, four-eyes, hardware class) that
341
+ * used to appear nowhere in the signed bytes. A receipt from a 3-of-3, hardware-key-pinned challenge
342
+ * was byte-for-byte indistinguishable from a 1-of-1, no-hardware one — so a relying party doing
343
+ * "offline verification" still had to take the gateway's word for the entire policy, which is the
344
+ * exact class of trust the offline verifier exists to remove. Binding it into the payload also means
345
+ * the APPROVER sees and attests to the policy their signature is being counted toward.
346
+ *
347
+ * What a verifier can check offline, and what it cannot:
348
+ * - `requiredApprovals` — fully checkable. Counts distinct trusted approver signatures.
349
+ * - `requesterCannotApprove` — fully checkable. The requester DID is in the same signed payload.
350
+ * - `requireHardwareKey` — PARTIALLY checkable. A verifier can confirm the witness is a WebAuthn
351
+ * assertion rather than a bare P-256 key, and it refuses one whose signed authenticatorData says
352
+ * Backup Eligible or Backup State (a synced passkey announcing itself). An assertion carries no
353
+ * attestation, though, so BE=0 is the authenticator's claim, not proof of a discrete security key.
354
+ * - `allowedAaguids` — NOT checkable offline. The AAGUID lives in attestedCredentialData, which is
355
+ * present at REGISTRATION, not in an assertion. Only the gateway (which stored it at enrollment)
356
+ * can enforce the model. What a verifier CAN do is refuse what obviously cannot satisfy it: a
357
+ * non-empty allowlist is treated exactly like `requireHardwareKey` — a bare-key witness is refused
358
+ * and an offline proof is refused outright (`requiresHardwareCredential`).
359
+ * - `signerClass` — PARTIALLY checkable, and differently per witness kind. For a WEBAUTHN witness
360
+ * the UV flag (already required by `verifyWebAuthnSignature`) is cryptographic evidence a
361
+ * user-verification ceremony — a human gesture — happened at signing. An ES256 witness carries no
362
+ * signer-class evidence at all: there the class rests on the issuing deployment's signing-time
363
+ * enforcement, or, for an offline proof, on the delegation ceremony that named the operators.
364
+ * The verifier's own obligation is narrower and absolute: REFUSE any value it does not
365
+ * recognize (only "human" is defined today), so a future signer class can never verify as
366
+ * human-approved by default. It deliberately does NOT reject ES256 witnesses under
367
+ * `signerClass: "human"` — humans legitimately sign with raw P-256 keys (offline break-glass);
368
+ * a deployment wanting cryptographic proof of the ceremony pins `requireHardwareKey`.
369
+ */
370
+ /**
371
+ * Whether a signed requirement can only be met by a hardware-backed WebAuthn credential: an explicit
372
+ * `requireHardwareKey`, OR a non-empty `allowedAaguids` model allowlist. The two are the same class of
373
+ * policy for every check a verifier can make — a bare key satisfies neither, and neither can be met
374
+ * offline — so they are refused together. Treating the allowlist as "not checkable, so not checked"
375
+ * let a bare software key satisfy a YubiKey-only rule on every offline path.
376
+ */
377
+ export function requiresHardwareCredential(requirement) {
378
+ return (requirement.requireHardwareKey === true ||
379
+ (Array.isArray(requirement.allowedAaguids) && requirement.allowedAaguids.length > 0));
380
+ }
381
+ /**
382
+ * The one signer class defined by DIV today (docs/DIV.md §4.3.2). Mirrors mcp-schemas, like
383
+ * DIV_VERSION — this package deliberately imports nothing from it.
384
+ */
385
+ export const SIGNER_CLASS_HUMAN = "human";
386
+ /** The signer classes this verifier knows how to reason about (DIV §4.3.2). Mirrors mcp-schemas. */
387
+ const KNOWN_SIGNER_CLASSES = new Set([SIGNER_CLASS_HUMAN]);
388
+ /**
389
+ * Extract and validate `requirement.signerClass` from a parsed signed requirement. FAIL CLOSED both
390
+ * ways: a payload with no class predates (or dropped) the field and cannot be verified by this
391
+ * version, and an unrecognized class must never verify as if it were human-approved — that is the
392
+ * entire point of putting the class in the signed bytes.
393
+ */
394
+ function parseSignerClass(requirement) {
395
+ const sc = requirement.signerClass;
396
+ if (typeof sc !== "string" || sc.length === 0)
397
+ return { ok: false, reason: "the signed requirement is missing signerClass (DIV §4.3.2)" };
398
+ if (!KNOWN_SIGNER_CLASSES.has(sc))
399
+ return {
400
+ ok: false,
401
+ reason: `the signed requirement declares signerClass "${sc}", which this verifier does not recognize — refusing rather than treating it as human-approved (DIV §4.3.2)`,
402
+ };
403
+ return { ok: true, signerClass: sc };
404
+ }
405
+ /**
406
+ * DIV §4.3.4 / §5-step-3c. `evidence` is REQUIRED in the signed bytes and MUST be `null` in v1.
407
+ *
408
+ * Read out of `canonicalPayload`, never an envelope echo — there is deliberately none (§4.4.1), and
409
+ * a forged value inside the signed bytes fails the byte comparison anyway.
410
+ *
411
+ * `parseField` deliberately is NOT used here: it returns `undefined` for an absent key, for a failed
412
+ * JSON parse, AND for a present `null`, so it cannot express the one distinction this check is made
413
+ * of. Collapsing "absent" into "null" turns the whole reservation into a no-op — an evidence-
414
+ * conditioned payload would then verify as though it were unconditioned, which is the exact outcome
415
+ * §4.3.4 exists to prevent.
416
+ */
417
+ function parseEvidence(canonical) {
418
+ let parsed;
419
+ try {
420
+ parsed = JSON.parse(canonical);
421
+ }
422
+ catch {
423
+ return { ok: false, reason: "the signed payload is not valid JSON" };
424
+ }
425
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
426
+ return { ok: false, reason: "the signed payload is not a JSON object" };
427
+ }
428
+ if (!("evidence" in parsed)) {
429
+ return { ok: false, reason: "the signed payload is missing evidence (DIV §4.3.4)" };
430
+ }
431
+ if (parsed.evidence !== null) {
432
+ return {
433
+ ok: false,
434
+ reason: "the signed payload declares an evidence condition, which this verifier does not support — refusing rather than treating it as unconditioned (DIV §4.3.4)",
435
+ };
436
+ }
437
+ return { ok: true };
438
+ }
439
+ /**
440
+ * Canonical DIV Intent Payload (docs/DIV.md v1) — byte-identical to
441
+ * mcp-schemas.canonicalIntentPayload. Strict RFC 8785 JCS: the whole object is serialized with every
442
+ * key sorted recursively by UTF-16 code unit via `stableStringify`. Do NOT hand-order keys.
443
+ */
444
+ export function canonicalIntentPayload(input) {
445
+ if (input.agentContext)
446
+ validateAgentIntentContext(input.agentContext, input.expiresAt);
447
+ return stableStringify({
448
+ v: DIV_VERSION,
449
+ type: DIV_INTENT_TYPE,
450
+ target: input.target,
451
+ actionType: input.actionType,
452
+ display: input.display,
453
+ params: input.params,
454
+ // DIV §4.3.4. Reserved, and REQUIRED in the bytes: `null` is the payload's explicit statement
455
+ // that no external-evidence condition applied, exactly as `requester.attestation`'s null is.
456
+ // Hardcoded rather than taken from `input` on purpose — an optional field a call site forgets is
457
+ // absent from Object.keys, so stableStringify never sees it and never throws, and the omission
458
+ // surfaces later as an unreproducible signature. Non-null values are a later spec version.
459
+ evidence: null,
460
+ ...commonSignedFields(input.requester, input.requirement),
461
+ nonce: input.nonce,
462
+ // The four agent-extension keys by NAME, never a spread: a context object carrying any other
463
+ // top-level key (`target`, `params`, …) would otherwise overwrite the signed fields above, so a
464
+ // relying party that filled `agentContext` from the receipt itself would verify the receipt
465
+ // against its own bytes. The Go, Rust, Java and Python builders copy exactly these four.
466
+ ...(input.agentContext
467
+ ? {
468
+ action: input.agentContext.action,
469
+ agent: input.agentContext.agent,
470
+ session: input.agentContext.session,
471
+ nbf: input.agentContext.nbf,
472
+ exp: input.expiresAt,
473
+ }
474
+ : { expiresAt: input.expiresAt }),
475
+ });
476
+ }
477
+ /**
478
+ * The requester + requirement projection shared by all three canonical builders.
479
+ *
480
+ * One definition rather than three copies: these bytes are the contract, and a field added to one
481
+ * builder but not the others is precisely the drift the cross-package vectors exist to catch. The
482
+ * golden vectors pin the output, so this factoring is verified rather than assumed.
483
+ */
484
+ function commonSignedFields(requester, requirement) {
485
+ const a = requester.attestation;
486
+ return {
487
+ requester: {
488
+ did: requester.did,
489
+ attestation: a ? { method: a.method, issuer: a.issuer, subject: a.subject } : null,
490
+ },
491
+ requirement: {
492
+ requiredApprovals: requirement.requiredApprovals,
493
+ requireHardwareKey: requirement.requireHardwareKey,
494
+ // Sorted: the set is what matters, and an unordered list would make two identical policies
495
+ // produce different bytes depending on how the rule happened to be written.
496
+ allowedAaguids: [...requirement.allowedAaguids].sort(),
497
+ requesterCannotApprove: requirement.requesterCannotApprove,
498
+ signerClass: requirement.signerClass,
499
+ },
500
+ };
501
+ }
502
+ /**
503
+ * Canonical OFFLINE INTENT payload (DIV §5a.2). Deliberately a separate function rather than a `type`
504
+ * parameter on `canonicalIntentPayload`.
505
+ *
506
+ * A parameter would mean every existing call site could silently produce the wrong kind by passing
507
+ * the wrong argument, and the normal approval path — which is the overwhelmingly common one — would
508
+ * carry a footgun for the sake of a rare one. Two functions cannot be confused: you either called the
509
+ * offline builder or you did not.
510
+ *
511
+ * `challengedAt` is the only extra field, and it exists so the verifier can bound the validity
512
+ * WINDOW. Without it, a payload minted with a 10-year `expiresAt` would be indistinguishable from a
513
+ * correctly minted one at verification time.
514
+ */
515
+ export function canonicalOfflineIntentPayload(input) {
516
+ return stableStringify({
517
+ v: DIV_VERSION,
518
+ type: DIV_OFFLINE_INTENT_TYPE,
519
+ target: input.target,
520
+ actionType: input.actionType,
521
+ display: input.display,
522
+ params: input.params,
523
+ // DIV §4.3.4. Reserved, and REQUIRED in the bytes: `null` is the payload's explicit statement
524
+ // that no external-evidence condition applied, exactly as `requester.attestation`'s null is.
525
+ // Hardcoded rather than taken from `input` on purpose — an optional field a call site forgets is
526
+ // absent from Object.keys, so stableStringify never sees it and never throws, and the omission
527
+ // surfaces later as an unreproducible signature. Non-null values are a later spec version.
528
+ evidence: null,
529
+ ...commonSignedFields(input.requester, input.requirement),
530
+ nonce: input.nonce,
531
+ challengedAt: input.challengedAt,
532
+ expiresAt: input.expiresAt,
533
+ });
534
+ }
535
+ /**
536
+ * Canonical DELEGATION payload (DIV §5a.5) — a signed statement about WHO MAY APPROVE, not about
537
+ * what may run.
538
+ *
539
+ * `delegatedTo` is sorted because it is a SET: the same three operators in a different order must
540
+ * produce the same bytes, exactly as for `allowedAaguids`. `requirement` here describes the quorum
541
+ * that signed this delegation, while `delegatedQuorum` is how many of `delegatedTo` must sign at
542
+ * incident time — two different quorums, which is why both are in the signed bytes.
543
+ */
544
+ export function canonicalDelegationPayload(input) {
545
+ return stableStringify({
546
+ v: DIV_VERSION,
547
+ type: DIV_DELEGATION_TYPE,
548
+ target: input.target,
549
+ actionType: input.actionType,
550
+ display: input.display,
551
+ params: input.params,
552
+ ...commonSignedFields(input.requester, input.requirement),
553
+ delegatedTo: [...input.delegatedTo].sort(),
554
+ delegatedQuorum: input.delegatedQuorum,
555
+ nonce: input.nonce,
556
+ sealedAt: input.sealedAt,
557
+ expiresAt: input.expiresAt,
558
+ });
559
+ }
560
+ /**
561
+ * Canonical AGENT AUTHORITY payload (DIV §5b) — byte-identical to
562
+ * mcp-schemas.canonicalAgentAuthorityPayload; parity is enforced by `canonical-parity.test.ts`.
563
+ *
564
+ * Not a delegation: `div-delegation` deliberately covers exactly one action, forbids wildcards and
565
+ * caps its window at 72 hours, because it pre-authorizes WHO MAY APPROVE at incident time. An
566
+ * authority is governance enforced online — it may carry a scope (patterns) and a long validity
567
+ * precisely because it authorizes nothing offline. `actionPatterns` is sorted because it is a SET,
568
+ * exactly as `allowedAaguids` is.
569
+ */
570
+ export function canonicalAgentAuthorityPayload(input) {
571
+ return stableStringify({
572
+ v: DIV_VERSION,
573
+ type: DIV_AGENT_AUTHORITY_TYPE,
574
+ target: input.target,
575
+ actionPatterns: [...input.actionPatterns].sort(),
576
+ display: input.display,
577
+ agent: { did: input.agent.did },
578
+ parentReceiptHash: input.parentReceiptHash ?? null,
579
+ ...commonSignedFields(input.requester, input.requirement),
580
+ nonce: input.nonce,
581
+ sealedAt: input.sealedAt,
582
+ expiresAt: input.expiresAt,
583
+ });
584
+ }
585
+ /**
586
+ * Canonical PLATFORM HASH-ONLY INTENT payload (DIV §5c.2) — byte-parity with mcp-schemas' copy is
587
+ * enforced by canonical-parity.test.ts. This mirror REPRODUCES bytes and does not validate the
588
+ * digest grammar (producers normalize, verifiers reproduce); `verifyPlatformReceipt` enforces the
589
+ * lowercase-hex rule on the RELYING PARTY's expected value instead.
590
+ */
591
+ export function canonicalPlatformIntentPayload(input) {
592
+ return stableStringify({
593
+ v: DIV_VERSION,
594
+ type: DIV_PLATFORM_INTENT_TYPE,
595
+ hashAlg: "SHA-256",
596
+ payloadHash: input.payloadHash,
597
+ rpId: input.rpId,
598
+ subject: { externalId: input.subjectExternalId },
599
+ signedAt: input.signedAt,
600
+ expiresAt: input.expiresAt,
601
+ nonce: input.nonce,
602
+ });
603
+ }
604
+ /** Short verification code (first 8 hex of SHA-256 of the canonical payload), grouped XXXX-XXXX. */
605
+ export function verificationCode(canonical) {
606
+ const hex = crypto
607
+ .createHash("sha256")
608
+ .update(Buffer.from(canonical, "utf8"))
609
+ .digest("hex")
610
+ .slice(0, 8)
611
+ .toUpperCase();
612
+ return `${hex.slice(0, 4)}-${hex.slice(4, 8)}`;
613
+ }
614
+ /** Verify a raw ECDSA P-256 signature (base64, DER or IEEE-P1363) over `payload` against an SPKI key. */
615
+ export function verifyEcdsaP256(publicKeyB64, payload, signatureB64) {
616
+ try {
617
+ const keyObject = crypto.createPublicKey({
618
+ key: Buffer.from(publicKeyB64, "base64"),
619
+ format: "der",
620
+ type: "spki",
621
+ });
622
+ // Pin the key to EC / P-256. createPublicKey happily accepts RSA, Ed25519 or P-521 SPKI, and
623
+ // crypto.verify would then verify under THAT algorithm while the receipt is labelled ES256. The
624
+ // key is chosen by whoever enrolled the wallet, so the label must be enforced, not trusted.
625
+ if (keyObject.asymmetricKeyType !== "ec")
626
+ return false;
627
+ if (keyObject.asymmetricKeyDetails?.namedCurve !== "prime256v1")
628
+ return false;
629
+ const signature = Buffer.from(signatureB64, "base64");
630
+ const data = Buffer.from(payload, "utf8");
631
+ // Raw IEEE-P1363 (r||s) is always exactly 64 bytes for P-256. DER is usually 70-72 but can in
632
+ // principle also be 64 (r and s each shedding three leading zero bytes — vanishingly rare, ~2^-48,
633
+ // yet a correctness cliff rather than a graceful one). Try the encodings instead of inferring one
634
+ // from length, so the distinction stops mattering at all.
635
+ const tryEncoding = (dsaEncoding) => {
636
+ try {
637
+ return crypto.verify("sha256", data, { key: keyObject, dsaEncoding }, signature);
638
+ }
639
+ catch {
640
+ return false;
641
+ }
642
+ };
643
+ if (signature.length === 64 && tryEncoding("ieee-p1363"))
644
+ return true;
645
+ return tryEncoding("der");
646
+ }
647
+ catch {
648
+ return false;
649
+ }
650
+ }
651
+ function parseField(canonical, key) {
652
+ try {
653
+ return JSON.parse(canonical)[key];
654
+ }
655
+ catch {
656
+ return undefined;
657
+ }
658
+ }
659
+ function parseNonce(canonical) {
660
+ return parseField(canonical, "nonce") ?? "";
661
+ }
662
+ /** Which DIV version a receipt was signed under. Unparseable/absent ⇒ 0 (rejected). */
663
+ function parseVersion(canonical) {
664
+ const v = parseField(canonical, "v");
665
+ return typeof v === "number" ? v : 0;
666
+ }
667
+ /** Default clock-skew tolerance for expiry validation (DIV §6.2 RECOMMENDED ±30s). */
668
+ export const DEFAULT_CLOCK_SKEW_SECONDS = 30;
669
+ /** DIV §4.3.2: a signed quorum is an integer ≥ 1. Zero passes §5 step 7's "at least" test vacuously. */
670
+ const isValidQuorum = (n) => Number.isInteger(n) && n >= 1;
671
+ const INVALID_QUORUM_REASON = "signed requirement.requiredApprovals must be an integer of at least 1 (DIV §4.3.2)";
672
+ /** The reason stem every port uses when a signed requirement is below the caller's floor. */
673
+ export const WEAKER_REQUIREMENT_REASON = "signed requirement is weaker than the relying party's policy";
674
+ /**
675
+ * DIV §5 step 3d. `null` when no floor was supplied or the signed requirement meets it. Fails CLOSED
676
+ * on a malformed floor: a floor the caller got wrong must not silently become "no floor".
677
+ */
678
+ function requirementFloorProblem(signed, floor) {
679
+ if (floor === undefined || floor === null)
680
+ return null;
681
+ if (typeof floor !== "object" ||
682
+ typeof floor.requiredApprovals !== "number" ||
683
+ !isValidQuorum(floor.requiredApprovals) ||
684
+ (floor.requesterCannotApprove !== undefined && typeof floor.requesterCannotApprove !== "boolean") ||
685
+ (floor.requireHardwareKey !== undefined && typeof floor.requireHardwareKey !== "boolean"))
686
+ return "expected.requirement is malformed: requiredApprovals must be an integer of at least 1 and the flags booleans";
687
+ const signedApprovals = signed.requiredApprovals ?? 0;
688
+ if (signedApprovals < floor.requiredApprovals)
689
+ return `${WEAKER_REQUIREMENT_REASON}: it requires ${signedApprovals} approval(s), the policy ${floor.requiredApprovals} (DIV §5 step 3d)`;
690
+ if (floor.requesterCannotApprove === true && signed.requesterCannotApprove !== true)
691
+ return `${WEAKER_REQUIREMENT_REASON}: it does not forbid the requester approving (DIV §5 step 3d)`;
692
+ if (floor.requireHardwareKey === true && signed.requireHardwareKey !== true)
693
+ return `${WEAKER_REQUIREMENT_REASON}: it does not require a hardware key (DIV §5 step 3d)`;
694
+ return null;
695
+ }
696
+ /**
697
+ * Prefix of a self-certifying Intyga DID: `did:intyga:key:<base64url(sha256(publicKey bytes))>`.
698
+ * The identifier IS a commitment to the enrolled public key (the issuing gateway's derivation), so a
699
+ * DID of this form can serve as a complete trust anchor entry on its own. The commitment is to the
700
+ * EXACT enrolled key bytes — an approver signing with a different credential (say, a browser passkey
701
+ * registered later) does not match it, and needs a `resolveKey` mapping under a stable DID instead.
702
+ */
703
+ export const SELF_CERTIFYING_DID_PREFIX = "did:intyga:key:";
704
+ /**
705
+ * Derive the self-certifying DID for a public key (base64; the DECODED bytes are hashed, so padded
706
+ * and unpadded encodings of the same key derive the same DID). Mirrors the gateway's derivation.
707
+ */
708
+ export function selfCertifyingDid(publicKeyB64) {
709
+ const digest = crypto
710
+ .createHash("sha256")
711
+ .update(Buffer.from(publicKeyB64, "base64"))
712
+ .digest("base64")
713
+ .replace(/\+/g, "-")
714
+ .replace(/\//g, "_")
715
+ .replace(/=+$/, "");
716
+ return `${SELF_CERTIFYING_DID_PREFIX}${digest}`;
717
+ }
718
+ // WebAuthn authenticatorData flag bits (WebAuthn L3 §6.1).
719
+ const AUTH_DATA_FLAG_UP = 0x01; // User Present
720
+ const AUTH_DATA_FLAG_UV = 0x04; // User Verified
721
+ const AUTH_DATA_FLAG_BE = 0x08; // Backup Eligible — the credential may be synced to other devices
722
+ const AUTH_DATA_FLAG_BS = 0x10; // Backup State — the credential is currently backed up
723
+ /**
724
+ * The keys we are willing to accept this witness under, drawn ENTIRELY from the caller's trust anchor.
725
+ *
726
+ * `witness.signerPublicKey` is never used as a verification key — only, in DID mode, as a claim about
727
+ * WHICH approver is speaking, which we then answer with our own resolver. The single exception is a
728
+ * pinned SELF-CERTIFYING DID (`did:intyga:key:…`) for which the anchor names NO keys: there the
729
+ * carried key is first PROVEN to be the pinned key by hashing it against the DID's fingerprint — the
730
+ * trust still comes from the pinned identifier, never from the receipt. An anchor that DOES name
731
+ * keys for the DID takes precedence over the commitment (see the precedence note below). Note we do
732
+ * not otherwise compare the presented key to the trusted one: there is nothing to gain (a mismatched
733
+ * key simply fails to verify) and byte-equality is actively wrong for COSE, where the same P-256 key
734
+ * has many valid encodings.
735
+ *
736
+ * Each candidate is tagged with the identity that key represents, so quorum counts distinct APPROVERS.
737
+ * In `publicKeys` mode the identity is the key itself: the receipt's `signerDid` is an unverified
738
+ * string there, and counting it would let one approver claim to be three.
739
+ */
740
+ function candidateKeys(anchor, witness,
741
+ /**
742
+ * When a delegation is in force, the eligible approvers are narrowed to the identities it names
743
+ * (DIV §5a.6 step 3). Applied ON TOP of the trust anchor, never instead of it: a delegation says
744
+ * WHO may approve, and the anchor still says which key is actually theirs.
745
+ */
746
+ restrictTo) {
747
+ // Plain-JS callers can hand over any shape; a malformed anchor is a refusal, never a TypeError.
748
+ if (typeof anchor !== "object" || anchor === null)
749
+ return { reason: "trust anchor must be { publicKeys } or { dids, resolveKey }" };
750
+ if (anchor.publicKeys !== undefined) {
751
+ if (!Array.isArray(anchor.publicKeys) || anchor.publicKeys.some((k) => typeof k !== "string"))
752
+ return { reason: "trust anchor publicKeys must be an array of base64 key strings" };
753
+ // A delegation names identities, and in publicKeys mode `signerDid` is an unverified string —
754
+ // enforcing `delegatedTo` against it would be security theatre. Refuse rather than pretend.
755
+ if (restrictTo)
756
+ return {
757
+ reason: "a delegation names approver identities, so it requires a DID-mode trust anchor ({ dids, resolveKey }); in publicKeys mode signerDid is unverified and delegatedTo cannot be enforced",
758
+ };
759
+ if (anchor.publicKeys.length === 0)
760
+ return { reason: "trusted approver allowlist is empty" };
761
+ return { keys: anchor.publicKeys.map((key) => ({ key, identity: key })) };
762
+ }
763
+ if (!Array.isArray(anchor.dids))
764
+ return { reason: "trust anchor must be { publicKeys } or { dids, resolveKey }" };
765
+ if (!witness.signerDid || !anchor.dids.includes(witness.signerDid)) {
766
+ return { reason: `signer ${witness.signerDid || "(unknown)"} is not an authorized approver` };
767
+ }
768
+ if (restrictTo && !restrictTo.includes(witness.signerDid)) {
769
+ return { reason: `signer ${witness.signerDid} is not named in the delegation` };
770
+ }
771
+ // PRECEDENCE: the anchor's own key mapping always wins, self-certifying DID or not. The RP's
772
+ // explicit pin must be able to both WIDEN what a key-derived DID would accept (the person's
773
+ // later-enrolled credentials live under the same DID) and NARROW it (a compromised credential is
774
+ // dropped by re-exporting the anchor without it) — a commitment that overrode the mapping could
775
+ // do neither, and the four Core Profile ports resolve mapped keys the same way.
776
+ const resolved = anchor.resolveKey ? anchor.resolveKey(witness.signerDid) : null;
777
+ // One DID may legitimately hold several keys; all of them identify the SAME approver, so quorum
778
+ // still counts one. Flattening them into separate identities would let one person meet an N-of-M.
779
+ const keys = (Array.isArray(resolved) ? resolved : resolved ? [resolved] : []).filter((k) => Boolean(k));
780
+ if (keys.length > 0) {
781
+ return { keys: keys.map((key) => ({ key, identity: witness.signerDid })) };
782
+ }
783
+ // Self-certifying DID fallback: when the anchor names no keys, the pinned identifier itself is
784
+ // the commitment — the carried key is trustworthy exactly when it hashes to the DID. This is what
785
+ // makes a bare DID list a complete anchor with no key distribution at all. Strict by design: the
786
+ // commitment is to the exact enrolled key bytes.
787
+ if (witness.signerDid.startsWith(SELF_CERTIFYING_DID_PREFIX)) {
788
+ if (!witness.signerPublicKey) {
789
+ return {
790
+ reason: `witness carries no public key to validate against self-certifying ${witness.signerDid}`,
791
+ };
792
+ }
793
+ if (selfCertifyingDid(witness.signerPublicKey) !== witness.signerDid) {
794
+ return {
795
+ reason: "witness public key does not hash to the pinned self-certifying DID (did:intyga:key), and the trust anchor names no keys for it",
796
+ };
797
+ }
798
+ return { keys: [{ key: witness.signerPublicKey, identity: witness.signerDid }] };
799
+ }
800
+ if (!anchor.resolveKey) {
801
+ return {
802
+ reason: `${witness.signerDid} is not self-certifying (${SELF_CERTIFYING_DID_PREFIX}…) and the trust anchor provides no resolveKey`,
803
+ };
804
+ }
805
+ return { reason: `no trusted key could be resolved for ${witness.signerDid}` };
806
+ }
807
+ /**
808
+ * One key, one person (DIV §4.4.6). An identity-associating anchor that maps the SAME key to two
809
+ * DIDs (an export bug, or one person enrolled under two identifiers) would otherwise let that key's
810
+ * holder count as two approvers, since quorum counts distinct identities. So a key already counted
811
+ * for one identity cannot count for another: distinct keys AND distinct identities are required.
812
+ * Keys are compared by their decoded bytes, so padded/unpadded and base64/base64url spellings of one
813
+ * encoding match; the same key in a different encoding (COSE vs SPKI) is not detected.
814
+ * Records the key when it is free; returns the refusal reason when it is not.
815
+ */
816
+ function sharedKeyProblem(counted, key, identity) {
817
+ const fingerprint = Buffer.from(key, "base64").toString("hex");
818
+ const owner = counted.get(fingerprint);
819
+ if (owner !== undefined && owner !== identity)
820
+ return `signer ${identity} verified under a key already counted for ${owner}; two approver identities sharing one key count once (DIV §4.4.6)`;
821
+ counted.set(fingerprint, identity);
822
+ return null;
823
+ }
824
+ /** Verify one witness signature over the canonical payload, using an already-TRUSTED key. */
825
+ function verifyWitness(witness, trustedKey, canonicalPayload, opts) {
826
+ // DIV §4.4.2: legacy missing/unknown labels use ES256; AUTO_APPROVED never does.
827
+ if (witness.sigAlg === "AUTO_APPROVED" || (witness.sigAlg != null && typeof witness.sigAlg !== "string")) {
828
+ return { ok: false, reason: "unsupported witness signature algorithm" };
829
+ }
830
+ if (witness.sigAlg !== "WEBAUTHN") {
831
+ return verifyEcdsaP256(trustedKey, canonicalPayload, witness.signature)
832
+ ? { ok: true }
833
+ : { ok: false, reason: "signature does not verify against the trusted signer key" };
834
+ }
835
+ if (!witness.authenticatorData || !witness.clientDataJSON) {
836
+ return { ok: false, reason: "WebAuthn witness missing authenticatorData or clientDataJSON" };
837
+ }
838
+ // FAIL CLOSED, same rule as the rest of this package: without an expected origin and RP ID there
839
+ // is nothing to pin the assertion to, and an assertion harvested at an attacker's relying party
840
+ // would verify. Refuse rather than check a weaker property.
841
+ if (!opts.expectedOrigin || !opts.expectedRpId) {
842
+ return {
843
+ ok: false,
844
+ reason: "WebAuthn receipts require expectedOrigin and expectedRpId — without them an assertion from any relying party would verify",
845
+ };
846
+ }
847
+ return verifyWebAuthnAssertion({
848
+ authenticatorData: witness.authenticatorData,
849
+ clientDataJSON: witness.clientDataJSON,
850
+ signature: witness.signature,
851
+ },
852
+ // The COSE key is parsed from the TRUSTED key, not from the receipt's copy.
853
+ () => coseKeyObject(Buffer.from(trustedKey, "base64")), canonicalPayload, {
854
+ expectedOrigins: [opts.expectedOrigin],
855
+ expectedRpId: opts.expectedRpId,
856
+ requireUserVerification: opts.requireUserVerification !== false,
857
+ allowCrossOrigin: opts.allowCrossOrigin === true,
858
+ });
859
+ }
860
+ /** A P-256 COSE_Key as a Node key object. Throws on anything else (see parseCosePublicKey). */
861
+ function coseKeyObject(cose) {
862
+ const { x, y } = parseCosePublicKey(cose);
863
+ return crypto.createPublicKey({ format: "jwk", key: { kty: "EC", crv: "P-256", x, y } });
864
+ }
865
+ /**
866
+ * The §4.4.5 checks on one assertion: the ceremony type, origin, cross-origin flag, challenge binding,
867
+ * RP ID hash, UP/UV flags and the signature over authenticatorData ‖ SHA-256(clientDataJSON). Shared
868
+ * by every receipt verifier (through verifyWitness) and by the standalone verifyWebAuthnWitness, so
869
+ * there is exactly one implementation of it in this package. `keyOf` is resolved only after the
870
+ * cheaper checks, in the same order as ever, so a refusal names the same reason it always did.
871
+ */
872
+ function verifyWebAuthnAssertion(parts, keyOf, signedPayload, expect) {
873
+ try {
874
+ const clientDataBuf = Buffer.from(parts.clientDataJSON, "base64");
875
+ const clientData = JSON.parse(clientDataBuf.toString("utf-8"));
876
+ // An assertion, not a registration: webauthn.create signs a different ceremony over the same
877
+ // challenge bytes, and must never be accepted as approval.
878
+ if (clientData.type !== "webauthn.get")
879
+ return { ok: false, reason: "clientDataJSON is not a webauthn.get assertion" };
880
+ if (typeof clientData.origin !== "string" || !expect.expectedOrigins.includes(clientData.origin))
881
+ return { ok: false, reason: "assertion origin does not match expectedOrigin" };
882
+ // See VerifyReceiptOptions.allowCrossOrigin: origin and rpIdHash both match for an embedded RP
883
+ // frame, so this flag is the only thing that separates "the human approved on our page" from
884
+ // "the human approved inside someone else's page".
885
+ if (clientData.crossOrigin === true && !expect.allowCrossOrigin)
886
+ return { ok: false, reason: "assertion was produced in a cross-origin frame (crossOrigin=true)" };
887
+ // WebAuthn L3 `topOrigin` names the top-level page when the ceremony ran in a frame. One that
888
+ // differs from `origin` is the same embedding as `crossOrigin: true`, reported another way, and
889
+ // is refused exactly like it (DIV §4.4.5 rule 5) — the rule the gateway applies at ingest with
890
+ // `webAuthnCrossOriginRefusal` (@intyga/mcp-schemas), so offline and online verdicts agree.
891
+ if (clientData.topOrigin !== undefined &&
892
+ clientData.topOrigin !== clientData.origin &&
893
+ !expect.allowCrossOrigin)
894
+ return {
895
+ ok: false,
896
+ reason: "assertion was produced in a frame embedded by another origin (topOrigin differs from origin)",
897
+ };
898
+ const expectedChallenge = base64url(signedPayload);
899
+ const clientChallengeClean = clientData.challenge
900
+ .replace(/\+/g, "-")
901
+ .replace(/\//g, "_")
902
+ .replace(/=/g, "");
903
+ if (clientChallengeClean !== expectedChallenge)
904
+ return { ok: false, reason: "clientDataJSON challenge does not match canonical payload" };
905
+ // authenticatorData is signed but was previously never INSPECTED: it carries the RP ID the
906
+ // credential answered for and whether the user was actually present/verified.
907
+ const authData = Buffer.from(parts.authenticatorData, "base64");
908
+ if (authData.length < 37)
909
+ return { ok: false, reason: "authenticatorData is too short" };
910
+ const rpIdHash = crypto.createHash("sha256").update(expect.expectedRpId, "utf8").digest();
911
+ if (!crypto.timingSafeEqual(authData.subarray(0, 32), rpIdHash))
912
+ return { ok: false, reason: "authenticatorData rpIdHash does not match expectedRpId" };
913
+ const flags = authData.readUInt8(32);
914
+ if (!(flags & AUTH_DATA_FLAG_UP))
915
+ return { ok: false, reason: "authenticatorData user-present flag is not set" };
916
+ if (expect.requireUserVerification && !(flags & AUTH_DATA_FLAG_UV))
917
+ return { ok: false, reason: "authenticatorData user-verified flag is not set" };
918
+ const keyObject = keyOf();
919
+ const clientDataHash = crypto.createHash("sha256").update(clientDataBuf).digest();
920
+ const signatureVerifyData = Buffer.concat([authData, clientDataHash]);
921
+ // Digest pinned explicitly: ES256 is P-256 + SHA-256 by definition, and leaving it implicit
922
+ // (`undefined`) makes the algorithm a property of the Node version rather than of this code.
923
+ const verified = crypto.verify("sha256", signatureVerifyData, { key: keyObject, dsaEncoding: "der" }, Buffer.from(parts.signature, "base64"));
924
+ return verified
925
+ ? { ok: true }
926
+ : { ok: false, reason: "WebAuthn signature does not verify against the trusted signer key" };
927
+ }
928
+ catch (err) {
929
+ const msg = err instanceof Error ? err.message : String(err);
930
+ return { ok: false, reason: `WebAuthn verification failed: ${msg}` };
931
+ }
932
+ }
933
+ /**
934
+ * Under a signed `requireHardwareKey`, a WebAuthn witness whose authenticatorData carries the Backup
935
+ * Eligible or Backup State flag cannot count (DIV §4.4.5 rule 6). Both flags are covered by the
936
+ * assertion signature, so a relying party can catch an issuer that let a synced passkey sign a
937
+ * hardware-pinned action. The converse is NOT evidence: BE=0 is the authenticator's own claim, not
938
+ * attestation — the model still comes only from enrollment records (§4.3.2).
939
+ *
940
+ * Only called for a witness that has already verified, so authenticatorData is at least 37 bytes.
941
+ */
942
+ function backupFlagsProblem(witness) {
943
+ const flags = Buffer.from(witness.authenticatorData ?? "", "base64").readUInt8(32);
944
+ if (!(flags & (AUTH_DATA_FLAG_BE | AUTH_DATA_FLAG_BS)))
945
+ return null;
946
+ return `signer ${witness.signerDid} used a backup-eligible (synced) passkey — authenticatorData BE/BS flag set — but the signed policy requires a hardware-backed WebAuthn credential`;
947
+ }
948
+ /**
949
+ * Verify ONE WebAuthn assertion on its own — the §4.4.5 checks every receipt verifier here applies
950
+ * to each WEBAUTHN witness, without a receipt around it: one approver of a quorum, a console step-up,
951
+ * a login approval, a row read back out of an audit ledger. Same implementation, not a copy.
952
+ *
953
+ * It answers only "did the holder of THIS key sign THIS payload, at THIS relying party, with the
954
+ * user present". It knows nothing of quorum, validity windows, payload type, the signed requirement
955
+ * or who the key belongs to: a caller verifying an approval must use verifyApprovalReceipt (or the
956
+ * delegation/agent-authority/platform verifiers), which also enforce those. Never throws.
957
+ */
958
+ export function verifyWebAuthnWitness(witness, expectation) {
959
+ const origins = typeof expectation?.expectedOrigin === "string"
960
+ ? [expectation.expectedOrigin]
961
+ : (expectation?.expectedOrigin ?? []);
962
+ // Fail closed, as verifyWitness does: with nothing pinned, an assertion from any RP would verify.
963
+ if (origins.length === 0 ||
964
+ !origins.every((o) => typeof o === "string" && o.length > 0) ||
965
+ typeof expectation.expectedRpId !== "string" ||
966
+ !expectation.expectedRpId)
967
+ return {
968
+ ok: false,
969
+ reason: "a WebAuthn witness requires expectedOrigin and expectedRpId — without them an assertion from any relying party would verify",
970
+ };
971
+ const fields = ["signedPayload", "publicKey", "authenticatorData", "clientDataJSON", "signature"];
972
+ for (const field of fields)
973
+ if (typeof witness?.[field] !== "string" || !witness[field])
974
+ return { ok: false, reason: `WebAuthn witness missing ${field}` };
975
+ return verifyWebAuthnAssertion(witness, () => webAuthnPublicKey(Buffer.from(witness.publicKey, "base64")), witness.signedPayload, {
976
+ expectedOrigins: origins,
977
+ expectedRpId: expectation.expectedRpId,
978
+ requireUserVerification: expectation.requireUserVerification !== false,
979
+ allowCrossOrigin: expectation.allowCrossOrigin === true,
980
+ });
981
+ }
982
+ /** A caller-supplied P-256 credential key: DER SPKI (a SEQUENCE, 0x30) or a COSE_Key (a CBOR map). */
983
+ function webAuthnPublicKey(der) {
984
+ if (der[0] !== 0x30)
985
+ return coseKeyObject(der);
986
+ const key = crypto.createPublicKey({ key: der, format: "der", type: "spki" });
987
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1")
988
+ throw new Error("public key is not a P-256 key");
989
+ return key;
990
+ }
991
+ /** Normalize a receipt to a witness list: `signatures` if present, else the single-signature fields. */
992
+ function witnessesOf(receipt) {
993
+ if (receipt.signatures?.length)
994
+ return receipt.signatures;
995
+ if (receipt.signerPublicKey && receipt.signature) {
996
+ return [
997
+ {
998
+ signerDid: receipt.signerDid ?? "",
999
+ signerPublicKey: receipt.signerPublicKey,
1000
+ signature: receipt.signature,
1001
+ sigAlg: receipt.sigAlg,
1002
+ authenticatorData: receipt.authenticatorData,
1003
+ clientDataJSON: receipt.clientDataJSON,
1004
+ },
1005
+ ];
1006
+ }
1007
+ return [];
1008
+ }
1009
+ /**
1010
+ * Independently verify an approval receipt against the instruction you are ABOUT to execute. Recomputes
1011
+ * the canonical payload from your params, confirms it byte-matches what was signed, and verifies the
1012
+ * human's P-256 or WebAuthn signature — with no Intyga secret.
1013
+ *
1014
+ * WHAT THIS PROVES: that enough APPROVERS YOU ALREADY TRUST (`expected.approvers`) signed exactly this
1015
+ * action, with exactly these params, for exactly the target and nonce you pass in `expected`; that the
1016
+ * number of distinct valid signatures meets the quorum recorded in the signed payload; that the
1017
+ * requester did not self-approve when the signed policy forbids it; and that the proof has not expired.
1018
+ *
1019
+ * THE SIGNED QUORUM IS THE SIGNERS' OWN STATEMENT. The signed `requirement` is authored by whoever
1020
+ * composed the bytes — so one approver (possibly the requester) can sign a 1-of-1 payload alone. Pass
1021
+ * `expected.requirement` (your own rule, see RequirementFloor) and a weaker signed requirement is
1022
+ * refused (DIV §5 step 3d). Without it, "quorum met" means only "the quorum the signers stated".
1023
+ *
1024
+ * THE TRUST ANCHOR IS NOT OPTIONAL. Verification uses the key you resolve for an approver, never the
1025
+ * `signerPublicKey` carried in the receipt. A receipt verified against its own embedded key proves
1026
+ * only internal consistency — anyone who can hand you a receipt could have minted the keypair.
1027
+ *
1028
+ * EXPIRY: the signed `expiresAt` is enforced fail-closed by default (±30s skew) — a lapsed proof is
1029
+ * rejected. Pass `{ allowExpired: true }` ONLY for post-hoc audit/forensic re-verification, where
1030
+ * confirming a signature that was valid AT THE TIME is the point.
1031
+ *
1032
+ * WHAT THIS DOES NOT PROVE: that the approval has not ALREADY BEEN USED within its validity window.
1033
+ * Expiry bounds how long a proof is valid, but single-use enforcement is separate and lives in the
1034
+ * gateway's /authorize/verify (which atomically marks the challenge CONSUMED) — this function is a
1035
+ * defense-in-depth companion to that call, not a replacement for it. If you verify offline and skip
1036
+ * the consume step, YOU must record redeemed nonces yourself; requiring `expected.nonce` here is what
1037
+ * makes that possible, since you cannot call this without having tracked the nonce you issued.
1038
+ *
1039
+ * Returns `{ ok: false, reason }` on any mismatch.
1040
+ */
1041
+ export function verifyApprovalReceipt(receipt, expected, opts = {}) {
1042
+ const version = parseVersion(receipt.canonicalPayload);
1043
+ if (version !== DIV_VERSION)
1044
+ return { ok: false, reason: `unsupported DIV payload version (${version || "unparseable"})` };
1045
+ // Which KIND of proof is this? The type is inside the signed bytes, so this is not spoofable
1046
+ // without breaking the signature — and the kinds rebuild through different canonical builders,
1047
+ // so none can ever be mistaken for another further down.
1048
+ const payloadType = parseField(receipt.canonicalPayload, "type");
1049
+ // A DELEGATION authorizes nothing (DIV §5a.5). It is refused here unconditionally — there is
1050
+ // deliberately NO option that would let one through, because a delegation that could authorize its
1051
+ // own action would be exactly the pre-signed bearer capability the design exists to avoid. Use
1052
+ // `verifyDelegation` to check one, then pass the result as `opts.delegation`.
1053
+ if (payloadType === DIV_DELEGATION_TYPE)
1054
+ return {
1055
+ ok: false,
1056
+ reason: "this is a delegation, which authorizes no action on its own — verify it with verifyDelegation and pass the result as { delegation }, together with an offline approval signed by the delegated operators",
1057
+ };
1058
+ // Same structural rule as the delegation branch above: an authority is governance evidence, and
1059
+ // the only way "it authorizes nothing" stays true is that this function can never say ok to one.
1060
+ if (payloadType === DIV_AGENT_AUTHORITY_TYPE)
1061
+ return {
1062
+ ok: false,
1063
+ reason: "this is an agent authority, which authorizes no action on its own — verify it with verifyAgentAuthority; execution still requires an approval receipt",
1064
+ };
1065
+ // A platform hash-only intent (DIV §5c) attests a DIGEST for an integrating platform's subject —
1066
+ // different signed shape, different display authority, different trust anchor. Refused here with
1067
+ // its own message so an integrator holding one is pointed at the right door.
1068
+ if (payloadType === DIV_PLATFORM_INTENT_TYPE)
1069
+ return {
1070
+ ok: false,
1071
+ reason: "this is a platform hash-only intent (DIV §5c) — verify it with verifyPlatformReceipt",
1072
+ };
1073
+ const offline = payloadType === DIV_OFFLINE_INTENT_TYPE;
1074
+ if (!offline && payloadType !== DIV_INTENT_TYPE)
1075
+ return { ok: false, reason: "payload is not a div-intent-verification" };
1076
+ if (offline && !opts.allowOffline)
1077
+ return {
1078
+ ok: false,
1079
+ reason: "this is an offline approval; pass { allowOffline: true } at the specific call site permitted to run under one",
1080
+ };
1081
+ // A delegation only ever substitutes the approver set for an OFFLINE proof. Accepting it against an
1082
+ // ordinary gateway-mediated receipt would silently replace the quorum the gateway enforced.
1083
+ if (opts.delegation && !offline)
1084
+ return { ok: false, reason: "a delegation can only substitute the approver set for an offline approval" };
1085
+ // Bind the receipt to the challenge the caller is redeeming, before anything else.
1086
+ if (parseNonce(receipt.canonicalPayload) !== expected.nonce)
1087
+ return { ok: false, reason: "receipt is for a different challenge" };
1088
+ // Rebuild the expected payload (DIV Local Payload Reconstruction). `target`, `actionType` and
1089
+ // `params` come from what YOU are about to execute; `display`, `requester`, `nonce` and `expiresAt`
1090
+ // are taken from the receipt and MUST byte-match the signed bytes below — a forged value changes the
1091
+ // string and fails the comparison, so trusting the receipt for them is not circular.
1092
+ if (!receipt.requester)
1093
+ return { ok: false, reason: "receipt missing requester" };
1094
+ const embeddedAgent = parseField(receipt.canonicalPayload, "agent");
1095
+ const agentIntent = embeddedAgent !== undefined;
1096
+ if (agentIntent && !expected.agentContext)
1097
+ return { ok: false, reason: "agent receipt requires independently asserted PEP context" };
1098
+ if (!agentIntent && expected.agentContext)
1099
+ return { ok: false, reason: "agent context was expected but is absent from the signed payload" };
1100
+ const expiresAt = parseField(receipt.canonicalPayload, agentIntent ? "exp" : "expiresAt");
1101
+ if (typeof expiresAt !== "string" || expiresAt.length === 0)
1102
+ return { ok: false, reason: "receipt missing expiration" };
1103
+ if (agentIntent) {
1104
+ const context = expected.agentContext;
1105
+ if (!context)
1106
+ return { ok: false, reason: "agent receipt requires independently asserted PEP context" };
1107
+ try {
1108
+ validateAgentIntentContext(context, expiresAt);
1109
+ }
1110
+ catch (error) {
1111
+ return {
1112
+ ok: false,
1113
+ reason: `invalid independently asserted agent context: ${error.message}`,
1114
+ };
1115
+ }
1116
+ const nowMs = (opts.asOf ?? new Date()).getTime();
1117
+ const skewMs = (opts.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS) * 1000;
1118
+ if (Date.parse(context.nbf) > nowMs + skewMs)
1119
+ return { ok: false, reason: "agent approval is not valid yet" };
1120
+ if (context.action.reversibility === "irreversible" && receipt.sigAlg === "AUTO_APPROVED")
1121
+ return { ok: false, reason: "irreversible agent action requires a human signature" };
1122
+ if (context.agent.delegatedBy) {
1123
+ if (!expected.requesterDid || !opts.agentAuthorityChain)
1124
+ return {
1125
+ ok: false,
1126
+ reason: "delegated agent receipt requires a trusted root-to-leaf authority chain",
1127
+ };
1128
+ const authority = verifyAgentDelegationChain(opts.agentAuthorityChain, {
1129
+ target: expected.target,
1130
+ actionType: expected.actionType,
1131
+ agentDid: expected.requesterDid,
1132
+ delegatedBy: context.agent.delegatedBy,
1133
+ }, opts);
1134
+ if (!authority.ok)
1135
+ return { ok: false, reason: authority.reason };
1136
+ }
1137
+ }
1138
+ // FAIL CLOSED on a missing target, like the nonce check above and the WebAuthn pinning below.
1139
+ // TypeScript makes `target` required, but this package is shipped to relying parties and is called
1140
+ // from plain JS too. Defaulting to the receipt's OWN target would have the receipt vouch for its own
1141
+ // scope — exactly the cross-service replay DIV Invariant 5 (Target Isolation) exists to stop.
1142
+ if (typeof expected.target !== "string" || expected.target.length === 0)
1143
+ return {
1144
+ ok: false,
1145
+ reason: "expected.target is required — it must be YOUR target identifier, asserted independently of the receipt (DIV Target Isolation)",
1146
+ };
1147
+ if (!expected.approvers)
1148
+ return {
1149
+ ok: false,
1150
+ reason: "expected.approvers is required — the Approver key MUST come from your own trust policy, never from the receipt (DIV Invariant 3)",
1151
+ };
1152
+ // The requirement is part of the SIGNED bytes, so a third party cannot alter it: a forged value
1153
+ // changes the string and fails the byte comparison below. It does NOT bind the signers themselves —
1154
+ // they authored it — which is why step 3d compares it against the caller's `expected.requirement`.
1155
+ const requirement = parseField(receipt.canonicalPayload, "requirement");
1156
+ if (!requirement || typeof requirement.requiredApprovals !== "number")
1157
+ return { ok: false, reason: "receipt payload is missing the signed approval requirement" };
1158
+ // DIV §4.3.2: an integer ≥ 1. Stated as its own refusal rather than clamped silently, because
1159
+ // §5 step 7 rejects unless the counted identities are AT LEAST this number — 0 is satisfied by
1160
+ // counting nothing, so an unenforced minimum attests an envelope with no valid witness signature.
1161
+ if (!isValidQuorum(requirement.requiredApprovals))
1162
+ return { ok: false, reason: INVALID_QUORUM_REASON };
1163
+ const signerClass = parseSignerClass(requirement);
1164
+ if (!signerClass.ok)
1165
+ return { ok: false, reason: signerClass.reason };
1166
+ // DIV §5 step 3d: the signed requirement is authored by the signers, so it is compared against the
1167
+ // caller's own policy BEFORE anything is counted toward it.
1168
+ const weaker = requirementFloorProblem(requirement, expected.requirement);
1169
+ if (weaker)
1170
+ return { ok: false, reason: weaker };
1171
+ // DIV §5-step-3c. Sits with the other signed-bytes structural gates, BEFORE Local Payload
1172
+ // Reconstruction. A non-null evidence value would also fail the byte comparison further down, but
1173
+ // it would surface as "target/params/actionType do not match what was approved" — a tampering
1174
+ // message for what is really an unsupported payload shape, which sends an operator hunting a
1175
+ // forgery that is not there.
1176
+ const evidence = parseEvidence(receipt.canonicalPayload);
1177
+ if (!evidence.ok)
1178
+ return { ok: false, reason: evidence.reason };
1179
+ // Offline proofs carry `challengedAt` so the validity WINDOW can be bounded here, not merely at
1180
+ // mint. A proof whose window exceeds the cap is refused even though its signature is perfectly
1181
+ // good — an offline relying party has no revocation channel, so the short window is the only one.
1182
+ let challengedAt = "";
1183
+ if (offline) {
1184
+ const raw = parseField(receipt.canonicalPayload, "challengedAt");
1185
+ if (typeof raw !== "string" || raw.length === 0)
1186
+ return { ok: false, reason: "offline proof is missing challengedAt" };
1187
+ challengedAt = raw;
1188
+ const challengedMs = parseRfc3339Ms(challengedAt);
1189
+ if (Number.isNaN(challengedMs))
1190
+ return { ok: false, reason: "challengedAt is not a valid RFC3339 timestamp" };
1191
+ // An unparseable `expiresAt` must be refused HERE rather than skipping the window cap and relying
1192
+ // on the expiry check below — that check is disabled by `allowExpired`, so the combination left
1193
+ // the cap unenforced on a proof whose window could not be computed at all.
1194
+ const expiryMs = parseRfc3339Ms(expiresAt);
1195
+ if (Number.isNaN(expiryMs))
1196
+ return { ok: false, reason: "expiresAt is not a valid RFC3339 timestamp" };
1197
+ const windowMinutes = (expiryMs - challengedMs) / 60_000;
1198
+ if (windowMinutes > MAX_OFFLINE_WINDOW_MINUTES)
1199
+ return {
1200
+ ok: false,
1201
+ reason: `offline window is ${windowMinutes.toFixed(1)} minutes, over the ${MAX_OFFLINE_WINDOW_MINUTES}-minute maximum`,
1202
+ };
1203
+ if (windowMinutes < 0)
1204
+ return { ok: false, reason: "offline proof expires before it was challenged" };
1205
+ // The cap above bounds the window's WIDTH; this bounds its POSITION (DIV §5a.3 rule 3). Without
1206
+ // it a proof challenged for a date years out, with a compliant 60-minute window, verifies today
1207
+ // and keeps verifying until that date — the pre-signed bearer capability §5a.1 rejects. NOT
1208
+ // gated on `allowExpired`: that override re-examines a proof that WAS valid and has lapsed, and
1209
+ // says nothing about one dated in the future.
1210
+ const nowMs = (opts.asOf ?? new Date()).getTime();
1211
+ const skewMs = (opts.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS) * 1000;
1212
+ if (challengedMs > nowMs + skewMs)
1213
+ return { ok: false, reason: "offline proof is challenged in the future (DIV §5a.3)" };
1214
+ // A hardware-key policy CANNOT be satisfied offline (DIV §5a.3 step 4, §5a.8). WebAuthn needs a
1215
+ // secure context and an RP ID that an offline signing surface will not match, so an offline
1216
+ // witness is always a bare key. Accepting the proof anyway would silently downgrade the very
1217
+ // policy the approver attested to, so it is refused instead — fail closed, and say why. A
1218
+ // non-empty authenticator-model allowlist is the same kind of policy: a bare key has no model at
1219
+ // all, and the enrollment record that would name one is not available offline (DIV §4.3.2).
1220
+ if (requiresHardwareCredential(requirement))
1221
+ return {
1222
+ ok: false,
1223
+ reason: "the signed policy requires a hardware-backed WebAuthn credential, which cannot be produced offline — this action cannot be approved out of band (DIV §5a.3)",
1224
+ };
1225
+ }
1226
+ // A delegation substitutes WHO may approve and HOW MANY, and nothing else (DIV §5a.6). Every
1227
+ // agreement check below is on the SIGNED bytes of both proofs, so neither can widen the other.
1228
+ let delegatedTo;
1229
+ let delegatedQuorum;
1230
+ if (opts.delegation) {
1231
+ const d = opts.delegation;
1232
+ // A successful seal check can be cached or precede a long signing ceremony. Its expiry must
1233
+ // still hold at USE time (DIV §5a.6), under the same clock/forensic policy as this approval.
1234
+ const delegationExpiryMs = parseRfc3339Ms(d.expiresAt);
1235
+ if (!Number.isFinite(delegationExpiryMs))
1236
+ return { ok: false, reason: "delegation expiresAt is not a valid RFC3339 timestamp" };
1237
+ const nowMs = (opts.asOf ?? new Date()).getTime();
1238
+ const skewMs = (opts.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS) * 1000;
1239
+ if (!opts.allowExpired && nowMs > delegationExpiryMs + skewMs)
1240
+ return {
1241
+ ok: false,
1242
+ reason: "delegation has expired (pass { allowExpired: true } for audit re-verification)",
1243
+ };
1244
+ // The delegation must be for the action actually being executed. `expected.*` is what the caller
1245
+ // is about to run, so comparing against it — not against the receipt — is what stops a delegation
1246
+ // for one action authorizing another.
1247
+ if (d.target !== expected.target)
1248
+ return { ok: false, reason: "the delegation was issued for a different target" };
1249
+ if (d.actionType !== expected.actionType)
1250
+ return { ok: false, reason: "the delegation was issued for a different actionType" };
1251
+ let delegationParams;
1252
+ let executingParams;
1253
+ try {
1254
+ delegationParams = stableStringify(d.params);
1255
+ executingParams = stableStringify(expected.params);
1256
+ }
1257
+ catch (err) {
1258
+ return { ok: false, reason: `params are not canonicalizable: ${err.message}` };
1259
+ }
1260
+ if (delegationParams !== executingParams)
1261
+ return { ok: false, reason: "the delegation was issued for different params" };
1262
+ // The offline payload's signed quorum must equal the delegated one, so the operators signed the
1263
+ // policy their signatures are being counted toward rather than a different one.
1264
+ if (requirement.requiredApprovals !== d.delegatedQuorum)
1265
+ return {
1266
+ ok: false,
1267
+ reason: `offline proof declares ${requirement.requiredApprovals} required approval(s) but the delegation delegates a quorum of ${d.delegatedQuorum}`,
1268
+ };
1269
+ delegatedTo = d.delegatedTo;
1270
+ delegatedQuorum = d.delegatedQuorum;
1271
+ }
1272
+ const rebuild = {
1273
+ target: expected.target,
1274
+ actionType: expected.actionType,
1275
+ display: receipt.actionDescription,
1276
+ params: expected.params,
1277
+ requester: receipt.requester,
1278
+ requirement: {
1279
+ requiredApprovals: requirement.requiredApprovals,
1280
+ requireHardwareKey: requirement.requireHardwareKey === true,
1281
+ allowedAaguids: Array.isArray(requirement.allowedAaguids) ? requirement.allowedAaguids : [],
1282
+ requesterCannotApprove: requirement.requesterCannotApprove === true,
1283
+ signerClass: signerClass.signerClass,
1284
+ },
1285
+ nonce: parseNonce(receipt.canonicalPayload),
1286
+ expiresAt,
1287
+ ...(agentIntent ? { agentContext: expected.agentContext } : {}),
1288
+ };
1289
+ let recomputed;
1290
+ try {
1291
+ recomputed = offline
1292
+ ? canonicalOfflineIntentPayload({ ...rebuild, challengedAt })
1293
+ : canonicalIntentPayload(rebuild);
1294
+ }
1295
+ catch (err) {
1296
+ // Almost always expected.params containing a Date/Map/class instance — say so, rather than
1297
+ // reporting it as a params mismatch and sending the caller hunting for a tampering that isn't there.
1298
+ return { ok: false, reason: `expected.params is not canonicalizable: ${err.message}` };
1299
+ }
1300
+ if (recomputed !== receipt.canonicalPayload)
1301
+ return {
1302
+ ok: false,
1303
+ reason: "target/params/actionType do not match what was approved",
1304
+ };
1305
+ // Expiration (DIV §5 step 8 / §6.2). Fail-closed by default; opt out only for audit re-verification.
1306
+ if (!opts.allowExpired) {
1307
+ const expiryMs = parseRfc3339Ms(expiresAt);
1308
+ if (Number.isNaN(expiryMs))
1309
+ return { ok: false, reason: "expiresAt is not a valid RFC3339 timestamp" };
1310
+ const nowMs = (opts.asOf ?? new Date()).getTime();
1311
+ const skewMs = (opts.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS) * 1000;
1312
+ if (nowMs > expiryMs + skewMs)
1313
+ return {
1314
+ ok: false,
1315
+ reason: "proof has expired (pass { allowExpired: true } for audit re-verification)",
1316
+ };
1317
+ }
1318
+ // Optional: assert WHICH workload the approval was granted to.
1319
+ if (expected.requesterDid !== undefined) {
1320
+ if (receipt.requester?.did !== expected.requesterDid)
1321
+ return { ok: false, reason: "approval was requested by a different principal" };
1322
+ }
1323
+ // A policy AUTO_APPROVED receipt carries NO human signature — there is nothing to cryptographically
1324
+ // verify, and such a receipt is trivially forgeable. We therefore REFUSE to attest it by default
1325
+ // (so `if (!verify().ok) throw` correctly blocks unsigned approvals). A relying party that has
1326
+ // consciously accepted policy pre-approval must opt in with `allowAutoApproved: true`.
1327
+ //
1328
+ // An OFFLINE proof is never auto-approved: the entire point is that humans signed it out of band, so
1329
+ // an unsigned one is a contradiction and `allowAutoApproved` must not rescue it.
1330
+ if (receipt.sigAlg === "AUTO_APPROVED" && offline) {
1331
+ return {
1332
+ ok: false,
1333
+ autoApproved: true,
1334
+ reason: "an offline approval cannot be auto-approved — there is no human signature to verify",
1335
+ };
1336
+ }
1337
+ if (receipt.sigAlg === "AUTO_APPROVED") {
1338
+ return opts.allowAutoApproved
1339
+ ? { ok: true, autoApproved: true }
1340
+ : {
1341
+ ok: false,
1342
+ autoApproved: true,
1343
+ reason: "auto-approved by policy — no human signature to verify (pass { allowAutoApproved: true } to accept)",
1344
+ };
1345
+ }
1346
+ const witnesses = witnessesOf(receipt);
1347
+ if (witnesses.length === 0)
1348
+ return { ok: false, reason: "receipt missing signature material" };
1349
+ // The witness list is attacker-supplied and every entry costs an ECDSA verification per candidate
1350
+ // key. A real quorum is single digits; 20 000 witnesses measured at 3.6s of blocked event loop and
1351
+ // a 1.16 MB failure string, in the relying party's process, before the action it gates. Bound it.
1352
+ if (witnesses.length > MAX_WITNESSES) {
1353
+ return {
1354
+ ok: false,
1355
+ reason: `receipt carries ${witnesses.length} witnesses, above the ${MAX_WITNESSES} this verifier will process`,
1356
+ };
1357
+ }
1358
+ // Count DISTINCT approvers whose signature verifies under a key we independently trust. Distinct is
1359
+ // load-bearing: without it, N copies of one approver's signature would satisfy an N-of-M quorum.
1360
+ // A signed four-eyes rule cannot be enforced against a key-set anchor: PublicKeys mode never
1361
+ // authenticates signerDid, so "this signer is not the requester" is unverifiable. Decided BEFORE
1362
+ // the loop so the receipt is refused for the reason that actually applies — the caller's anchor is
1363
+ // the wrong shape for the signed policy, which is not a quorum shortfall. It used to sit inside
1364
+ // the loop, after a witness had matched, so a receipt where nothing matched reported "quorum not
1365
+ // met" instead. Go, Rust, Java and Python all decide it here.
1366
+ if (requirement.requiredApprovals > 1 && "publicKeys" in expected.approvers) {
1367
+ return { ok: false, reason: "multi-approver quorum requires a DID-mode trust anchor (DIV §5 step 3b)" };
1368
+ }
1369
+ if (requirement.requesterCannotApprove === true && "publicKeys" in expected.approvers) {
1370
+ return {
1371
+ ok: false,
1372
+ reason: "requesterCannotApprove requires a DID-mode trust anchor; key-only mode cannot authenticate requester identity",
1373
+ };
1374
+ }
1375
+ const verifiedSigners = new Set();
1376
+ const countedKeys = new Map();
1377
+ const failures = [];
1378
+ for (const witness of witnesses) {
1379
+ const candidates = candidateKeys(expected.approvers, witness, delegatedTo);
1380
+ if ("reason" in candidates) {
1381
+ failures.push(candidates.reason);
1382
+ continue;
1383
+ }
1384
+ // Try each trusted candidate; the one that verifies identifies the approver. In DID mode the
1385
+ // candidates are all keys held by that one DID, so a match still counts as a single approver.
1386
+ let matched = null;
1387
+ let matchedKey = "";
1388
+ let lastReason = "signature does not verify against any trusted approver key";
1389
+ for (const candidate of candidates.keys) {
1390
+ const attempt = verifyWitness(witness, candidate.key, receipt.canonicalPayload, opts);
1391
+ if (attempt.ok) {
1392
+ matched = candidate.identity;
1393
+ matchedKey = candidate.key;
1394
+ break;
1395
+ }
1396
+ lastReason = attempt.reason;
1397
+ }
1398
+ if (matched === null) {
1399
+ failures.push(lastReason);
1400
+ continue;
1401
+ }
1402
+ // A hardware-key policy is only partially checkable offline (see ApprovalRequirementAttestation):
1403
+ // a bare P-256 key carries no attestation at all, so it can never satisfy the requirement, while a
1404
+ // WebAuthn assertion is accepted without being able to prove the authenticator's model.
1405
+ if (requiresHardwareCredential(requirement) && witness.sigAlg !== "WEBAUTHN") {
1406
+ failures.push(`signer ${witness.signerDid} used a bare key, but the signed policy requires a hardware-backed WebAuthn credential`);
1407
+ continue;
1408
+ }
1409
+ // The assertion's signed BE/BS flags can prove a synced passkey signed (DIV §4.4.5 rule 6).
1410
+ const synced = requirement.requireHardwareKey === true ? backupFlagsProblem(witness) : null;
1411
+ if (synced) {
1412
+ failures.push(synced);
1413
+ continue;
1414
+ }
1415
+ // Four-eyes, verified offline against the requester in the same signed payload.
1416
+ if (requirement.requesterCannotApprove === true && witness.signerDid === receipt.requester.did) {
1417
+ failures.push(`four-eyes: requester ${witness.signerDid} cannot approve their own action`);
1418
+ continue;
1419
+ }
1420
+ const shared = sharedKeyProblem(countedKeys, matchedKey, matched);
1421
+ if (shared) {
1422
+ failures.push(shared);
1423
+ continue;
1424
+ }
1425
+ verifiedSigners.add(matched);
1426
+ }
1427
+ // Under a delegation the quorum is the DELEGATED one. It was already checked to equal the offline
1428
+ // payload's signed `requiredApprovals`, so this is the same number by a different route — stated
1429
+ // explicitly so the substitution is visible at the point it takes effect. Both numbers were
1430
+ // refused above unless they are integers ≥ 1, so no floor is applied here.
1431
+ const required = delegatedQuorum ?? requirement.requiredApprovals;
1432
+ if (verifiedSigners.size < required) {
1433
+ // Report the first few reasons only. Folding every failure into one string is what turned a long
1434
+ // witness list into a megabyte of error text; the leading reasons are the diagnostic ones anyway.
1435
+ const shown = failures.slice(0, MAX_REPORTED_FAILURES);
1436
+ const elided = failures.length - shown.length;
1437
+ const detail = shown.length > 0 ? ` (${shown.join("; ")}${elided > 0 ? `; +${elided} more` : ""})` : "";
1438
+ return {
1439
+ ok: false,
1440
+ reason: `quorum not met: ${verifiedSigners.size} of ${required} required approver signatures verified${detail}`,
1441
+ };
1442
+ }
1443
+ // Sorted, not insertion order: verify-go, verify-rust and sdk-python all return this sorted, and
1444
+ // Go's type even documents itself as mirroring this field. It is a RESULT, never signed bytes, so
1445
+ // ordering cannot affect a verdict — but a caller that logs or diffs it should not see four
1446
+ // different answers depending on which SDK produced them.
1447
+ return { ok: true, signers: [...verifiedSigners].sort() };
1448
+ }
1449
+ /**
1450
+ * Verify a PLATFORM HASH-ONLY receipt (DIV §5c.3). Deliberately a separate function:
1451
+ * `verifyApprovalReceipt` refuses the `div-platform-intent` type outright, and this function
1452
+ * refuses every other type, so neither proof kind can ever pass through the other's door.
1453
+ *
1454
+ * Every witness must be a WebAuthn assertion (this plane's subjects only ever sign with enrolled
1455
+ * passkeys on the platform's registered origin), so `opts.expectedOrigin` is REQUIRED and the RP ID
1456
+ * expectation comes from `expected.rpId`. `AUTO_APPROVED` is refused with no override — policy
1457
+ * pre-approval does not exist on this plane.
1458
+ */
1459
+ export function verifyPlatformReceipt(receipt, expected, opts = {}) {
1460
+ const version = parseVersion(receipt.canonicalPayload);
1461
+ if (version !== DIV_VERSION)
1462
+ return { ok: false, reason: `unsupported DIV payload version (${version || "unparseable"})` };
1463
+ const payloadType = parseField(receipt.canonicalPayload, "type");
1464
+ if (payloadType !== DIV_PLATFORM_INTENT_TYPE) {
1465
+ return {
1466
+ ok: false,
1467
+ reason: payloadType === DIV_INTENT_TYPE || payloadType === DIV_OFFLINE_INTENT_TYPE
1468
+ ? "this is an ordinary approval receipt — verify it with verifyApprovalReceipt"
1469
+ : "payload is not a div-platform-intent",
1470
+ };
1471
+ }
1472
+ if (parseNonce(receipt.canonicalPayload) !== expected.nonce)
1473
+ return { ok: false, reason: "receipt is for a different challenge" };
1474
+ if (!expected.approvers)
1475
+ return {
1476
+ ok: false,
1477
+ reason: "expected.approvers is required — the subject's key MUST come from your own trust policy, never from the receipt (DIV Invariant 3)",
1478
+ };
1479
+ // Refused rather than case-folded: two spellings of one digest would be two different signed byte
1480
+ // strings (the Merkle hex-case lesson, DIV §5c.2).
1481
+ if (typeof expected.payloadHash !== "string" || !/^[0-9a-f]{64}$/.test(expected.payloadHash))
1482
+ return {
1483
+ ok: false,
1484
+ reason: "expected.payloadHash must be the 64-character lowercase hex SHA-256 you recomputed yourself",
1485
+ };
1486
+ if (typeof expected.rpId !== "string" || expected.rpId.length === 0)
1487
+ return {
1488
+ ok: false,
1489
+ reason: "expected.rpId is required — it must be YOUR registered RP ID, asserted independently of the receipt",
1490
+ };
1491
+ // One rpId, used twice (signed bytes + rpIdHash). A conflicting override would silently verify
1492
+ // the assertion against a different RP than the bytes name.
1493
+ if (opts.expectedRpId !== undefined && opts.expectedRpId !== expected.rpId)
1494
+ return { ok: false, reason: "opts.expectedRpId conflicts with expected.rpId — pass the RP ID once" };
1495
+ // User verification is UNCONDITIONAL on this plane (DIV §5c.3): `requireUserVerification: false`
1496
+ // is an ordinary-receipt option and is overridden here, never honoured.
1497
+ const effOpts = {
1498
+ ...opts,
1499
+ expectedRpId: expected.rpId,
1500
+ requireUserVerification: true,
1501
+ };
1502
+ const signedAt = parseField(receipt.canonicalPayload, "signedAt");
1503
+ const expiresAt = parseField(receipt.canonicalPayload, "expiresAt");
1504
+ if (typeof signedAt !== "string" || signedAt.length === 0)
1505
+ return { ok: false, reason: "receipt missing signedAt" };
1506
+ if (typeof expiresAt !== "string" || expiresAt.length === 0)
1507
+ return { ok: false, reason: "receipt missing expiresAt" };
1508
+ const subject = parseField(receipt.canonicalPayload, "subject");
1509
+ const subjectExternalId = subject && typeof subject.externalId === "string" ? subject.externalId : null;
1510
+ if (!subjectExternalId)
1511
+ return { ok: false, reason: "receipt missing subject.externalId" };
1512
+ if (expected.subjectExternalId !== undefined && subjectExternalId !== expected.subjectExternalId)
1513
+ return { ok: false, reason: "receipt was signed by a different subject" };
1514
+ // Local Payload Reconstruction: digest, RP and nonce from YOUR state; signedAt/expiresAt/subject
1515
+ // from the signed bytes (a forged value changes the string and fails the comparison).
1516
+ let recomputed;
1517
+ try {
1518
+ recomputed = canonicalPlatformIntentPayload({
1519
+ payloadHash: expected.payloadHash,
1520
+ rpId: expected.rpId,
1521
+ subjectExternalId,
1522
+ signedAt,
1523
+ expiresAt,
1524
+ nonce: expected.nonce,
1525
+ });
1526
+ }
1527
+ catch (err) {
1528
+ return { ok: false, reason: `platform payload is not canonicalizable: ${err.message}` };
1529
+ }
1530
+ if (recomputed !== receipt.canonicalPayload)
1531
+ return { ok: false, reason: "payloadHash/rpId do not match what was signed" };
1532
+ // Timestamp sanity, then expiry — fail closed (DIV §6.2), opt out only for audit re-verification.
1533
+ const signedMs = parseRfc3339Ms(signedAt);
1534
+ const expiryMs = parseRfc3339Ms(expiresAt);
1535
+ if (Number.isNaN(signedMs))
1536
+ return { ok: false, reason: "signedAt is not a valid RFC3339 timestamp" };
1537
+ if (Number.isNaN(expiryMs))
1538
+ return { ok: false, reason: "expiresAt is not a valid RFC3339 timestamp" };
1539
+ if (expiryMs < signedMs)
1540
+ return { ok: false, reason: "receipt expires before it was signed" };
1541
+ const nowMs = (opts.asOf ?? new Date()).getTime();
1542
+ const skewMs = (opts.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS) * 1000;
1543
+ // Position bound, like the offline rule (§5a.3 rule 3): a challenge frozen in the future was not
1544
+ // live now, whatever its window. Not gated on allowExpired, which only re-examines lapsed proofs.
1545
+ if (signedMs > nowMs + skewMs)
1546
+ return { ok: false, reason: "receipt is signed in the future (DIV §5c.3)" };
1547
+ if (!opts.allowExpired && nowMs > expiryMs + skewMs)
1548
+ return {
1549
+ ok: false,
1550
+ reason: "proof has expired (pass { allowExpired: true } for audit re-verification)",
1551
+ };
1552
+ // No override exists on purpose: there is no policy pre-approval on this plane, so an unsigned
1553
+ // platform receipt is a contradiction, not a configuration.
1554
+ if (receipt.sigAlg === "AUTO_APPROVED")
1555
+ return {
1556
+ ok: false,
1557
+ reason: "a platform receipt cannot be auto-approved — there is no signature to verify",
1558
+ };
1559
+ const witnesses = witnessesOf(receipt);
1560
+ if (witnesses.length === 0)
1561
+ return { ok: false, reason: "receipt missing signature material" };
1562
+ if (witnesses.length > MAX_WITNESSES)
1563
+ return {
1564
+ ok: false,
1565
+ reason: `receipt carries ${witnesses.length} witnesses, above the ${MAX_WITNESSES} this verifier will process`,
1566
+ };
1567
+ const verifiedSigners = new Set();
1568
+ const countedKeys = new Map();
1569
+ const failures = [];
1570
+ for (const witness of witnesses) {
1571
+ // §5c.3: every witness is a WebAuthn assertion. A bare-key signature has no origin/RP binding,
1572
+ // which is the entire trust boundary of this plane — refuse it rather than verify less.
1573
+ if (witness.sigAlg !== "WEBAUTHN") {
1574
+ failures.push(`signer ${witness.signerDid} used a bare key; platform receipts are WebAuthn-only`);
1575
+ continue;
1576
+ }
1577
+ const candidates = candidateKeys(expected.approvers, witness);
1578
+ if ("reason" in candidates) {
1579
+ failures.push(candidates.reason);
1580
+ continue;
1581
+ }
1582
+ let matched = null;
1583
+ let matchedKey = "";
1584
+ let lastReason = "signature does not verify against any trusted subject key";
1585
+ for (const candidate of candidates.keys) {
1586
+ const attempt = verifyWitness(witness, candidate.key, receipt.canonicalPayload, effOpts);
1587
+ if (attempt.ok) {
1588
+ matched = candidate.identity;
1589
+ matchedKey = candidate.key;
1590
+ break;
1591
+ }
1592
+ lastReason = attempt.reason;
1593
+ }
1594
+ if (matched === null) {
1595
+ failures.push(lastReason);
1596
+ continue;
1597
+ }
1598
+ const shared = sharedKeyProblem(countedKeys, matchedKey, matched);
1599
+ if (shared) {
1600
+ failures.push(shared);
1601
+ continue;
1602
+ }
1603
+ verifiedSigners.add(matched);
1604
+ }
1605
+ if (verifiedSigners.size < 1) {
1606
+ const shown = failures.slice(0, MAX_REPORTED_FAILURES);
1607
+ const elided = failures.length - shown.length;
1608
+ const detail = shown.length > 0 ? ` (${shown.join("; ")}${elided > 0 ? `; +${elided} more` : ""})` : "";
1609
+ return { ok: false, reason: `no valid subject signature${detail}` };
1610
+ }
1611
+ return { ok: true, signers: [...verifiedSigners].sort() };
1612
+ }
1613
+ /**
1614
+ * Verify a DELEGATION (DIV §5a.6 step 1) — a statement, signed in advance by the ordinary quorum, that
1615
+ * names local operators who may approve one pre-declared action while the gateway is unreachable.
1616
+ *
1617
+ * Deliberately a SEPARATE function from `verifyApprovalReceipt`, which refuses this payload type
1618
+ * outright. A delegation authorizes nothing, and the only way to keep that true structurally is to
1619
+ * make it impossible to hand one to the approval verifier and get an `ok: true` back. What you get here
1620
+ * is a `VerifiedDelegation` — an input to a later approval check, never a substitute for one.
1621
+ *
1622
+ * `approvers` MUST be the ORDINARY approver set (from your trust bundle), not the delegated operators:
1623
+ * the point of the check is that the people entitled to approve this action are the ones who signed
1624
+ * away that entitlement.
1625
+ */
1626
+ export function verifyDelegation(receipt, expected, opts = {}) {
1627
+ const version = parseVersion(receipt.canonicalPayload);
1628
+ if (version !== DIV_VERSION)
1629
+ return { ok: false, reason: `unsupported DIV payload version (${version || "unparseable"})` };
1630
+ if (parseField(receipt.canonicalPayload, "type") !== DIV_DELEGATION_TYPE)
1631
+ return { ok: false, reason: "payload is not a div-delegation" };
1632
+ // DIV §4.4.6: a Delegation REQUIRES an identity-associating anchor and MUST be refused under a
1633
+ // key-set anchor — at seal verification too, not only when delegatedTo is enforced at use time.
1634
+ // The sealing quorum names PEOPLE; in publicKeys mode it would count credentials instead.
1635
+ if (expected.approvers && "publicKeys" in expected.approvers && expected.approvers.publicKeys)
1636
+ return {
1637
+ ok: false,
1638
+ reason: "a delegation requires a DID-mode trust anchor ({ dids, resolveKey }); a key-set anchor cannot associate identities (DIV §4.4.6)",
1639
+ };
1640
+ const delegatedTo = parseField(receipt.canonicalPayload, "delegatedTo");
1641
+ const delegatedQuorum = parseField(receipt.canonicalPayload, "delegatedQuorum");
1642
+ if (!Array.isArray(delegatedTo) || delegatedTo.some((d) => typeof d !== "string" || d.length === 0))
1643
+ return { ok: false, reason: "delegation is missing a valid delegatedTo set" };
1644
+ if (typeof delegatedQuorum !== "number" || !Number.isInteger(delegatedQuorum) || delegatedQuorum < 1)
1645
+ return { ok: false, reason: "delegation is missing a valid delegatedQuorum" };
1646
+ // Deduplicate before the size check: a delegatedTo listing one operator three times would otherwise
1647
+ // appear to support a 3-of-3 quorum that one person could satisfy alone.
1648
+ const distinctDelegates = [...new Set(delegatedTo)];
1649
+ if (distinctDelegates.length < delegatedQuorum)
1650
+ return {
1651
+ ok: false,
1652
+ reason: `delegation names ${distinctDelegates.length} distinct operator(s) but delegates a quorum of ${delegatedQuorum} — it can never be satisfied`,
1653
+ };
1654
+ const sealedAt = parseField(receipt.canonicalPayload, "sealedAt");
1655
+ const expiresAt = parseField(receipt.canonicalPayload, "expiresAt");
1656
+ if (typeof sealedAt !== "string" || sealedAt.length === 0)
1657
+ return { ok: false, reason: "delegation is missing sealedAt" };
1658
+ if (typeof expiresAt !== "string" || expiresAt.length === 0)
1659
+ return { ok: false, reason: "delegation is missing expiresAt" };
1660
+ const sealedMs = parseRfc3339Ms(sealedAt);
1661
+ const expiryMs = parseRfc3339Ms(expiresAt);
1662
+ if (Number.isNaN(sealedMs))
1663
+ return { ok: false, reason: "sealedAt is not a valid RFC3339 timestamp" };
1664
+ if (Number.isNaN(expiryMs))
1665
+ return { ok: false, reason: "expiresAt is not a valid RFC3339 timestamp" };
1666
+ const windowHours = (expiryMs - sealedMs) / 3_600_000;
1667
+ if (windowHours < 0)
1668
+ return { ok: false, reason: "delegation expires before it was sealed" };
1669
+ if (windowHours > MAX_DELEGATION_WINDOW_HOURS)
1670
+ return {
1671
+ ok: false,
1672
+ reason: `delegation window is ${windowHours.toFixed(1)} hours, over the ${MAX_DELEGATION_WINDOW_HOURS}-hour maximum`,
1673
+ };
1674
+ // Position, not just width (DIV §5a.6 step 1, mirroring §5a.3 rule 3). A forward-dated `sealedAt`
1675
+ // slides the 72-hour window arbitrarily far out, and §5a.8 names that cap as Delegation's ONLY
1676
+ // mitigation. Unconditional, like the offline mirror: `allowExpired` does not reach it.
1677
+ const nowMs = (opts.asOf ?? new Date()).getTime();
1678
+ const skewMs = (opts.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS) * 1000;
1679
+ if (sealedMs > nowMs + skewMs)
1680
+ return { ok: false, reason: "delegation is sealed in the future (DIV §5a.6)" };
1681
+ // Reconstruct and check the signature by delegating to the ordinary verifier. Building the expected
1682
+ // bytes here and comparing them ourselves would be a second implementation of the check that already
1683
+ // exists — and the one place the two could disagree is the place it matters most. The trick is that
1684
+ // the reconstruction needs the delegation-specific fields, which `verifyApprovalReceipt` will not
1685
+ // produce, so the byte comparison happens here and the CRYPTO happens there.
1686
+ const nonce = parseNonce(receipt.canonicalPayload);
1687
+ if (!receipt.requester)
1688
+ return { ok: false, reason: "delegation missing requester" };
1689
+ const requirement = parseField(receipt.canonicalPayload, "requirement");
1690
+ if (!requirement || typeof requirement.requiredApprovals !== "number")
1691
+ return { ok: false, reason: "delegation payload is missing the signed approval requirement" };
1692
+ if (!isValidQuorum(requirement.requiredApprovals))
1693
+ return { ok: false, reason: INVALID_QUORUM_REASON };
1694
+ const signerClass = parseSignerClass(requirement);
1695
+ if (!signerClass.ok)
1696
+ return { ok: false, reason: signerClass.reason };
1697
+ const weaker = requirementFloorProblem(requirement, expected.requirement);
1698
+ if (weaker)
1699
+ return { ok: false, reason: weaker };
1700
+ if (typeof expected.target !== "string" || expected.target.length === 0)
1701
+ return {
1702
+ ok: false,
1703
+ reason: "expected.target is required — it must be YOUR target identifier, asserted independently of the delegation (DIV Target Isolation)",
1704
+ };
1705
+ if (!expected.approvers)
1706
+ return {
1707
+ ok: false,
1708
+ reason: "expected.approvers is required — the delegating approvers MUST come from your own trust policy, never from the delegation (DIV Invariant 3)",
1709
+ };
1710
+ let recomputed;
1711
+ try {
1712
+ recomputed = canonicalDelegationPayload({
1713
+ target: expected.target,
1714
+ actionType: expected.actionType,
1715
+ display: receipt.actionDescription,
1716
+ params: expected.params,
1717
+ requester: receipt.requester,
1718
+ requirement: {
1719
+ requiredApprovals: requirement.requiredApprovals,
1720
+ requireHardwareKey: requirement.requireHardwareKey === true,
1721
+ allowedAaguids: Array.isArray(requirement.allowedAaguids) ? requirement.allowedAaguids : [],
1722
+ requesterCannotApprove: requirement.requesterCannotApprove === true,
1723
+ signerClass: signerClass.signerClass,
1724
+ },
1725
+ delegatedTo: delegatedTo,
1726
+ delegatedQuorum,
1727
+ nonce,
1728
+ sealedAt,
1729
+ expiresAt,
1730
+ });
1731
+ }
1732
+ catch (err) {
1733
+ return { ok: false, reason: `expected.params is not canonicalizable: ${err.message}` };
1734
+ }
1735
+ if (recomputed !== receipt.canonicalPayload)
1736
+ return { ok: false, reason: "target/params/actionType do not match what was delegated" };
1737
+ // Expiry, then the signatures and quorum. `allowExpired` is honoured for forensic re-verification,
1738
+ // exactly as on the approval path.
1739
+ if (!opts.allowExpired) {
1740
+ if (nowMs > expiryMs + skewMs)
1741
+ return {
1742
+ ok: false,
1743
+ reason: "delegation has expired (pass { allowExpired: true } for audit re-verification)",
1744
+ };
1745
+ }
1746
+ if (receipt.sigAlg === "AUTO_APPROVED")
1747
+ return {
1748
+ ok: false,
1749
+ reason: "a delegation cannot be auto-approved — delegating approval authority requires human signatures",
1750
+ };
1751
+ const witnesses = witnessesOf(receipt);
1752
+ if (witnesses.length === 0)
1753
+ return { ok: false, reason: "delegation missing signature material" };
1754
+ // Same resource bound as the approval path: signature verification is the expensive step, and a
1755
+ // delegation is verified in the same process, right before the same irreversible action. Go, Rust
1756
+ // and Python bound both paths; leaving this one open re-creates the measured 3.6s event-loop stall
1757
+ // one function over.
1758
+ if (witnesses.length > MAX_WITNESSES) {
1759
+ return {
1760
+ ok: false,
1761
+ reason: `delegation carries ${witnesses.length} witnesses, above the ${MAX_WITNESSES} this verifier will process`,
1762
+ };
1763
+ }
1764
+ // A signed four-eyes rule cannot be enforced against a key-set anchor: PublicKeys mode never
1765
+ // authenticates signerDid, so "this signer is not the requester" is unverifiable. Decided BEFORE
1766
+ // the loop so the receipt is refused for the reason that actually applies — the caller's anchor is
1767
+ // the wrong shape for the signed policy, which is not a quorum shortfall. It used to sit inside
1768
+ // the loop, after a witness had matched, so a receipt where nothing matched reported "quorum not
1769
+ // met" instead. Go, Rust, Java and Python all decide it here.
1770
+ if (requirement.requiredApprovals > 1 && "publicKeys" in expected.approvers) {
1771
+ return { ok: false, reason: "multi-approver quorum requires a DID-mode trust anchor (DIV §5 step 3b)" };
1772
+ }
1773
+ if (requirement.requesterCannotApprove === true && "publicKeys" in expected.approvers) {
1774
+ return {
1775
+ ok: false,
1776
+ reason: "requesterCannotApprove requires a DID-mode trust anchor; key-only mode cannot authenticate requester identity",
1777
+ };
1778
+ }
1779
+ const verifiedSigners = new Set();
1780
+ const countedKeys = new Map();
1781
+ const failures = [];
1782
+ for (const witness of witnesses) {
1783
+ const candidates = candidateKeys(expected.approvers, witness);
1784
+ if ("reason" in candidates) {
1785
+ failures.push(candidates.reason);
1786
+ continue;
1787
+ }
1788
+ let matched = null;
1789
+ let matchedKey = "";
1790
+ let lastReason = "signature does not verify against any trusted approver key";
1791
+ for (const candidate of candidates.keys) {
1792
+ const attempt = verifyWitness(witness, candidate.key, receipt.canonicalPayload, opts);
1793
+ if (attempt.ok) {
1794
+ matched = candidate.identity;
1795
+ matchedKey = candidate.key;
1796
+ break;
1797
+ }
1798
+ lastReason = attempt.reason;
1799
+ }
1800
+ if (matched === null) {
1801
+ failures.push(lastReason);
1802
+ continue;
1803
+ }
1804
+ if (requiresHardwareCredential(requirement) && witness.sigAlg !== "WEBAUTHN") {
1805
+ failures.push(`signer ${witness.signerDid} used a bare key, but the signed policy requires a hardware-backed WebAuthn credential`);
1806
+ continue;
1807
+ }
1808
+ // The assertion's signed BE/BS flags can prove a synced passkey signed (DIV §4.4.5 rule 6).
1809
+ const synced = requirement.requireHardwareKey === true ? backupFlagsProblem(witness) : null;
1810
+ if (synced) {
1811
+ failures.push(synced);
1812
+ continue;
1813
+ }
1814
+ if (requirement.requesterCannotApprove === true && witness.signerDid === receipt.requester.did) {
1815
+ failures.push(`four-eyes: requester ${witness.signerDid} cannot delegate to themselves`);
1816
+ continue;
1817
+ }
1818
+ const shared = sharedKeyProblem(countedKeys, matchedKey, matched);
1819
+ if (shared) {
1820
+ failures.push(shared);
1821
+ continue;
1822
+ }
1823
+ verifiedSigners.add(matched);
1824
+ }
1825
+ const required = requirement.requiredApprovals;
1826
+ if (verifiedSigners.size < required) {
1827
+ // Folded like the approval path: an attacker-shaped witness list must not be able to inflate the
1828
+ // reason string (the 1.16 MB error the approval path once produced).
1829
+ const shown = failures.slice(0, MAX_REPORTED_FAILURES);
1830
+ const elided = failures.length - shown.length;
1831
+ const detail = shown.length > 0 ? ` (${shown.join("; ")}${elided > 0 ? `; +${elided} more` : ""})` : "";
1832
+ return {
1833
+ ok: false,
1834
+ reason: `delegation quorum not met: ${verifiedSigners.size} of ${required} required approver signatures verified${detail}`,
1835
+ };
1836
+ }
1837
+ return {
1838
+ ok: true,
1839
+ delegation: {
1840
+ // The DEDUPLICATED set: this is what gets enforced against witness DIDs later, and a duplicate
1841
+ // entry must not create the illusion of a larger eligible pool.
1842
+ delegatedTo: distinctDelegates,
1843
+ delegatedQuorum,
1844
+ target: expected.target,
1845
+ actionType: expected.actionType,
1846
+ params: expected.params,
1847
+ nonce,
1848
+ signers: [...verifiedSigners].sort(), // sorted, as in the quorum path above
1849
+ expiresAt,
1850
+ },
1851
+ };
1852
+ }
1853
+ /**
1854
+ * Verify an AGENT AUTHORITY (DIV §5b) — a statement, sealed by a human quorum, of the standing scope
1855
+ * one agent may operate under.
1856
+ *
1857
+ * Deliberately a SEPARATE function from `verifyApprovalReceipt`, which refuses this payload type
1858
+ * outright — the same structural rule as delegations. What you get back is governance EVIDENCE:
1859
+ * "these named humans granted this agent this scope, and the grant was live at `asOf`". It is never
1860
+ * an approval; executing an action still requires an ordinary receipt.
1861
+ *
1862
+ * Two things the caller asserts and never reads from the artifact (DIV Invariant 3):
1863
+ * `expected.approvers` (the sealing quorum's keys, from your own trust policy) and
1864
+ * `expected.target` / `expected.agentDid` (what YOU are checking authority over). Revocation is
1865
+ * authoritative online only — an offline verifier sees validity, not revocation; treat a seal like
1866
+ * a certificate, not a bearer token.
1867
+ */
1868
+ export function verifyAgentAuthority(receipt, expected, opts = {}) {
1869
+ const version = parseVersion(receipt.canonicalPayload);
1870
+ if (version !== DIV_VERSION)
1871
+ return { ok: false, reason: `unsupported DIV payload version (${version || "unparseable"})` };
1872
+ if (parseField(receipt.canonicalPayload, "type") !== DIV_AGENT_AUTHORITY_TYPE)
1873
+ return { ok: false, reason: "payload is not a div-agent-authority" };
1874
+ const actionPatterns = parseField(receipt.canonicalPayload, "actionPatterns");
1875
+ const parentReceiptHash = parseField(receipt.canonicalPayload, "parentReceiptHash");
1876
+ if (parentReceiptHash !== null &&
1877
+ (typeof parentReceiptHash !== "string" || !AGENT_DIGEST.test(parentReceiptHash)))
1878
+ return { ok: false, reason: "authority is missing a valid parent receipt commitment" };
1879
+ if (!Array.isArray(actionPatterns) ||
1880
+ actionPatterns.length === 0 ||
1881
+ actionPatterns.some((p) => typeof p !== "string" || p.length === 0))
1882
+ return { ok: false, reason: "authority is missing a valid actionPatterns set" };
1883
+ const sealedAt = parseField(receipt.canonicalPayload, "sealedAt");
1884
+ const expiresAt = parseField(receipt.canonicalPayload, "expiresAt");
1885
+ if (typeof sealedAt !== "string" || sealedAt.length === 0)
1886
+ return { ok: false, reason: "authority is missing sealedAt" };
1887
+ if (typeof expiresAt !== "string" || expiresAt.length === 0)
1888
+ return { ok: false, reason: "authority is missing expiresAt" };
1889
+ const sealedMs = parseRfc3339Ms(sealedAt);
1890
+ const expiryMs = parseRfc3339Ms(expiresAt);
1891
+ if (Number.isNaN(sealedMs))
1892
+ return { ok: false, reason: "sealedAt is not a valid RFC3339 timestamp" };
1893
+ if (Number.isNaN(expiryMs))
1894
+ return { ok: false, reason: "expiresAt is not a valid RFC3339 timestamp" };
1895
+ // No 72-hour cap here, deliberately: that cap exists because a delegation pre-authorizes offline
1896
+ // APPROVAL and cannot be revoked at an offline relying party. An authority authorizes nothing and
1897
+ // is enforced (and revoked) online, so its window is deployment policy, not a verifier rule.
1898
+ if (expiryMs < sealedMs)
1899
+ return { ok: false, reason: "authority expires before it was sealed" };
1900
+ // The position rule still applies (DIV §5b.2): §5b.3's evidence claim is that the grant was live
1901
+ // at the evaluation time, and a seal dated after it was not. Unconditional, as in §5a.3 rule 3.
1902
+ const nowMs = (opts.asOf ?? new Date()).getTime();
1903
+ const skewMs = (opts.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS) * 1000;
1904
+ if (sealedMs > nowMs + skewMs)
1905
+ return { ok: false, reason: "authority is sealed in the future (DIV §5b.2)" };
1906
+ const nonce = parseNonce(receipt.canonicalPayload);
1907
+ if (!receipt.requester)
1908
+ return { ok: false, reason: "authority missing requester" };
1909
+ const requirement = parseField(receipt.canonicalPayload, "requirement");
1910
+ if (!requirement || typeof requirement.requiredApprovals !== "number")
1911
+ return { ok: false, reason: "authority payload is missing the signed approval requirement" };
1912
+ if (!isValidQuorum(requirement.requiredApprovals))
1913
+ return { ok: false, reason: INVALID_QUORUM_REASON };
1914
+ const signerClass = parseSignerClass(requirement);
1915
+ if (!signerClass.ok)
1916
+ return { ok: false, reason: signerClass.reason };
1917
+ const weaker = requirementFloorProblem(requirement, expected.requirement);
1918
+ if (weaker)
1919
+ return { ok: false, reason: weaker };
1920
+ if (typeof expected.target !== "string" || expected.target.length === 0)
1921
+ return {
1922
+ ok: false,
1923
+ reason: "expected.target is required — it must be YOUR target identifier, asserted independently of the authority (DIV Target Isolation)",
1924
+ };
1925
+ if (typeof expected.agentDid !== "string" || expected.agentDid.length === 0)
1926
+ return {
1927
+ ok: false,
1928
+ reason: "expected.agentDid is required — name the agent whose authority you are checking",
1929
+ };
1930
+ if (!expected.approvers)
1931
+ return {
1932
+ ok: false,
1933
+ reason: "expected.approvers is required — the sealing approvers MUST come from your own trust policy, never from the artifact (DIV Invariant 3)",
1934
+ };
1935
+ // Local Payload Reconstruction: the byte comparison pins target, agent DID and the pattern set at
1936
+ // once, so the crypto below never runs against bytes the caller has not re-derived.
1937
+ let recomputed;
1938
+ try {
1939
+ recomputed = canonicalAgentAuthorityPayload({
1940
+ target: expected.target,
1941
+ actionPatterns: actionPatterns,
1942
+ display: receipt.actionDescription,
1943
+ agent: { did: expected.agentDid },
1944
+ parentReceiptHash,
1945
+ requester: receipt.requester,
1946
+ requirement: {
1947
+ requiredApprovals: requirement.requiredApprovals,
1948
+ requireHardwareKey: requirement.requireHardwareKey === true,
1949
+ allowedAaguids: Array.isArray(requirement.allowedAaguids) ? requirement.allowedAaguids : [],
1950
+ requesterCannotApprove: requirement.requesterCannotApprove === true,
1951
+ signerClass: signerClass.signerClass,
1952
+ },
1953
+ nonce,
1954
+ sealedAt,
1955
+ expiresAt,
1956
+ });
1957
+ }
1958
+ catch (err) {
1959
+ return { ok: false, reason: `authority payload is not canonicalizable: ${err.message}` };
1960
+ }
1961
+ if (recomputed !== receipt.canonicalPayload)
1962
+ return { ok: false, reason: "target/agent/actionPatterns do not match what was sealed" };
1963
+ if (!opts.allowExpired) {
1964
+ if (nowMs > expiryMs + skewMs)
1965
+ return {
1966
+ ok: false,
1967
+ reason: "authority has expired (pass { allowExpired: true } for audit re-verification)",
1968
+ };
1969
+ }
1970
+ if (receipt.sigAlg === "AUTO_APPROVED")
1971
+ return {
1972
+ ok: false,
1973
+ reason: "an agent authority cannot be auto-approved — granting agent scope requires human signatures",
1974
+ };
1975
+ const witnesses = witnessesOf(receipt);
1976
+ if (witnesses.length === 0)
1977
+ return { ok: false, reason: "authority missing signature material" };
1978
+ if (witnesses.length > MAX_WITNESSES) {
1979
+ return {
1980
+ ok: false,
1981
+ reason: `authority carries ${witnesses.length} witnesses, above the ${MAX_WITNESSES} this verifier will process`,
1982
+ };
1983
+ }
1984
+ // A signed four-eyes rule cannot be enforced against a key-set anchor: PublicKeys mode never
1985
+ // authenticates signerDid, so "this signer is not the requester" is unverifiable. Decided BEFORE
1986
+ // the loop so the receipt is refused for the reason that actually applies — the caller's anchor is
1987
+ // the wrong shape for the signed policy, which is not a quorum shortfall. It used to sit inside
1988
+ // the loop, after a witness had matched, so a receipt where nothing matched reported "quorum not
1989
+ // met" instead. Go, Rust, Java and Python all decide it here.
1990
+ if (requirement.requiredApprovals > 1 && "publicKeys" in expected.approvers) {
1991
+ return { ok: false, reason: "multi-approver quorum requires a DID-mode trust anchor (DIV §5 step 3b)" };
1992
+ }
1993
+ if (requirement.requesterCannotApprove === true && "publicKeys" in expected.approvers) {
1994
+ return {
1995
+ ok: false,
1996
+ reason: "requesterCannotApprove requires a DID-mode trust anchor; key-only mode cannot authenticate requester identity",
1997
+ };
1998
+ }
1999
+ const verifiedSigners = new Set();
2000
+ const countedKeys = new Map();
2001
+ const failures = [];
2002
+ for (const witness of witnesses) {
2003
+ const candidates = candidateKeys(expected.approvers, witness);
2004
+ if ("reason" in candidates) {
2005
+ failures.push(candidates.reason);
2006
+ continue;
2007
+ }
2008
+ let matched = null;
2009
+ let matchedKey = "";
2010
+ let lastReason = "signature does not verify against any trusted approver key";
2011
+ for (const candidate of candidates.keys) {
2012
+ const attempt = verifyWitness(witness, candidate.key, receipt.canonicalPayload, opts);
2013
+ if (attempt.ok) {
2014
+ matched = candidate.identity;
2015
+ matchedKey = candidate.key;
2016
+ break;
2017
+ }
2018
+ lastReason = attempt.reason;
2019
+ }
2020
+ if (matched === null) {
2021
+ failures.push(lastReason);
2022
+ continue;
2023
+ }
2024
+ if (requiresHardwareCredential(requirement) && witness.sigAlg !== "WEBAUTHN") {
2025
+ failures.push(`signer ${witness.signerDid} used a bare key, but the signed policy requires a hardware-backed WebAuthn credential`);
2026
+ continue;
2027
+ }
2028
+ // The assertion's signed BE/BS flags can prove a synced passkey signed (DIV §4.4.5 rule 6).
2029
+ const synced = requirement.requireHardwareKey === true ? backupFlagsProblem(witness) : null;
2030
+ if (synced) {
2031
+ failures.push(synced);
2032
+ continue;
2033
+ }
2034
+ if (requirement.requesterCannotApprove === true && witness.signerDid === receipt.requester.did) {
2035
+ failures.push(`four-eyes: requester ${witness.signerDid} cannot seal their own request`);
2036
+ continue;
2037
+ }
2038
+ const shared = sharedKeyProblem(countedKeys, matchedKey, matched);
2039
+ if (shared) {
2040
+ failures.push(shared);
2041
+ continue;
2042
+ }
2043
+ verifiedSigners.add(matched);
2044
+ }
2045
+ const required = requirement.requiredApprovals;
2046
+ if (verifiedSigners.size < required) {
2047
+ const shown = failures.slice(0, MAX_REPORTED_FAILURES);
2048
+ const elided = failures.length - shown.length;
2049
+ const detail = shown.length > 0 ? ` (${shown.join("; ")}${elided > 0 ? `; +${elided} more` : ""})` : "";
2050
+ return {
2051
+ ok: false,
2052
+ reason: `authority sealing quorum not met: ${verifiedSigners.size} of ${required} required approver signatures verified${detail}`,
2053
+ };
2054
+ }
2055
+ return {
2056
+ ok: true,
2057
+ authority: {
2058
+ agentDid: expected.agentDid,
2059
+ target: expected.target,
2060
+ // Deduplicated + sorted: a duplicate pattern must not suggest a wider scope, and every port
2061
+ // that grows this surface later should report the same order.
2062
+ actionPatterns: [...new Set(actionPatterns)].sort(),
2063
+ parentReceiptHash,
2064
+ nonce,
2065
+ signers: [...verifiedSigners].sort(),
2066
+ sealedAt,
2067
+ expiresAt,
2068
+ },
2069
+ };
2070
+ }
2071
+ /** Hash the COMPLETE proof, including every witness. Hashing only the intent would permit a
2072
+ * never-approved pending intent to be used as a parent receipt. */
2073
+ export function agentReceiptDigest(receipt) {
2074
+ const witnesses = witnessesOf(receipt)
2075
+ // Absent fields project to JSON null (DIV §4.3.6). A DID-mode witness may legitimately omit
2076
+ // `signerPublicKey` (the key comes from the caller's anchor), and hashing `undefined` used to
2077
+ // throw out of every chain verifier that reached this digest.
2078
+ .map((w) => ({
2079
+ signerDid: w.signerDid ?? null,
2080
+ signerPublicKey: w.signerPublicKey ?? null,
2081
+ signature: w.signature ?? null,
2082
+ sigAlg: w.sigAlg ?? null,
2083
+ authenticatorData: w.authenticatorData ?? null,
2084
+ clientDataJSON: w.clientDataJSON ?? null,
2085
+ }))
2086
+ .sort((a, b) => {
2087
+ const left = stableStringify(a);
2088
+ const right = stableStringify(b);
2089
+ return left < right ? -1 : left > right ? 1 : 0;
2090
+ });
2091
+ const content = stableStringify({ canonicalPayload: receipt.canonicalPayload, witnesses });
2092
+ return `sha256:${crypto.createHash("sha256").update("intyga-agent-receipt-v1\0").update(content).digest("hex")}`;
2093
+ }
2094
+ /** Verify a root-to-leaf chain of human-sealed agent scopes. Each child commits the COMPLETE
2095
+ * parent receipt. A child's substring pattern denotes a subset only when it contains one of the
2096
+ * parent's patterns; this deliberately rejects scopes whose inclusion cannot be proved. */
2097
+ export function verifyAgentDelegationChain(chain, action, opts = {}) {
2098
+ if (chain.length < 2 || !AGENT_DIGEST.test(action.delegatedBy))
2099
+ return { ok: false, reason: "delegation requires a complete root-to-leaf authority chain" };
2100
+ let parent = null;
2101
+ let parentHash = null;
2102
+ for (let index = 0; index < chain.length; index++) {
2103
+ const link = chain[index];
2104
+ if (!link)
2105
+ return { ok: false, reason: "authority chain has a missing link" };
2106
+ const proof = verifyAgentAuthority(link.receipt, link.expected, opts);
2107
+ if (!proof.ok || !proof.authority)
2108
+ return { ok: false, reason: `authority link ${index + 1}: ${proof.reason}` };
2109
+ const authority = proof.authority;
2110
+ if (authority.target !== action.target)
2111
+ return { ok: false, reason: "delegated authority changes target" };
2112
+ if (authority.parentReceiptHash !== parentHash)
2113
+ return { ok: false, reason: "delegated authority has a missing or different parent receipt" };
2114
+ if (parent) {
2115
+ if (parseRfc3339Ms(authority.sealedAt) < parseRfc3339Ms(parent.sealedAt) ||
2116
+ parseRfc3339Ms(authority.expiresAt) > parseRfc3339Ms(parent.expiresAt))
2117
+ return { ok: false, reason: "child authority outlives or predates its parent" };
2118
+ const parentPatterns = parent.actionPatterns.map((pattern) => pattern.toLowerCase());
2119
+ if (authority.actionPatterns.some((child) => !parentPatterns.some((scope) => scope === "*" || child.toLowerCase().includes(scope))))
2120
+ return { ok: false, reason: "child authority escalates the parent's action scope" };
2121
+ }
2122
+ parent = authority;
2123
+ try {
2124
+ parentHash = agentReceiptDigest(link.receipt);
2125
+ }
2126
+ catch (err) {
2127
+ return {
2128
+ ok: false,
2129
+ reason: `authority link ${index + 1} cannot be digested: ${err.message}`,
2130
+ };
2131
+ }
2132
+ }
2133
+ if (parentHash !== action.delegatedBy || parent?.agentDid !== action.agentDid)
2134
+ return { ok: false, reason: "action does not name its delegated agent and leaf authority receipt" };
2135
+ if (!parent.actionPatterns.some((pattern) => pattern === "*" || action.actionType.toLowerCase().includes(pattern.toLowerCase())))
2136
+ return { ok: false, reason: "action falls outside the delegated authority" };
2137
+ return { ok: true };
2138
+ }
2139
+ /** RP-side commitment to the exact model/tool/prompt configuration handed to the agent runtime.
2140
+ * This is an RP assertion, not an integrity attestation. The PEP must recalculate it immediately
2141
+ * before execution and refuse any mismatch with the signed intent. Raw prompt text is never logged. */
2142
+ export function agentConfigDigest(config) {
2143
+ if (!config.model.provider ||
2144
+ !config.model.version ||
2145
+ config.tools.some((tool) => !tool.id || !tool.version || !AGENT_DIGEST.test(tool.schemaDigest)))
2146
+ throw new Error("agent config requires immutable model and tool identities");
2147
+ const tools = [...config.tools].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
2148
+ if (tools.some((tool, index) => index > 0 && tool.id === tools.at(index - 1)?.id))
2149
+ throw new Error("duplicate agent tool identity");
2150
+ const content = stableStringify({ model: config.model, tools, systemPrompt: config.systemPrompt });
2151
+ return `sha256:${crypto.createHash("sha256").update("intyga-agent-config-v1\0").update(content).digest("hex")}`;
2152
+ }
2153
+ /** Verify a complete ordered session bundle against a head pinned OUTSIDE the bundle. An
2154
+ * unanchored single branch cannot prove another branch was not withheld. */
2155
+ export function verifyAgentSessionChain(entries, trustedHead, opts = {}) {
2156
+ if (!AGENT_DIGEST.test(trustedHead) || entries.length === 0)
2157
+ return { ok: false, reason: "complete agent chain and independently trusted head are required" };
2158
+ let previous = null;
2159
+ let sessionId;
2160
+ let currency;
2161
+ let sum = 0n;
2162
+ let lastAggregate = null;
2163
+ const seen = new Set();
2164
+ for (let index = 0; index < entries.length; index++) {
2165
+ const entry = entries[index];
2166
+ if (!entry)
2167
+ return { ok: false, reason: "agent chain has a missing entry" };
2168
+ const { receipt, expected } = entry;
2169
+ const context = expected.agentContext;
2170
+ if (!context)
2171
+ return { ok: false, reason: "agent chain entry lacks independent PEP context" };
2172
+ const result = verifyApprovalReceipt(receipt, expected, { ...opts, allowAutoApproved: false });
2173
+ if (!result.ok)
2174
+ return { ok: false, reason: `agent chain entry ${index + 1}: ${result.reason}` };
2175
+ const { session, action } = context;
2176
+ lastAggregate = session.aggregate;
2177
+ if (sessionId === undefined)
2178
+ sessionId = session.id;
2179
+ if (session.id !== sessionId)
2180
+ return { ok: false, reason: "agent chain changes session identity" };
2181
+ if (BigInt(session.seq) !== BigInt(index + 1))
2182
+ return { ok: false, reason: "agent chain has a gap, duplicate, or forked sequence" };
2183
+ if (session.prev !== previous)
2184
+ return { ok: false, reason: "agent chain predecessor is missing or forked" };
2185
+ let digest;
2186
+ try {
2187
+ digest = agentReceiptDigest(receipt);
2188
+ }
2189
+ catch (err) {
2190
+ return {
2191
+ ok: false,
2192
+ reason: `agent chain entry ${index + 1} cannot be digested: ${err.message}`,
2193
+ };
2194
+ }
2195
+ if (seen.has(digest))
2196
+ return { ok: false, reason: "agent chain repeats a receipt" };
2197
+ seen.add(digest);
2198
+ previous = digest;
2199
+ if (action.amount) {
2200
+ if (currency === undefined)
2201
+ currency = action.amount.currency;
2202
+ if (action.amount.currency !== currency || session.aggregate?.currency !== currency)
2203
+ return { ok: false, reason: "agent chain changes currency" };
2204
+ sum += decimalToNanoUnits(action.amount.amount);
2205
+ if (decimalToNanoUnits(session.aggregate.amount) !== sum)
2206
+ return { ok: false, reason: "agent aggregate does not equal the sum of signed steps" };
2207
+ }
2208
+ else if (session.aggregate !== null || currency !== undefined) {
2209
+ return { ok: false, reason: "agent chain mixes monetary and non-monetary steps" };
2210
+ }
2211
+ }
2212
+ if (previous !== trustedHead)
2213
+ return { ok: false, reason: "agent chain does not reach the trusted head" };
2214
+ return { ok: true, aggregate: lastAggregate };
2215
+ }
2216
+ function decimalToNanoUnits(value) {
2217
+ const [integer, fraction = ""] = value.split(".");
2218
+ if (!integer)
2219
+ throw new Error("invalid decimal amount");
2220
+ return BigInt(integer) * 1000000000n + BigInt(fraction.padEnd(9, "0") || "0");
2221
+ }
2222
+ // ─── Audit ledger inclusion proofs ───────────────────────────────────────────
2223
+ // The other half of "inspect-it-yourself": confirm an audit event is committed to Intyga's append-only
2224
+ // Merkle log against an independently anchored daily root. Same zero-dependency, no-secret contract as
2225
+ // the approval-receipt verifier above. See docs/DEWP.md for the format and ledger/roots for the
2226
+ // published end-of-day roots. Surfaced on the CLI as `intyga audit-verify`.
2227
+ export { ALGORITHM_REGISTRY, AUDIT_PROFILE, BUNDLE_KIND, DEWP_PROTOCOL, DEWP_VERSION, deriveVerificationLevel, leafCountMismatch, verifyBundle, verifyEmbeddedSignature, } from "./ledger-bundle.js";
2228
+ export { EVIDENCE_BUNDLE_KIND, verifyEvidenceBundle, } from "./ledger-evidence.js";
2229
+ export { ANCHOR_ALGORITHMS, ANCHOR_CLOCK_SKEW_SECONDS, DEFAULT_MAX_ANCHOR_LAG_SECONDS, anchorDigest, anchorDigestHex, anchorPreimage, isWellFormedAnchor, parseAnchorTimestampMs, signAnchor, verifyAnchorQuorum, verifyAnchorSignature, } from "./ledger-anchor.js";
2230
+ export { chainHash, chainPreimage, GENESIS_PREV_CHAIN_HASH, verifyRootsChain, } from "./ledger-chain.js";
2231
+ export { parseRekorEvidence, rekorPayloadHashFor, verifyRekorAnchor, } from "./ledger-rekor.js";
2232
+ export { canonicalPreimage, leafHash } from "./ledger-leaf.js";
2233
+ export { hashLeaf, hashPair, merkleProof, merkleRoot, sha256Hex, verifyMerkleProof, } from "./ledger-merkle.js";
2234
+ export { verifyInclusionProof } from "./ledger-proof.js";
2235
+ export { verifyRfc3161Anchor, verifyRfc3161Timestamp, verifyRfc3161TimestampAsync, } from "./ledger-rfc3161.js";
2236
+ export { verifyAuditSignature, } from "./ledger-signature.js";