@uuaid/pillar 0.2.0-alpha.11 → 0.2.0-alpha.13
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/INTERACTION-CONVENTION.md +24 -0
- package/bin/pillar-escrow.mjs +674 -0
- package/package.json +13 -3
- package/src/escrow/box.mjs +43 -0
- package/src/escrow/escrow-service.mjs +538 -0
- package/src/escrow/rails/evm-rail.mjs +427 -0
- package/src/escrow/rails/index.mjs +41 -0
- package/src/escrow/rails/zilligon-rail.mjs +73 -0
- package/src/escrow/terms.mjs +368 -0
- package/src/escrow/token-registry.mjs +231 -0
- package/src/escrow/verify.mjs +119 -0
- package/src/escrow/zilligon.mjs +266 -0
- package/src/identity/keychain.mjs +27 -10
- package/src/index.mjs +1 -1
- package/src/mailbox/mailbox.mjs +10 -0
- package/src/net/carrier-client.mjs +4 -2
- package/src/observability.mjs +141 -0
- package/src/pillar.mjs +56 -10
- package/src/vda/core.mjs +290 -0
- package/src/vda/directory.mjs +87 -0
- package/src/vda/instruments.mjs +246 -0
- package/src/vda/shims.mjs +77 -0
package/src/pillar.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { createTransport, PILLAR_PROTOCOL, uuaidToDhtKey, peerIdFromEd25519Hex }
|
|
|
22
22
|
import { seal, open, decrypt, envelopeSha, ENVELOPE_VERSION } from "./net/envelope.mjs";
|
|
23
23
|
import { Mailbox } from "./mailbox/mailbox.mjs";
|
|
24
24
|
import { CarrierClient } from "./net/carrier-client.mjs";
|
|
25
|
+
import { Observability } from "./observability.mjs";
|
|
25
26
|
|
|
26
27
|
const VALUE_STORE_PREFIX = "/uuaid/1.0";
|
|
27
28
|
|
|
@@ -39,6 +40,7 @@ export class Pillar {
|
|
|
39
40
|
cryptoInventory = { ed25519: "active", "ml-dsa-65": "reserved", "slh-dsa-128s": "reserved" },
|
|
40
41
|
trust = null, // { policy, allow, block } from loadTrustPolicy(); null = open
|
|
41
42
|
tierGrant = null, // signed grant object (tier.mjs) sent with carrier delivers
|
|
43
|
+
metrics = null, // Observability instance; a default is created internally
|
|
42
44
|
}) {
|
|
43
45
|
if (!keychain?._identity) throw new Error("Pillar: keychain must be loaded before construction");
|
|
44
46
|
this.keychain = keychain;
|
|
@@ -55,6 +57,7 @@ export class Pillar {
|
|
|
55
57
|
this.cryptoInventory = cryptoInventory;
|
|
56
58
|
this.node = null;
|
|
57
59
|
this.trust = trust;
|
|
60
|
+
this.metrics = metrics ?? new Observability();
|
|
58
61
|
this._envelopeHandlers = new Set();
|
|
59
62
|
this._openStreams = new Map(); // peerIdString -> pushable
|
|
60
63
|
}
|
|
@@ -238,7 +241,11 @@ export class Pillar {
|
|
|
238
241
|
* once at fleet-up time.
|
|
239
242
|
*/
|
|
240
243
|
async send({ recipient, recipientPublicKey, kind = "message", payload = {}, resolveTimeoutMs = 5000 }) {
|
|
241
|
-
if (!this.node && !this.carrierClient)
|
|
244
|
+
if (!this.node && !this.carrierClient) {
|
|
245
|
+
this.metrics.inc("send.error.not-started");
|
|
246
|
+
throw new Error("pillar not started");
|
|
247
|
+
}
|
|
248
|
+
const t0 = Date.now();
|
|
242
249
|
|
|
243
250
|
const book = this.addressBook?.get(recipient);
|
|
244
251
|
|
|
@@ -254,6 +261,7 @@ export class Pillar {
|
|
|
254
261
|
} catch (_e) { /* fall through to the error below */ }
|
|
255
262
|
}
|
|
256
263
|
if (!pubkey) {
|
|
264
|
+
this.metrics.inc("send.error.no-public-key");
|
|
257
265
|
throw new Error(`send: no public key known for ${recipient} — encryption is mandatory; register the peer (address book / fleet.json) or pass recipientPublicKey`);
|
|
258
266
|
}
|
|
259
267
|
|
|
@@ -263,9 +271,15 @@ export class Pillar {
|
|
|
263
271
|
// 1. Address book path: direct libp2p dial (lowest latency, no third party).
|
|
264
272
|
if (this.node && book?.peerId && book?.multiaddr) {
|
|
265
273
|
try {
|
|
266
|
-
|
|
274
|
+
const delivery = await this._deliverToPeer(envelope, book.peerId, book.multiaddr);
|
|
275
|
+
this.metrics.inc("send.via.direct");
|
|
276
|
+
this.metrics.observe("send.latencyMs.via.direct", Date.now() - t0);
|
|
277
|
+
return { envelope, persisted, via: "direct", delivery };
|
|
267
278
|
} catch (e) {
|
|
268
|
-
if (!this.carrierClient)
|
|
279
|
+
if (!this.carrierClient) {
|
|
280
|
+
this.metrics.inc("send.error.direct-delivery-failed");
|
|
281
|
+
throw e;
|
|
282
|
+
} // else fall through to carrier
|
|
269
283
|
}
|
|
270
284
|
}
|
|
271
285
|
|
|
@@ -273,16 +287,27 @@ export class Pillar {
|
|
|
273
287
|
if (this.carrierClient) {
|
|
274
288
|
const receipt = await this.carrierClient.deliver(envelope);
|
|
275
289
|
this.mailbox.markOutboundDelivered(envelope.id, envelopeSha(envelope));
|
|
290
|
+
this.metrics.inc("send.via.carrier");
|
|
291
|
+
this.metrics.observe("send.latencyMs.via.carrier", Date.now() - t0);
|
|
276
292
|
return { envelope, persisted, via: "carrier", delivery: receipt };
|
|
277
293
|
}
|
|
278
294
|
|
|
279
295
|
// 3. DHT path.
|
|
280
|
-
if (!this.node)
|
|
296
|
+
if (!this.node) {
|
|
297
|
+
this.metrics.inc("send.error.no-route");
|
|
298
|
+
throw new Error(`send: cannot resolve ${recipient}: no carrier configured and libp2p not started`);
|
|
299
|
+
}
|
|
281
300
|
if (!dhtAdvert) {
|
|
282
301
|
try { dhtAdvert = await this.resolveUuaid(recipient, { timeoutMs: resolveTimeoutMs }); }
|
|
283
|
-
catch (e) {
|
|
302
|
+
catch (e) {
|
|
303
|
+
this.metrics.inc("send.error.resolve-failed");
|
|
304
|
+
throw new Error(`send: cannot resolve ${recipient}: ${e.message}`);
|
|
305
|
+
}
|
|
284
306
|
}
|
|
285
|
-
|
|
307
|
+
const delivery = await this._deliverToPeer(envelope, dhtAdvert.peerId, dhtAdvert.addrs?.[0]);
|
|
308
|
+
this.metrics.inc("send.via.dht");
|
|
309
|
+
this.metrics.observe("send.latencyMs.via.dht", Date.now() - t0);
|
|
310
|
+
return { envelope, persisted, via: "dht", delivery };
|
|
286
311
|
}
|
|
287
312
|
|
|
288
313
|
/**
|
|
@@ -312,6 +337,9 @@ export class Pillar {
|
|
|
312
337
|
results.push({ member, ok: false, error: e.message });
|
|
313
338
|
}
|
|
314
339
|
}
|
|
340
|
+
this.metrics.inc("sendGroup");
|
|
341
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
342
|
+
if (failed) this.metrics.inc("sendGroup.failed", failed);
|
|
315
343
|
return { group, results };
|
|
316
344
|
}
|
|
317
345
|
|
|
@@ -331,8 +359,11 @@ export class Pillar {
|
|
|
331
359
|
onEnvelope: async (envelope, { carrier, seq }) => {
|
|
332
360
|
await this._receive(JSON.stringify(envelope), null);
|
|
333
361
|
this.mailbox.setCarrierCursor?.(carrier, seq);
|
|
362
|
+
this.metrics.inc(`carrier.poll.${carrier}`);
|
|
363
|
+
},
|
|
364
|
+
onError: (_e, meta) => {
|
|
365
|
+
this.metrics.inc(`carrier.poll.error.${meta?.carrier ?? "unknown"}`);
|
|
334
366
|
},
|
|
335
|
-
onError: (_e) => { /* transient poll errors are expected; backoff is in client */ },
|
|
336
367
|
});
|
|
337
368
|
return this._carrierPoller;
|
|
338
369
|
}
|
|
@@ -361,6 +392,7 @@ export class Pillar {
|
|
|
361
392
|
this.mailbox.setCarrierCursor?.(base, seq);
|
|
362
393
|
since = Math.max(since, seq);
|
|
363
394
|
total++;
|
|
395
|
+
this.metrics.inc(`carrier.drain.${base}`);
|
|
364
396
|
}
|
|
365
397
|
}
|
|
366
398
|
}
|
|
@@ -454,15 +486,20 @@ export class Pillar {
|
|
|
454
486
|
async _receive(rawLine, connection) {
|
|
455
487
|
let envelope;
|
|
456
488
|
try { envelope = JSON.parse(rawLine); }
|
|
457
|
-
catch (_e) {
|
|
489
|
+
catch (_e) {
|
|
490
|
+
this.metrics.inc("received.unparsable");
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
458
493
|
const verdict = open(envelope, { cryptoInventory: this.cryptoInventory });
|
|
459
494
|
if (!verdict.ok) {
|
|
495
|
+
this.metrics.inc(`received.rejected.${verdict.reason}`);
|
|
460
496
|
// eslint-disable-next-line no-console
|
|
461
497
|
console.warn("pillar: rejected envelope from", connection?.remotePeer?.toString(), verdict.reason);
|
|
462
498
|
return;
|
|
463
499
|
}
|
|
464
500
|
if (envelope.recipient !== this.uuaid) {
|
|
465
501
|
// Not for us. Store-and-forward would go here in v0.3.
|
|
502
|
+
this.metrics.inc("received.wrong-recipient");
|
|
466
503
|
return;
|
|
467
504
|
}
|
|
468
505
|
// E2E: decrypt the sealed payload. Ciphertext-at-rest: the mailbox stores
|
|
@@ -471,12 +508,17 @@ export class Pillar {
|
|
|
471
508
|
try {
|
|
472
509
|
payload = decrypt(this.keychain, envelope);
|
|
473
510
|
} catch (e) {
|
|
511
|
+
this.metrics.inc("received.decrypt-failed");
|
|
474
512
|
// eslint-disable-next-line no-console
|
|
475
513
|
console.warn("pillar: envelope decrypt failed (dropping)", envelope.id, e.message);
|
|
476
514
|
return;
|
|
477
515
|
}
|
|
478
516
|
const persisted = this.mailbox.appendInbound(envelope);
|
|
479
|
-
if (persisted.duplicate)
|
|
517
|
+
if (persisted.duplicate) {
|
|
518
|
+
this.metrics.inc("received.duplicate");
|
|
519
|
+
return; // replay: do not re-fire handlers
|
|
520
|
+
}
|
|
521
|
+
this.metrics.inc(`received.kind.${envelope.kind}`);
|
|
480
522
|
// Trust gate (recipient consent): quarantine-not-drop. The envelope is
|
|
481
523
|
// already persisted (chain-hashed evidence); a gated sender just never
|
|
482
524
|
// reaches handlers. An ack/reply passes the gate ONLY if its inReplyTo
|
|
@@ -491,6 +533,7 @@ export class Pillar {
|
|
|
491
533
|
const verdict = evaluateTrust(this.trust, envelope.sender, known);
|
|
492
534
|
if (!verdict.deliver) {
|
|
493
535
|
this.mailbox.markQuarantined(envelope.id, verdict.reason);
|
|
536
|
+
this.metrics.inc(`received.quarantined.${verdict.reason}`);
|
|
494
537
|
// eslint-disable-next-line no-console
|
|
495
538
|
console.warn(`pillar: quarantined ${envelope.id} from ${envelope.sender} (${verdict.reason})`);
|
|
496
539
|
return;
|
|
@@ -500,7 +543,10 @@ export class Pillar {
|
|
|
500
543
|
const view = { ...envelope, payload };
|
|
501
544
|
for (const h of this._envelopeHandlers) {
|
|
502
545
|
try { await h(view, persisted); }
|
|
503
|
-
catch (e) {
|
|
546
|
+
catch (e) {
|
|
547
|
+
this.metrics.inc("received.handler-error");
|
|
548
|
+
console.error("pillar: envelope handler threw", e);
|
|
549
|
+
}
|
|
504
550
|
}
|
|
505
551
|
}
|
|
506
552
|
}
|
package/src/vda/core.mjs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VDA-1 core — claim normalization, content-addressed ids, the signed
|
|
3
|
+
* attestation envelope, offline verification, composite receipts, quorum.
|
|
4
|
+
* Spec: docs/VDA-1-SPEC.md. No instrument logic lives here: this file is
|
|
5
|
+
* everything a CONSUMER needs to verify, and nothing an ISSUER needs to
|
|
6
|
+
* observe rails.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
10
|
+
import { Keychain } from "../identity/keychain.mjs";
|
|
11
|
+
|
|
12
|
+
export const VDA_V = 1;
|
|
13
|
+
export const ATTESTATION_KIND = "vda-attestation";
|
|
14
|
+
export const RECEIPT_KIND = "vda-receipt";
|
|
15
|
+
/** Legacy pre-VDA envelopes (accepted read-only, never emitted — spec §4). */
|
|
16
|
+
export const LEGACY_ATTESTATION_KIND = "transfer-attestation";
|
|
17
|
+
|
|
18
|
+
/** Closed verdict set (spec §5). */
|
|
19
|
+
export const VERDICTS = ["verified", "failed", "pending", "instrument-blind", "issuer-attested", "unsupported"];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Composite ordering (spec §7): any failed member fails the receipt; the
|
|
23
|
+
* composite verdict is the minimum over members.
|
|
24
|
+
*/
|
|
25
|
+
const VERDICT_RANK = { verified: 5, "issuer-attested": 4, pending: 3, "instrument-blind": 2, unsupported: 1, failed: 0 };
|
|
26
|
+
export function worseVerdict(a, b) {
|
|
27
|
+
return (VERDICT_RANK[a] ?? 0) <= (VERDICT_RANK[b] ?? 0) ? a : b;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEC_RE = /^[0-9]+$/;
|
|
31
|
+
const HEX_RE = /^0x[0-9a-fA-F]+$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Per-kind field allowlists (spec §3): normalization keeps EXACTLY these
|
|
35
|
+
* fields, drops everything else BEFORE hashing (unknown fields are never
|
|
36
|
+
* signed), and coerces amounts to decimal strings. EVM-style hex fields are
|
|
37
|
+
* lowercased so the same on-chain event always hashes to the same id
|
|
38
|
+
* regardless of the claimant's casing (id intersection would break
|
|
39
|
+
* otherwise).
|
|
40
|
+
*/
|
|
41
|
+
const CLAIM_SHAPES = {
|
|
42
|
+
// Refusal envelopes for claims that failed normalization: the claim is
|
|
43
|
+
// replaced by this well-formed stub so the refusal itself stays a
|
|
44
|
+
// conformant, offline-verifiable envelope (spec §4).
|
|
45
|
+
malformed: ["kind", "originalKind"],
|
|
46
|
+
transfer: ["kind", "rail", "chainId", "txId", "from", "to", "amount", "asset"],
|
|
47
|
+
"data-delivery": ["kind", "contentHash", "from", "to", "recipientPubKey", "envelopeId"],
|
|
48
|
+
"message-delivery": ["kind", "envelopeId", "from", "to", "envelopeSha256"],
|
|
49
|
+
"media-availability": ["kind", "locator", "contentHash", "publisher"],
|
|
50
|
+
"fiat-settlement": ["kind", "railName", "reference", "from", "to", "amount", "currency"],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function lcHex(v) {
|
|
54
|
+
return typeof v === "string" && HEX_RE.test(v) ? v.toLowerCase() : v;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Normalize a claim to its VDA-1 shape. Returns { ok, claim } or
|
|
59
|
+
* { ok:false, reason } — malformed claims are refusals (fail-closed,
|
|
60
|
+
* spec §10), never guesses.
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeClaim(raw) {
|
|
63
|
+
const kind = raw?.kind;
|
|
64
|
+
const shape = CLAIM_SHAPES[kind];
|
|
65
|
+
if (!shape) return { ok: false, reason: `unknown-claim-kind:${String(kind)}` };
|
|
66
|
+
const c = {};
|
|
67
|
+
for (const f of shape) c[f] = raw[f] === undefined ? null : raw[f];
|
|
68
|
+
c.kind = kind;
|
|
69
|
+
|
|
70
|
+
if (kind === "malformed") {
|
|
71
|
+
c.originalKind = String(c.originalKind ?? "unknown");
|
|
72
|
+
} else if (kind === "transfer") {
|
|
73
|
+
c.amount = String(c.amount ?? "");
|
|
74
|
+
if (!DEC_RE.test(c.amount)) return { ok: false, reason: "malformed-amount" };
|
|
75
|
+
if (typeof c.txId !== "string" || !c.txId) return { ok: false, reason: "malformed-txid" };
|
|
76
|
+
if (typeof c.to !== "string" || !c.to) return { ok: false, reason: "malformed-to" };
|
|
77
|
+
c.txId = lcHex(c.txId); c.to = lcHex(c.to); c.from = lcHex(c.from);
|
|
78
|
+
c.asset = typeof c.asset === "string" && c.asset ? (c.asset.startsWith("erc20:") ? "erc20:" + lcHex(c.asset.slice(6)) : c.asset) : "native";
|
|
79
|
+
c.chainId = c.chainId == null ? null : Number(c.chainId);
|
|
80
|
+
} else if (kind === "fiat-settlement") {
|
|
81
|
+
c.amount = String(c.amount ?? "");
|
|
82
|
+
if (!DEC_RE.test(c.amount)) return { ok: false, reason: "malformed-amount" };
|
|
83
|
+
if (typeof c.reference !== "string" || !c.reference) return { ok: false, reason: "malformed-reference" };
|
|
84
|
+
if (typeof c.to !== "string" || !c.to) return { ok: false, reason: "malformed-to" };
|
|
85
|
+
if (typeof c.currency !== "string" || !c.currency) return { ok: false, reason: "malformed-currency" };
|
|
86
|
+
} else if (kind === "data-delivery") {
|
|
87
|
+
if (!/^[0-9a-f]{64}$/.test(String(c.contentHash ?? ""))) return { ok: false, reason: "malformed-content-hash" };
|
|
88
|
+
if (typeof c.from !== "string" || !c.from || typeof c.to !== "string" || !c.to) return { ok: false, reason: "malformed-parties" };
|
|
89
|
+
} else if (kind === "message-delivery") {
|
|
90
|
+
if (typeof c.envelopeId !== "string" || !c.envelopeId) return { ok: false, reason: "malformed-envelope-id" };
|
|
91
|
+
if (typeof c.from !== "string" || !c.from || typeof c.to !== "string" || !c.to) return { ok: false, reason: "malformed-parties" };
|
|
92
|
+
} else if (kind === "media-availability") {
|
|
93
|
+
if (typeof c.locator !== "string" || !c.locator) return { ok: false, reason: "malformed-locator" };
|
|
94
|
+
if (!/^[0-9a-f]{64}$/.test(String(c.contentHash ?? ""))) return { ok: false, reason: "malformed-content-hash" };
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, claim: c };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* attestationId = FULL lowercase hex sha256(jcs(claim)) (spec §2).
|
|
101
|
+
* 256-bit ids everywhere the id is a join key (quorum, binds, dedup):
|
|
102
|
+
* truncated ids are birthday-collidable and this fleet was already burned by
|
|
103
|
+
* 16-hex collisions (AAUA handles; RedTeam spec-review B1, 2026-08-26).
|
|
104
|
+
*/
|
|
105
|
+
export function vdaClaimId(claim) {
|
|
106
|
+
return createHash("sha256").update(jcs(claim)).digest("hex");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Display-only short form (spec §2). NEVER a join key. */
|
|
110
|
+
export function shortId(attestationId) {
|
|
111
|
+
return String(attestationId ?? "").slice(0, 16);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Hash of a complete envelope INCLUDING sig — pins one exact member of a
|
|
116
|
+
* receipt (spec §7 manifest). attestationId identifies the CLAIM; this
|
|
117
|
+
* identifies the ATTESTATION.
|
|
118
|
+
*/
|
|
119
|
+
export function envelopeSha256(att) {
|
|
120
|
+
return createHash("sha256").update(jcs(att ?? {})).digest("hex");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Legacy pre-VDA id (16 hex) — read-only verification of old envelopes. */
|
|
124
|
+
export function legacyClaimId(claim) {
|
|
125
|
+
return createHash("sha256").update(jcs(claim)).digest("hex").slice(0, 16);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build (and sign) a VDA-1 envelope. `coverage` is mandatory; negatives
|
|
130
|
+
* without coverage are non-conformant (spec §5.1) and this builder refuses
|
|
131
|
+
* to produce them.
|
|
132
|
+
*/
|
|
133
|
+
export function buildAttestation({ claim, verdict, evidence = null, reason = null, coverage, binds = null, keychain = null, now = null }) {
|
|
134
|
+
if (!VERDICTS.includes(verdict)) throw new Error(`vda: unknown verdict ${verdict}`);
|
|
135
|
+
if (typeof coverage !== "string" || !coverage.trim()) throw new Error("vda: coverage statement required");
|
|
136
|
+
if (verdict !== "verified" && verdict !== "issuer-attested" && (typeof reason !== "string" || !reason)) {
|
|
137
|
+
throw new Error(`vda: verdict ${verdict} requires a named reason`);
|
|
138
|
+
}
|
|
139
|
+
const att = {
|
|
140
|
+
v: VDA_V, kind: ATTESTATION_KIND,
|
|
141
|
+
attestationId: vdaClaimId(claim), claim, verdict,
|
|
142
|
+
...(evidence ? { evidence } : {}),
|
|
143
|
+
...(reason ? { reason } : {}),
|
|
144
|
+
coverage,
|
|
145
|
+
...(Array.isArray(binds) && binds.length ? { binds: [...binds] } : {}),
|
|
146
|
+
verifier: keychain?._identity?.uuaid ?? null,
|
|
147
|
+
verifiedAt: now ?? new Date().toISOString(),
|
|
148
|
+
};
|
|
149
|
+
if (keychain) att.sig = keychain.sign(Buffer.from(jcs(att), "utf-8")).toString("hex");
|
|
150
|
+
return att;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Offline verification of one envelope (spec §4): recompute the id from the
|
|
155
|
+
* claim AND verify the signature. Nothing else may be required. Accepts
|
|
156
|
+
* legacy `transfer-attestation` envelopes read-only.
|
|
157
|
+
*/
|
|
158
|
+
export function verifyEnvelope(publicKeyHex, att) {
|
|
159
|
+
const kindOk = att?.v === VDA_V && (att?.kind === ATTESTATION_KIND || att?.kind === LEGACY_ATTESTATION_KIND);
|
|
160
|
+
let sigOk = false;
|
|
161
|
+
try {
|
|
162
|
+
const { sig, ...rest } = att ?? {};
|
|
163
|
+
sigOk = kindOk && typeof sig === "string" &&
|
|
164
|
+
Keychain.verifyDetached(publicKeyHex, Buffer.from(jcs(rest), "utf-8"), Buffer.from(sig, "hex"));
|
|
165
|
+
} catch (_e) { sigOk = false; }
|
|
166
|
+
let idOk = false;
|
|
167
|
+
let claimOk = true;
|
|
168
|
+
if (kindOk && att?.kind === ATTESTATION_KIND) {
|
|
169
|
+
// Spec §4: the embedded claim MUST equal its own normalization — an
|
|
170
|
+
// envelope smuggling extra/renamed claim fields is non-conformant even
|
|
171
|
+
// with a valid signature (RedTeam spec-review finding 4).
|
|
172
|
+
const n = normalizeClaim(att?.claim ?? {});
|
|
173
|
+
claimOk = n.ok && jcs(n.claim) === jcs(att.claim);
|
|
174
|
+
idOk = claimOk && att?.attestationId === vdaClaimId(att.claim);
|
|
175
|
+
} else if (kindOk) {
|
|
176
|
+
idOk = att?.attestationId === legacyClaimId(att?.claim ?? {});
|
|
177
|
+
}
|
|
178
|
+
return { ok: sigOk && idOk && claimOk, sigOk, idOk, claimOk };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Composite receipt verification (spec §7), fully offline.
|
|
183
|
+
* keyResolver(verifierUuaid, verifiedAtIso) -> publicKeyHex | null.
|
|
184
|
+
* Returns { ok, verdict, members } — ok only when every member verifies,
|
|
185
|
+
* every bind resolves inside the bundle, and no member failed.
|
|
186
|
+
*/
|
|
187
|
+
export function verifyReceipt(receipt, keyResolver) {
|
|
188
|
+
if (receipt?.v !== VDA_V || receipt?.kind !== RECEIPT_KIND || !Array.isArray(receipt?.attestations) || receipt.attestations.length === 0) {
|
|
189
|
+
return { ok: false, verdict: "unsupported", reason: "malformed-receipt", members: [] };
|
|
190
|
+
}
|
|
191
|
+
const present = new Set(receipt.attestations.map((a) => a?.attestationId));
|
|
192
|
+
// Spec §7 manifest: a receipt DECLARES its full member set, so presenting
|
|
193
|
+
// a subset (dropping the weaker leg to inflate the composite verdict) is
|
|
194
|
+
// detectable — RedTeam spec-review finding 3. Entries are ENVELOPE
|
|
195
|
+
// hashes, not claim ids: two verifiers attesting the same claim share an
|
|
196
|
+
// attestationId, so only the envelope hash pins exact members. Receipts
|
|
197
|
+
// without a manifest are legacy-tolerated; conformant issuers MUST
|
|
198
|
+
// include one.
|
|
199
|
+
if (Array.isArray(receipt.manifest)) {
|
|
200
|
+
const have = receipt.attestations.map((a) => envelopeSha256(a));
|
|
201
|
+
const haveSet = new Set(have);
|
|
202
|
+
const missing = receipt.manifest.filter((h) => !haveSet.has(h));
|
|
203
|
+
const undeclared = have.filter((h) => !receipt.manifest.includes(h));
|
|
204
|
+
if (missing.length || undeclared.length) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false, verdict: "failed", reason: "manifest-mismatch",
|
|
207
|
+
...(missing.length ? { missing } : {}), ...(undeclared.length ? { undeclared } : {}),
|
|
208
|
+
members: [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const members = [];
|
|
213
|
+
let verdict = "verified";
|
|
214
|
+
let ok = true;
|
|
215
|
+
for (const att of receipt.attestations) {
|
|
216
|
+
const key = typeof keyResolver === "function" ? keyResolver(att?.verifier ?? null, att?.verifiedAt ?? null) : null;
|
|
217
|
+
const env = key ? verifyEnvelope(key, att) : { ok: false, sigOk: false, idOk: att?.attestationId === vdaClaimId(att?.claim ?? {}) };
|
|
218
|
+
const badBinds = (att?.binds ?? []).filter((b) => !present.has(b));
|
|
219
|
+
const memberOk = env.ok && badBinds.length === 0;
|
|
220
|
+
members.push({
|
|
221
|
+
attestationId: att?.attestationId ?? null, verifier: att?.verifier ?? null,
|
|
222
|
+
verdict: att?.verdict ?? "unsupported", envelope: env,
|
|
223
|
+
...(key ? {} : { keyUnresolved: true }),
|
|
224
|
+
...(badBinds.length ? { unresolvedBinds: badBinds } : {}),
|
|
225
|
+
});
|
|
226
|
+
ok = ok && memberOk;
|
|
227
|
+
verdict = worseVerdict(verdict, memberOk ? (att?.verdict ?? "unsupported") : "failed");
|
|
228
|
+
}
|
|
229
|
+
return { ok: ok && verdict !== "failed", verdict, members };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* M-of-N quorum over independent attestations of ONE claim (spec §9):
|
|
234
|
+
* agreement is set intersection on the content-addressed id. Counts
|
|
235
|
+
* DISTINCT verifiers whose envelope verifies offline and whose verdict is
|
|
236
|
+
* `verified`. Members with unresolvable keys or bad signatures are listed
|
|
237
|
+
* but never counted.
|
|
238
|
+
*
|
|
239
|
+
* Independence caveat (spec §9, RedTeam spec-review finding 2): distinct
|
|
240
|
+
* KEYS are what this function can verify; distinct OPERATORS/ENDPOINTS are
|
|
241
|
+
* a deployment property the consumer must judge — the report surfaces each
|
|
242
|
+
* member's verifier so that judgment is possible, and `m` is honest about
|
|
243
|
+
* being a distinct-key count, nothing stronger. n = attestations examined.
|
|
244
|
+
*/
|
|
245
|
+
export function quorumReport(attestations, keyResolver, { require = 2 } = {}) {
|
|
246
|
+
const list = Array.isArray(attestations) ? attestations : [];
|
|
247
|
+
if (list.length === 0) return { ok: false, reason: "no-attestations", m: 0, n: 0, members: [] };
|
|
248
|
+
const id = list[0]?.attestationId ?? null;
|
|
249
|
+
const members = [];
|
|
250
|
+
const counted = new Set();
|
|
251
|
+
const uuaidOfKey = new Map();
|
|
252
|
+
// verdicts by verifier — VALID envelopes only, so a forged "contradiction"
|
|
253
|
+
// cannot knock an honest verifier out of the count.
|
|
254
|
+
const validVerdicts = new Map();
|
|
255
|
+
for (const att of list) {
|
|
256
|
+
const sameClaim = att?.attestationId === id && id != null;
|
|
257
|
+
const key = typeof keyResolver === "function" ? keyResolver(att?.verifier ?? null, att?.verifiedAt ?? null) : null;
|
|
258
|
+
const env = key && sameClaim ? verifyEnvelope(key, att) : { ok: false };
|
|
259
|
+
// dedupe key = the RESOLVED KEY (spec §9 "distinct keys"): two uuaids
|
|
260
|
+
// sharing one key are one voter (RedTeam round-2 rec 4)
|
|
261
|
+
if (env.ok && att?.verifier) {
|
|
262
|
+
uuaidOfKey.set(key, att.verifier);
|
|
263
|
+
const set = validVerdicts.get(key) ?? new Set();
|
|
264
|
+
set.add(att?.verdict ?? "unsupported");
|
|
265
|
+
validVerdicts.set(key, set);
|
|
266
|
+
}
|
|
267
|
+
const countable = sameClaim && env.ok === true && att?.verdict === "verified" && att?.verifier && !counted.has(key);
|
|
268
|
+
if (countable) counted.add(key);
|
|
269
|
+
members.push({
|
|
270
|
+
verifier: att?.verifier ?? null, verdict: att?.verdict ?? null,
|
|
271
|
+
counted: Boolean(countable),
|
|
272
|
+
...(sameClaim ? {} : { reason: "claim-mismatch" }),
|
|
273
|
+
...(key ? {} : { reason: "key-unresolved" }),
|
|
274
|
+
...(key && sameClaim && !env.ok ? { reason: "envelope-invalid" } : {}),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// Equivocation (spec §11): one key VALIDLY signing contradictory verdicts
|
|
278
|
+
// for the same claim. Excluded from the count and named — a signed
|
|
279
|
+
// contradiction is evidence about the VERIFIER.
|
|
280
|
+
const equivocatorKeys = [...validVerdicts.entries()].filter(([, s]) => s.size > 1).map(([k]) => k);
|
|
281
|
+
for (const k of equivocatorKeys) counted.delete(k);
|
|
282
|
+
|
|
283
|
+
const m = counted.size;
|
|
284
|
+
return {
|
|
285
|
+
ok: m >= require, attestationId: id, m, n: list.length, require,
|
|
286
|
+
verifiers: [...counted].map((k) => uuaidOfKey.get(k) ?? k),
|
|
287
|
+
...(equivocatorKeys.length ? { equivocators: equivocatorKeys.map((k) => uuaidOfKey.get(k) ?? k) } : {}),
|
|
288
|
+
members,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VDA-1 verifier key directory (spec §8) — append-only records
|
|
3
|
+
* { uuaid, publicKeyHex, validFrom, revokedAt } resolved by validity window
|
|
4
|
+
* at the attestation's verifiedAt. Content-addressed: directoryId =
|
|
5
|
+
* sha256(jcs(records)) so any two holders can compare directories by hash.
|
|
6
|
+
*
|
|
7
|
+
* Transport is out of band by design (file, pin, DHT profile); this class
|
|
8
|
+
* only enforces the RESOLUTION rules: window containment, append-only
|
|
9
|
+
* rotation, never-rewrite.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
15
|
+
|
|
16
|
+
export class VerifierDirectory {
|
|
17
|
+
constructor(records = []) {
|
|
18
|
+
this.records = [...records];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Load a directory, optionally pinning its content hash. The directory is
|
|
23
|
+
* the identity root (spec §8): an unpinned load trusts whatever file is at
|
|
24
|
+
* the path — pass expectedId (obtained out of band: a pin, a chain anchor,
|
|
25
|
+
* a config constant) to make substitution detectable (RedTeam spec-review
|
|
26
|
+
* finding 1).
|
|
27
|
+
*/
|
|
28
|
+
static fromFile(path, { expectedId = null } = {}) {
|
|
29
|
+
if (!existsSync(path)) {
|
|
30
|
+
if (expectedId) throw new Error(`directory: ${path} missing but an expectedId was pinned`);
|
|
31
|
+
return new VerifierDirectory([]);
|
|
32
|
+
}
|
|
33
|
+
const doc = JSON.parse(readFileSync(path, "utf-8"));
|
|
34
|
+
const d = new VerifierDirectory(Array.isArray(doc?.records) ? doc.records : []);
|
|
35
|
+
if (expectedId && d.id() !== String(expectedId).toLowerCase()) {
|
|
36
|
+
throw new Error(`directory: content hash ${d.id()} does not match pinned ${expectedId}`);
|
|
37
|
+
}
|
|
38
|
+
return d;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
toFile(path) {
|
|
42
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
43
|
+
writeFileSync(path, JSON.stringify({ v: 1, kind: "vda-verifier-directory", directoryId: this.id(), records: this.records }, null, 2) + "\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
id() {
|
|
47
|
+
return createHash("sha256").update(jcs(this.records)).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Append a key record. Rotation = append; rewriting an existing record is refused. */
|
|
51
|
+
add({ uuaid, publicKeyHex, validFrom, revokedAt = null }) {
|
|
52
|
+
if (typeof uuaid !== "string" || !uuaid) throw new Error("directory: uuaid required");
|
|
53
|
+
if (!/^[0-9a-fA-F]{64}$/.test(String(publicKeyHex ?? ""))) throw new Error("directory: publicKeyHex must be 32-byte hex");
|
|
54
|
+
if (typeof validFrom !== "string" || Number.isNaN(Date.parse(validFrom))) throw new Error("directory: validFrom must be RFC3339");
|
|
55
|
+
const dup = this.records.find((r) => r.uuaid === uuaid && r.publicKeyHex === publicKeyHex.toLowerCase() && r.validFrom === validFrom);
|
|
56
|
+
if (dup) throw new Error("directory: identical record exists (append-only; rotation adds a NEW record)");
|
|
57
|
+
this.records.push({ uuaid, publicKeyHex: publicKeyHex.toLowerCase(), validFrom, revokedAt });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Revoke = set revokedAt on the matching record (the one permitted mutation; never removes). */
|
|
61
|
+
revoke({ uuaid, publicKeyHex, revokedAt }) {
|
|
62
|
+
if (typeof revokedAt !== "string" || Number.isNaN(Date.parse(revokedAt))) throw new Error("directory: revokedAt must be RFC3339");
|
|
63
|
+
const rec = this.records.find((r) => r.uuaid === uuaid && r.publicKeyHex === String(publicKeyHex).toLowerCase() && r.revokedAt == null);
|
|
64
|
+
if (!rec) throw new Error("directory: no live record to revoke");
|
|
65
|
+
rec.revokedAt = revokedAt;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the key for (uuaid, atIso): a record whose [validFrom, revokedAt)
|
|
70
|
+
* window contains atIso. Multiple live windows -> newest validFrom wins
|
|
71
|
+
* (rotation overlap during cutover). Returns publicKeyHex or null.
|
|
72
|
+
*/
|
|
73
|
+
resolve(uuaid, atIso) {
|
|
74
|
+
const at = Date.parse(atIso ?? "");
|
|
75
|
+
if (Number.isNaN(at)) return null;
|
|
76
|
+
const live = this.records
|
|
77
|
+
.filter((r) => r.uuaid === uuaid)
|
|
78
|
+
.filter((r) => Date.parse(r.validFrom) <= at && (r.revokedAt == null || at < Date.parse(r.revokedAt)))
|
|
79
|
+
.sort((a, b) => Date.parse(b.validFrom) - Date.parse(a.validFrom));
|
|
80
|
+
return live[0]?.publicKeyHex ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** keyResolver closure for verifyReceipt / quorumReport. */
|
|
84
|
+
resolver() {
|
|
85
|
+
return (uuaid, atIso) => this.resolve(uuaid, atIso);
|
|
86
|
+
}
|
|
87
|
+
}
|