@uuaid/pillar 0.2.0-alpha.8 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOSTING-LIMITS.md +432 -0
- package/INTERACTION-CONVENTION.md +222 -1
- package/README.md +371 -17
- package/bin/carrier.mjs +7 -1
- package/bin/pillar-ask.mjs +1 -0
- package/bin/pillar-blob.mjs +139 -0
- package/bin/pillar-directory.mjs +232 -0
- package/bin/pillar-escrow.mjs +675 -0
- package/bin/pillar-responder.mjs +47 -1
- package/bin/pillar-seed.mjs +113 -13
- package/bin/pillar.mjs +345 -4
- package/package.json +30 -7
- package/src/blob/blob.mjs +510 -0
- package/src/blob/broker.mjs +129 -0
- package/src/blob/http.mjs +334 -0
- package/src/blob/store.mjs +55 -0
- 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/identity/tier.mjs +130 -0
- package/src/identity/trust.mjs +63 -0
- package/src/index.mjs +28 -2
- package/src/mailbox/mailbox.mjs +41 -0
- package/src/net/carrier-client.mjs +93 -14
- package/src/net/carrier-server.mjs +212 -15
- package/src/net/delegation.mjs +209 -0
- package/src/net/envelope.mjs +81 -0
- package/src/net/link-offer.mjs +107 -0
- package/src/net/refusals.mjs +126 -0
- package/src/net/seed-registry.mjs +467 -0
- package/src/net/seed-tier.mjs +185 -0
- package/src/node-check.mjs +32 -0
- package/src/observability.mjs +141 -0
- package/src/pillar.mjs +206 -17
- 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/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
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VDA-1 instruments (spec §6) — the observation side. One small function
|
|
3
|
+
* per domain; every path returns a full signed envelope via buildAttestation
|
|
4
|
+
* so verdict/coverage/reason discipline is enforced in one place.
|
|
5
|
+
*
|
|
6
|
+
* transfer -> delegated to TransferVerifier (rails; Phase 0)
|
|
7
|
+
* data-delivery -> recipient-signed delivery-ack (§6.2)
|
|
8
|
+
* message-delivery -> pillar mailbox chain-hashed logs (§6.3)
|
|
9
|
+
* media-availability -> fetch-and-hash probe (§6.4)
|
|
10
|
+
* fiat-settlement -> issuer artifact wrap, `issuer-attested` at best (§6.5)
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
14
|
+
import { Keychain } from "../identity/keychain.mjs";
|
|
15
|
+
import { normalizeClaim, buildAttestation } from "./core.mjs";
|
|
16
|
+
|
|
17
|
+
export const ACK_KIND = "delivery-ack";
|
|
18
|
+
|
|
19
|
+
/** sha256 hex of raw bytes (the contentHash every data/media claim carries). */
|
|
20
|
+
export function contentHashOf(bytes) {
|
|
21
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Recipient side: produce a signed delivery acknowledgment for received
|
|
26
|
+
* bytes. The ack is the INSTRUMENT for data-delivery claims — hold it with
|
|
27
|
+
* the content, hand it to whoever needs to attest delivery.
|
|
28
|
+
*/
|
|
29
|
+
export function makeDeliveryAck({ keychain, contentHash, from, to, envelopeId = null, now = null }) {
|
|
30
|
+
if (!/^[0-9a-f]{64}$/.test(String(contentHash ?? ""))) throw new Error("delivery-ack: contentHash must be sha256 hex");
|
|
31
|
+
const ack = { v: 1, kind: ACK_KIND, contentHash, from, to, envelopeId, at: now ?? new Date().toISOString() };
|
|
32
|
+
const ackSig = keychain.sign(Buffer.from(jcs(ack), "utf-8")).toString("hex");
|
|
33
|
+
return { ack, ackSig, recipientPubKey: keychain._identity.publicKeyHex };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function refuse(claimRaw, reason, keychain) {
|
|
37
|
+
// Refusals still normalize what they can so the id is stable when the
|
|
38
|
+
// claim is well-formed but the instrument declines; a malformed claim is
|
|
39
|
+
// replaced by the conformant `malformed` stub (spec §4).
|
|
40
|
+
const n = normalizeClaim(claimRaw ?? {});
|
|
41
|
+
const claim = n.ok ? n.claim : normalizeClaim({ kind: "malformed", originalKind: String(claimRaw?.kind ?? "unknown") }).claim;
|
|
42
|
+
return buildAttestation({
|
|
43
|
+
claim, verdict: "unsupported", reason,
|
|
44
|
+
coverage: "Verifier refused before consulting any instrument; this attestation observes nothing about the event.",
|
|
45
|
+
keychain,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Attest a data-delivery claim from a delivery-ack (spec §6.2).
|
|
51
|
+
* Key binding honesty: recipientPubKey in the CLAIM means "the claimant
|
|
52
|
+
* asserts this key is the recipient's"; `directory` (optional) upgrades the
|
|
53
|
+
* binding by resolving `to` at ack time. Coverage names which binding was
|
|
54
|
+
* used — a claim-supplied key proves possession of that key, not identity.
|
|
55
|
+
*/
|
|
56
|
+
export function attestDataDelivery({ claim: claimRaw, ack, ackSig, recipientPubKey = null, directory = null, keychain = null, binds = null }) {
|
|
57
|
+
const n = normalizeClaim({ ...claimRaw, kind: "data-delivery" });
|
|
58
|
+
if (!n.ok) return refuse(claimRaw, n.reason, keychain);
|
|
59
|
+
const claim = n.claim;
|
|
60
|
+
|
|
61
|
+
const directoryKey = directory ? directory.resolve(claim.to, ack?.at ?? null) : null;
|
|
62
|
+
const key = directoryKey ?? claim.recipientPubKey ?? recipientPubKey;
|
|
63
|
+
const binding = directoryKey ? "directory-resolved" : "claim-supplied";
|
|
64
|
+
const coverage =
|
|
65
|
+
`Instrument: recipient-signed delivery-ack under a ${binding} Ed25519 key; proves the holder of that key ` +
|
|
66
|
+
`acknowledged bytes with this contentHash — not retention, use, or (for claim-supplied keys) recipient identity.`;
|
|
67
|
+
|
|
68
|
+
if (!key) {
|
|
69
|
+
return buildAttestation({ claim, verdict: "unsupported", reason: "no-recipient-key", coverage, keychain });
|
|
70
|
+
}
|
|
71
|
+
if (ack?.kind !== ACK_KIND || ack?.v !== 1) {
|
|
72
|
+
return buildAttestation({ claim, verdict: "unsupported", reason: "malformed-ack", coverage, keychain });
|
|
73
|
+
}
|
|
74
|
+
let sigOk = false;
|
|
75
|
+
try {
|
|
76
|
+
sigOk = Keychain.verifyDetached(key, Buffer.from(jcs(ack), "utf-8"), Buffer.from(String(ackSig ?? ""), "hex"));
|
|
77
|
+
} catch (_e) { sigOk = false; }
|
|
78
|
+
if (!sigOk) {
|
|
79
|
+
return buildAttestation({ claim, verdict: "failed", reason: "ack-signature-invalid", coverage, keychain });
|
|
80
|
+
}
|
|
81
|
+
const mismatch =
|
|
82
|
+
ack.contentHash !== claim.contentHash ? "content-hash-mismatch" :
|
|
83
|
+
ack.from !== claim.from ? "from-mismatch" :
|
|
84
|
+
ack.to !== claim.to ? "to-mismatch" :
|
|
85
|
+
claim.envelopeId != null && ack.envelopeId !== claim.envelopeId ? "envelope-id-mismatch" : null;
|
|
86
|
+
if (mismatch) {
|
|
87
|
+
return buildAttestation({ claim, verdict: "failed", reason: mismatch, coverage, keychain });
|
|
88
|
+
}
|
|
89
|
+
return buildAttestation({
|
|
90
|
+
claim, verdict: "verified",
|
|
91
|
+
evidence: { ack, ackSig, recipientPubKey: key, binding },
|
|
92
|
+
coverage, keychain, binds,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Attest a message-delivery claim from a pillar Mailbox (spec §6.3).
|
|
98
|
+
* `source` picks the instrument: "inbox" (recipient's own mailbox — proves
|
|
99
|
+
* the envelope was recorded as received) or "outbox-ack" (sender's mailbox
|
|
100
|
+
* — proves the sender HOLDS a delivery acknowledgment). Different facts;
|
|
101
|
+
* coverage names which. The mailbox passed in must belong to the named
|
|
102
|
+
* party or the instrument is blind.
|
|
103
|
+
*/
|
|
104
|
+
export function attestMessageDelivery({ claim: claimRaw, mailbox, mailboxOwner, source = "inbox", keychain = null }) {
|
|
105
|
+
const n = normalizeClaim({ ...claimRaw, kind: "message-delivery" });
|
|
106
|
+
if (!n.ok) return refuse(claimRaw, n.reason, keychain);
|
|
107
|
+
const claim = n.claim;
|
|
108
|
+
|
|
109
|
+
const coverageInbox =
|
|
110
|
+
"Instrument: the RECIPIENT's chain-hashed inbox log; sees only envelopes this mailbox recorded as received. " +
|
|
111
|
+
"Proves recording at the recipient, not that the sender's copy matches.";
|
|
112
|
+
const coverageOutbox =
|
|
113
|
+
"Instrument: the SENDER's chain-hashed outbox log with delivery ack; proves the sender holds an acknowledgment, " +
|
|
114
|
+
"not what the recipient's mailbox contains.";
|
|
115
|
+
|
|
116
|
+
if (source === "inbox") {
|
|
117
|
+
if (mailboxOwner !== claim.to) {
|
|
118
|
+
return buildAttestation({
|
|
119
|
+
claim, verdict: "instrument-blind", reason: "mailbox-not-recipients",
|
|
120
|
+
coverage: coverageInbox + " This mailbox does not belong to the claimed recipient, so it is structurally blind to the claim.",
|
|
121
|
+
keychain,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Full sight within the recipient's own inbox: absent = failed (§10.6 satisfied).
|
|
125
|
+
const row = mailbox.getInbound(claim.envelopeId);
|
|
126
|
+
if (!row) {
|
|
127
|
+
return buildAttestation({ claim, verdict: "failed", reason: "not-in-recipient-inbox", coverage: coverageInbox, keychain });
|
|
128
|
+
}
|
|
129
|
+
if (row.sender !== claim.from) {
|
|
130
|
+
return buildAttestation({ claim, verdict: "failed", reason: "sender-mismatch", coverage: coverageInbox, keychain });
|
|
131
|
+
}
|
|
132
|
+
if (claim.envelopeSha256 != null && row.envelope_sha256 !== claim.envelopeSha256) {
|
|
133
|
+
return buildAttestation({ claim, verdict: "failed", reason: "envelope-hash-mismatch", coverage: coverageInbox, keychain });
|
|
134
|
+
}
|
|
135
|
+
return buildAttestation({
|
|
136
|
+
claim, verdict: "verified",
|
|
137
|
+
evidence: { source: "inbox", seq: row.seq, envelopeSha256: row.envelope_sha256, chainHash: row.chain_hash, receivedAt: row.received_at },
|
|
138
|
+
coverage: coverageInbox, keychain,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (source === "outbox-ack") {
|
|
143
|
+
if (mailboxOwner !== claim.from) {
|
|
144
|
+
return buildAttestation({
|
|
145
|
+
claim, verdict: "instrument-blind", reason: "mailbox-not-senders",
|
|
146
|
+
coverage: coverageOutbox + " This mailbox does not belong to the claimed sender, so it is structurally blind to the claim.",
|
|
147
|
+
keychain,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const row = mailbox.getOutbound(claim.envelopeId);
|
|
151
|
+
if (!row) {
|
|
152
|
+
return buildAttestation({ claim, verdict: "failed", reason: "not-in-sender-outbox", coverage: coverageOutbox, keychain });
|
|
153
|
+
}
|
|
154
|
+
if (row.recipient !== claim.to) {
|
|
155
|
+
return buildAttestation({ claim, verdict: "failed", reason: "recipient-mismatch", coverage: coverageOutbox, keychain });
|
|
156
|
+
}
|
|
157
|
+
if (claim.envelopeSha256 != null && row.envelope_sha256 !== claim.envelopeSha256) {
|
|
158
|
+
return buildAttestation({ claim, verdict: "failed", reason: "envelope-hash-mismatch", coverage: coverageOutbox, keychain });
|
|
159
|
+
}
|
|
160
|
+
if (row.status !== "delivered" || !row.ack_sha256) {
|
|
161
|
+
return buildAttestation({ claim, verdict: "pending", reason: "no-delivery-ack-yet", coverage: coverageOutbox, keychain });
|
|
162
|
+
}
|
|
163
|
+
return buildAttestation({
|
|
164
|
+
claim, verdict: "verified",
|
|
165
|
+
evidence: { source: "outbox-ack", seq: row.seq, envelopeSha256: row.envelope_sha256, chainHash: row.chain_hash, ackSha256: row.ack_sha256, deliveredAt: row.delivered_at },
|
|
166
|
+
coverage: coverageOutbox, keychain,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return refuse(claimRaw, `unknown-source:${String(source)}`, keychain);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Attest media availability by fetch-and-hash (spec §6.4). Point-in-time by
|
|
175
|
+
* construction; unreachable is `pending`, only a hash mismatch of fetched
|
|
176
|
+
* bytes is `failed`. `fetcher(locator) -> Buffer` is injectable for tests;
|
|
177
|
+
* default uses global fetch.
|
|
178
|
+
*/
|
|
179
|
+
export async function attestMediaAvailability({ claim: claimRaw, fetcher = null, keychain = null, maxBytes = 64 * 1024 * 1024 }) {
|
|
180
|
+
const n = normalizeClaim({ ...claimRaw, kind: "media-availability" });
|
|
181
|
+
if (!n.ok) return refuse(claimRaw, n.reason, keychain);
|
|
182
|
+
const claim = n.claim;
|
|
183
|
+
const coverage =
|
|
184
|
+
"Instrument: point-in-time fetch-and-hash of the locator; proves what the locator served AT verifiedAt to this " +
|
|
185
|
+
"verifier's vantage — not availability at any other time, place, or path.";
|
|
186
|
+
|
|
187
|
+
let bytes;
|
|
188
|
+
try {
|
|
189
|
+
if (fetcher) {
|
|
190
|
+
bytes = await fetcher(claim.locator);
|
|
191
|
+
} else {
|
|
192
|
+
const res = await fetch(claim.locator, { redirect: "follow" });
|
|
193
|
+
if (!res.ok) return buildAttestation({ claim, verdict: "pending", reason: `locator-unreachable:http-${res.status}`, coverage, keychain });
|
|
194
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
195
|
+
bytes = buf;
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return buildAttestation({ claim, verdict: "pending", reason: `locator-unreachable:${String(e?.message ?? e).slice(0, 120)}`, coverage, keychain });
|
|
199
|
+
}
|
|
200
|
+
if (!Buffer.isBuffer(bytes)) bytes = Buffer.from(bytes);
|
|
201
|
+
if (bytes.length > maxBytes) {
|
|
202
|
+
return buildAttestation({ claim, verdict: "unsupported", reason: "content-exceeds-max-bytes", coverage, keychain });
|
|
203
|
+
}
|
|
204
|
+
const observedHash = contentHashOf(bytes);
|
|
205
|
+
if (observedHash !== claim.contentHash) {
|
|
206
|
+
return buildAttestation({
|
|
207
|
+
claim, verdict: "failed", reason: "content-hash-mismatch",
|
|
208
|
+
evidence: { fetchedAt: new Date().toISOString(), byteLength: bytes.length, observedHash },
|
|
209
|
+
coverage, keychain,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return buildAttestation({
|
|
213
|
+
claim, verdict: "verified",
|
|
214
|
+
evidence: { fetchedAt: new Date().toISOString(), byteLength: bytes.length, observedHash },
|
|
215
|
+
coverage, keychain,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Wrap a fiat issuer artifact (spec §6.5). `issuer-attested` at BEST, never
|
|
221
|
+
* `verified` — the verifier vouches for the artifact's presence and hash,
|
|
222
|
+
* not for settlement. artifactBytes is hashed here so the envelope pins the
|
|
223
|
+
* exact artifact the verdict rests on.
|
|
224
|
+
*/
|
|
225
|
+
export function attestFiatSettlement({ claim: claimRaw, artifactBytes, artifactKind, issuer, artifactExcerpt = null, keychain = null }) {
|
|
226
|
+
const n = normalizeClaim({ ...claimRaw, kind: "fiat-settlement" });
|
|
227
|
+
if (!n.ok) return refuse(claimRaw, n.reason, keychain);
|
|
228
|
+
const claim = n.claim;
|
|
229
|
+
const coverage =
|
|
230
|
+
"Instrument: an issuer-mediated artifact; the verifier attests the artifact's presence and hash only. " +
|
|
231
|
+
"Settlement itself is asserted by the issuer, not observed by this verifier — hence issuer-attested, never verified.";
|
|
232
|
+
if (!Buffer.isBuffer(artifactBytes) || artifactBytes.length === 0) {
|
|
233
|
+
return buildAttestation({ claim, verdict: "unsupported", reason: "no-issuer-artifact", coverage, keychain });
|
|
234
|
+
}
|
|
235
|
+
if (typeof artifactKind !== "string" || !artifactKind || typeof issuer !== "string" || !issuer) {
|
|
236
|
+
return buildAttestation({ claim, verdict: "unsupported", reason: "artifact-provenance-missing", coverage, keychain });
|
|
237
|
+
}
|
|
238
|
+
return buildAttestation({
|
|
239
|
+
claim, verdict: "issuer-attested",
|
|
240
|
+
evidence: {
|
|
241
|
+
artifactSha256: contentHashOf(artifactBytes), artifactKind, issuer,
|
|
242
|
+
...(artifactExcerpt ? { artifactExcerpt: String(artifactExcerpt).slice(0, 500) } : {}),
|
|
243
|
+
},
|
|
244
|
+
coverage, keychain,
|
|
245
|
+
});
|
|
246
|
+
}
|