@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/CHANGELOG.md +162 -0
- package/LICENSE +201 -0
- package/README.md +296 -2
- package/dist/approval-policy.d.ts +37 -0
- package/dist/approval-policy.js +149 -0
- package/dist/index.d.ts +803 -0
- package/dist/index.js +2236 -0
- package/dist/ledger-anchor.d.ts +195 -0
- package/dist/ledger-anchor.js +314 -0
- package/dist/ledger-bundle.d.ts +235 -0
- package/dist/ledger-bundle.js +419 -0
- package/dist/ledger-chain.d.ts +58 -0
- package/dist/ledger-chain.js +121 -0
- package/dist/ledger-evidence.d.ts +193 -0
- package/dist/ledger-evidence.js +613 -0
- package/dist/ledger-leaf.d.ts +25 -0
- package/dist/ledger-leaf.js +44 -0
- package/dist/ledger-merkle.d.ts +46 -0
- package/dist/ledger-merkle.js +183 -0
- package/dist/ledger-proof.d.ts +40 -0
- package/dist/ledger-proof.js +29 -0
- package/dist/ledger-rekor.d.ts +49 -0
- package/dist/ledger-rekor.js +187 -0
- package/dist/ledger-rfc3161.d.ts +25 -0
- package/dist/ledger-rfc3161.js +345 -0
- package/dist/ledger-signature.d.ts +16 -0
- package/dist/ledger-signature.js +61 -0
- package/package.json +57 -4
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";
|