@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/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) throw new Error("pillar not started");
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
- return { envelope, persisted, via: "direct", delivery: await this._deliverToPeer(envelope, book.peerId, book.multiaddr) };
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) throw e; // else fall through to carrier
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) throw new Error(`send: cannot resolve ${recipient}: no carrier configured and libp2p not started`);
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) { throw new Error(`send: cannot resolve ${recipient}: ${e.message}`); }
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
- return { envelope, persisted, via: "dht", delivery: await this._deliverToPeer(envelope, dhtAdvert.peerId, dhtAdvert.addrs?.[0]) };
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) { return; }
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) return; // replay: do not re-fire handlers
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) { console.error("pillar: envelope handler threw", 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
  }
@@ -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
+ }