pazair-verify 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,8 @@ Trust between AI agents, checkable by anyone. Verify PazAIr receipts, Word Passe
4
4
  (the [PazAIr Trust Protocol](../SPEC.md)) in a few lines, without trusting PazAIr.
5
5
 
6
6
  Zero dependencies. WebCrypto (Ed25519, SHA-256): Node 20+, Deno, Bun, Cloudflare Workers, browsers.
7
+ To also check the post-quantum signatures (ML-DSA-65, FIPS 204) install `@noble/post-quantum` next to it; without it
8
+ they are reported as *not checked*, never as valid.
7
9
 
8
10
  ```sh
9
11
  npm install pazair-verify
@@ -35,6 +37,29 @@ const nonce = crypto.randomUUID();
35
37
  const r = await checkWordPass(url, { proof, nonce }); // r.checks.holder === true
36
38
  ```
37
39
 
40
+ ## Remember: "Forgets nothing. Holds nothing against you."
41
+
42
+ An issuer's daily roots form one chain, `head_d = sha256(head_{d-1} ‖ root_d)` from 64 zeros, every link signed with
43
+ Ed25519 and ML-DSA-65. Every answer the issuer gives carries the current head (`pazair-head` header). Recompute the
44
+ whole chain here, from the issuer's published roots, and compare it with what the issuer claims:
45
+
46
+ ```sh
47
+ npx pazair-verify remember https://pazair.kulalabs.ch
48
+ # https://pazair.kulalabs.ch: Remember holds. 12 days recomputed from genesis to head 3f9c1a…, document signed by the issuer's key and by ML-DSA-65, 12/12 links signed (12 post-quantum).
49
+
50
+ npx pazair-verify witness 2026-10-10:3f9c1a… # a head you kept from an earlier answer
51
+ # Your head of 2026-10-10 is what the chain recomputes to today; 2 day(s) anchored since.
52
+ ```
53
+
54
+ ```js
55
+ import { checkRemember, checkHead, recomputeChain } from 'pazair-verify';
56
+ const r = await checkRemember('https://pazair.kulalabs.ch'); // r.verdict: consistent | inconsistent | unreachable
57
+ const h = await checkHead('2026-10-10:3f9c…'); // the head you kept, against the chain you recompute
58
+ ```
59
+
60
+ Nothing here asks the issuer whether its chain is fine. The chain is fetched as data and recomputed; the issuer's
61
+ `witness` tool is for the issuer's own count of witnesses, this is yours. SPEC section 14 has the formulas.
62
+
38
63
  ## Lower level
39
64
 
40
65
  ```js
@@ -54,10 +79,14 @@ console.log(await verifyReceipt(receipt, { delivery }));
54
79
  | Function | Checks |
55
80
  |---|---|
56
81
  | `checkWordPass(url, { fetch?, keys?, proof?, nonce? })` | the pass at a URL with the issuer's own keys, the proof, the root on Stellar and in its Bitcoin block; a verdict and a sentence |
82
+ | `checkRemember(origin, { fetch? })` | Section 14: the Remember document's two signatures, the chain recomputed from genesis, the document's head against it, every link's two signatures; a verdict and a sentence |
83
+ | `checkHead("<day>:<head>", origin, { fetch? })` | a head you kept (`pazair-head`) against the chain you recompute; consistent, inconsistent, unreachable |
84
+ | `recomputeChain(days)` / `verifyLink(link, sig, { keys, pqPublicKey })` / `verifyRememberDocument(doc, { keys })` | the pieces: heads from roots with the first broken day, one link's Ed25519 and ML-DSA-65 signatures, the document's |
57
85
  | `bitcoinHasRoot(otsBase64, root, { fetch? })` | the .ots proof is for this root and ends in the Merkle root of the block it names (public block explorers) |
58
86
  | `verifyReceipt(receipt, { keys?, delivery? })` | Ed25519 signature; optionally that `delivery` is the one signed |
59
87
  | `verifyReceiptChain(top, { receipts, keys?, maxDepth? })` | Section 3.1: every receipt `top` was built on is present, signed, bought by its seller for that order; returns `{ valid, links, depth, total_minor, broken }` |
60
88
  | `verifyMandate(receipt, mandate, { keys?, end? })` | Section 3.2: the receipt was bought within the principal's mandate (agent, currency, cap per order, before its end); returns `{ valid, covers, reasons }` |
89
+ | `verifyAward(receipt, tender, award, { keys?, qa? })` | Section 3.3: the receipt was paid under this award for this tender (buyer, awarded seller, budget, price or milestone, deadline, questions published before the award); returns `{ valid, covers, reasons }` |
61
90
  | `receiptHash(receipt)` | The hash a parent receipt lists in `inputs` |
62
91
  | `verifyPass(anchored, { keys? })` | signature, Merkle proof into the day's root; returns the anchors to check on Bitcoin and Stellar |
63
92
  | `verifyProof(leaf, proof, root)` | a Merkle path |
package/bin/cli.js CHANGED
@@ -1,22 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  // npx pazair-verify <word pass url> [--json]: "May I see your Word Pass?", checked from the command line.
3
- // Exit codes: 0 trusted, 1 invalid (do not rely on it), 2 usage, 3 unreachable (could not check).
3
+ // npx pazair-verify remember [origin] [--json]: Remember, the issuer's chain of daily roots, recomputed here.
4
+ // npx pazair-verify witness <day:head> [origin] [--json]: a head you kept, against the chain you recompute.
5
+ // Exit codes: 0 trusted / consistent, 1 invalid / inconsistent (do not rely on it), 2 usage, 3 unreachable (could not check).
4
6
  import { readFileSync } from 'node:fs';
5
- import { checkWordPass } from '../src/index.js';
7
+ import { checkHead, checkRemember, checkWordPass } from '../src/index.js';
6
8
 
7
9
  const args = process.argv.slice(2);
8
10
  if (args.includes('-v') || args.includes('--version')) {
9
11
  console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version);
10
12
  process.exit(0);
11
13
  }
12
- const url = args.find((a) => !a.startsWith('-'));
14
+ const words = args.filter((a) => !a.startsWith('-'));
13
15
  const help = args.includes('-h') || args.includes('--help');
14
- if (!url || help) {
15
- (help ? console.log : console.error)('Usage: npx pazair-verify <word pass url> [--json]\nExample: npx pazair-verify https://pazair.kulalabs.ch/v1/agents/ag_xyz/pass');
16
- process.exit(help ? 0 : 2);
16
+ const json = args.includes('--json');
17
+ const usage = 'Usage: npx pazair-verify <word pass url> [--json]\n npx pazair-verify remember [origin] [--json]\n npx pazair-verify witness <day:head> [origin] [--json]\nExamples: npx pazair-verify https://pazair.kulalabs.ch/v1/agents/ag_xyz/pass\n npx pazair-verify remember https://pazair.kulalabs.ch\n npx pazair-verify witness 2026-10-10:3f9c… https://pazair.kulalabs.ch';
18
+ if (!words.length || help) { (help ? console.log : console.error)(usage); process.exit(help ? 0 : 2); }
19
+ const clean = (s) => String(s).replace(/[\u0000-\u001f\u007f-\u009f]/g, '?'); // nothing from the network rewrites your terminal
20
+ const codes = { consistent: 0, kept_its_word: 0, no_badge_yet: 0, signed_unanchored: 0, inconsistent: 1, invalid: 1, unreachable: 3 };
21
+
22
+ if (words[0] === 'remember') {
23
+ const r = await checkRemember(words[1] ?? 'https://pazair.kulalabs.ch');
24
+ console.log(clean(r.say));
25
+ if (json) console.log(JSON.stringify(r, null, 2));
26
+ process.exit(codes[r.verdict] ?? 1);
27
+ }
28
+ if (words[0] === 'witness') {
29
+ if (!words[1]) { console.error(usage); process.exit(2); }
30
+ const r = await checkHead(words[1], words[2] ?? 'https://pazair.kulalabs.ch');
31
+ console.log(clean(r.say));
32
+ if (json) console.log(JSON.stringify(r, null, 2));
33
+ process.exit(codes[r.verdict] ?? 1);
17
34
  }
18
- const r = await checkWordPass(url);
19
- const clean = (s) => s.replace(/[\u0000-\u001f\u007f-\u009f]/g, '?'); // a pass name cannot rewrite your terminal
35
+ const r = await checkWordPass(words[0]);
20
36
  console.log(clean(r.say));
21
- if (args.includes('--json')) console.log(JSON.stringify({ ...r, pass: undefined }, null, 2));
22
- process.exit({ kept_its_word: 0, no_badge_yet: 0, signed_unanchored: 0, invalid: 1, unreachable: 3 }[r.trust] ?? 1);
37
+ if (json) console.log(JSON.stringify({ ...r, pass: undefined }, null, 2));
38
+ process.exit(codes[r.trust] ?? 1);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pazair-verify",
3
- "version": "1.6.1",
4
- "description": "Verify Word Passes, receipts and Merkle proofs: \"May I see your Word Pass?\", checked by anyone, for any issuer. Zero dependencies.",
3
+ "version": "1.7.0",
4
+ "description": "Verify Word Passes, receipts, Merkle proofs and Remember (the chain of daily roots, Ed25519 + ML-DSA-65): checked by anyone, for any issuer, without trusting it. Zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./src/index.d.ts",
@@ -23,8 +23,20 @@
23
23
  "scripts": {
24
24
  "test": "node --test test/*.test.js"
25
25
  },
26
+ "devDependencies": {
27
+ "@noble/post-quantum": "^0.7.1"
28
+ },
29
+ "peerDependencies": {
30
+ "@noble/post-quantum": ">=0.7.0"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@noble/post-quantum": { "optional": true }
34
+ },
26
35
  "keywords": [
27
36
  "word-pass",
37
+ "remember",
38
+ "post-quantum",
39
+ "ml-dsa",
28
40
  "ai-agents",
29
41
  "agent-trust",
30
42
  "a2a",
package/src/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export function verifyHolderProof(proof: unknown, opts: { keys?: Jwk[]; agent: s
9
9
  export function verifyReceipt(receipt: Record<string, unknown>, opts?: { keys?: Jwk[]; delivery?: unknown }): Promise<{ valid: boolean; signature_valid: boolean; delivery_matches: boolean | null }>;
10
10
  export function wordOf(pass: Record<string, unknown>): { kept_pct: number | null; badge: 'word_kept_99' | 'word_kept_95' | null };
11
11
  export function verifyMandate(receipt: Record<string, unknown>, mandate: Record<string, unknown>, opts?: { keys?: Jwk[]; end?: Record<string, unknown> }): Promise<{ valid: boolean; covers: string[]; reasons: string[] }>;
12
+ export function verifyAward(receipt: Record<string, unknown>, tender: Record<string, unknown>, award: Record<string, unknown>, opts?: { keys?: Jwk[]; qa?: Record<string, unknown>[] }): Promise<{ valid: boolean; covers: string[]; reasons: string[] }>;
12
13
  export function receiptHash(receipt: Record<string, unknown>): Promise<string>;
13
14
  export function verifyReceiptChain(top: Record<string, unknown>, opts?: { receipts?: Record<string, unknown>[]; keys?: Jwk[]; maxDepth?: number }): Promise<{ valid: boolean; links: number; depth: number; total_minor: Record<string, number>; broken: string | null }>;
14
15
  export function leafOf(pass: Record<string, unknown>): Promise<string>;
@@ -26,3 +27,19 @@ export function bitcoinHasRoot(otsBase64: string, root: string, opts?: { fetch?:
26
27
  export function stellarHasRoot(tx: string, root: string, opts?: { fetch?: typeof fetch; horizon?: string; account?: string }): Promise<boolean | null>;
27
28
  export function checkWordPass(url: string, opts?: { fetch?: typeof fetch; keys?: Jwk[]; proof?: unknown; nonce?: string; aud?: string }): Promise<PassCheck>;
28
29
  export function sayPass(pass: Record<string, unknown>, by?: string): string;
30
+
31
+ // Remember (SPEC section 14): the chain of daily roots and its two signatures, checked without the issuer.
32
+ export const GENESIS: string;
33
+ export function headOf(prev: string, root: string): Promise<string>;
34
+ export type ChainHead = { day: string; root: string; prev: string; head: string; published_head: string | null; published_matches: boolean | null };
35
+ export function recomputeChain(days: { day: string; root: string; head?: string | null }[]): Promise<{ head: string; days: number; last_day: string | null; broken_at: string | null; heads: ChainHead[] }>;
36
+ export type HeadSig = { kid: string; ed25519: string; pq_kid: string; ml_dsa_65: string; over?: string };
37
+ /** ml_dsa_65 is null when @noble/post-quantum is not installed next to pazair-verify: not checked, never trusted. */
38
+ export function verifyLink(link: { day: string; prev: string; root: string; head: string }, sig: HeadSig | null, opts?: { keys?: Jwk[]; pqPublicKey?: string }): Promise<{ ed25519: boolean; ml_dsa_65: boolean | null; pq_kid_matches: boolean }>;
39
+ export function verifyRememberDocument(doc: Record<string, unknown>, opts?: { keys?: Jwk[] }): Promise<{ ed25519: boolean; ml_dsa_65: boolean | null; pq_kid_matches: boolean }>;
40
+ export function loadMlDsa(): Promise<unknown | null>;
41
+ export function verifyMlDsa(sigB64u: string, msg: Uint8Array, publicKeyB64u: string): Promise<boolean | null>;
42
+ export function pqKid(publicKeyB64u: string): Promise<string>;
43
+ export type RememberCheck = { verdict: 'consistent' | 'inconsistent' | 'unreachable'; origin: string; say: string; checks: { document: { ed25519: boolean; ml_dsa_65: boolean | null; pq_kid_matches: boolean; kid_in_issuer_keys: boolean }; chain: { days: number; recomputed_head: string; document_head: string | null; head_matches: boolean; broken_at: string | null; published_heads_match: boolean }; links: { with_signature: number; ed25519_valid: number; ml_dsa_65_valid: number | null; failures: string[] }; ml_dsa_65_checked: boolean } | null };
44
+ export function checkRemember(origin?: string, opts?: { fetch?: typeof fetch }): Promise<RememberCheck>;
45
+ export function checkHead(kept: string, origin?: string, opts?: { fetch?: typeof fetch }): Promise<{ verdict: 'consistent' | 'inconsistent' | 'unreachable'; day?: string; kept?: string; recomputed?: string | null; published?: string | null; days_since?: number; say: string }>;
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // pazair-verify: check PazAIr receipts, Word Passes and Merkle proofs without trusting PazAIr.
2
2
  // Zero dependencies. WebCrypto Ed25519 + SHA-256: Node 20+, Deno, Bun, Cloudflare Workers, modern browsers.
3
+ // Remember (SPEC section 14: the chain of daily roots, two signatures) lives in ./remember.js and is exported below.
3
4
  // Specification: https://github.com/Kula-Labs/pazair/blob/main/SPEC.md
4
5
 
5
6
  const enc = new TextEncoder();
@@ -123,6 +124,42 @@ export async function verifyMandate(receipt, mandate, { keys, end } = {}) {
123
124
  return { valid: reasons.length === 0, covers, reasons };
124
125
  }
125
126
 
127
+ /**
128
+ * Section 3.3: was this receipt paid under this award, for this tender? `qa` are the signed questions, if held.
129
+ * Returns { valid, covers, reasons }: covers lists what held, reasons what did not.
130
+ */
131
+ export async function verifyAward(receipt, tender, award, { keys, qa } = {}) {
132
+ keys ??= await fetchKeys();
133
+ const reasons = [], covers = [];
134
+ const ok = (cond, yes, no) => (cond ? covers.push(yes) : reasons.push(no));
135
+ const tenderHash = await receiptHash(tender);
136
+ ok(tender?.kind === 'tender' && (await verifySignature(tender, keys)), 'tender signed', 'tender signature');
137
+ ok(award?.kind === 'award' && (await verifySignature(award, keys)), 'award signed', 'award signature');
138
+ ok(await verifySignature(receipt, keys), 'receipt signed', 'receipt signature');
139
+ ok(award?.tender === tenderHash, 'award names this tender', 'award names another tender');
140
+ ok(receipt?.award === (await receiptHash(award)), 'receipt names this award', 'receipt names another award');
141
+ ok(receipt?.buyer === tender?.buyer, 'paid by the tendering agent', `paid by ${receipt?.buyer}, not by ${tender?.buyer}`);
142
+ ok(receipt?.seller === award?.seller && award?.seller !== tender?.buyer, 'delivered by the awarded seller', `delivered by ${receipt?.seller}, awarded to ${award?.seller}`);
143
+ ok(receipt?.currency === tender?.currency, 'currency of the tender', `currency ${receipt?.currency}, tender ${tender?.currency}`);
144
+ ok(Number.isInteger(award?.price_minor) && award.price_minor <= tender?.budget_max_minor, 'price within the budget', `price ${award?.price_minor} over the budget ${tender?.budget_max_minor}`);
145
+ const ms = receipt?.milestone === undefined ? null : tender?.milestones?.[receipt.milestone];
146
+ const cap = receipt?.milestone === undefined ? award?.price_minor : ms?.max_minor;
147
+ ok(Number.isInteger(receipt?.amount_minor) && Number.isInteger(cap) && receipt.amount_minor <= cap,
148
+ ms ? `amount within milestone ${receipt.milestone}` : 'amount within the price', `amount ${receipt?.amount_minor} over ${cap}`);
149
+ ok(String(tender?.issued_at) <= String(award?.at) && String(award?.at) <= String(receipt?.delivered_at), 'tender, award, delivery in order', 'out of order in time');
150
+ if (tender?.deadline != null) ok(String(receipt?.delivered_at) <= String(tender.deadline), 'delivered by the deadline', 'delivered after the deadline');
151
+ if (qa !== undefined) {
152
+ const held = new Map();
153
+ for (const q of qa) held.set(await receiptHash(q), q);
154
+ for (const h of award?.qa ?? []) {
155
+ const q = held.get(h);
156
+ ok(q?.kind === 'tender_qa' && q.tender === tenderHash && String(q.at) <= String(award.at) && (await verifySignature(q, keys)),
157
+ `question ${h.slice(0, 8)} published before the award`, `question ${h.slice(0, 8)} missing or invalid`);
158
+ }
159
+ }
160
+ return { valid: reasons.length === 0, covers, reasons };
161
+ }
162
+
126
163
  /** The Merkle leaf of a Word Pass: sha256 of its canonical JSON, signature included. */
127
164
  export const leafOf = (pass) => sha256hex(canonical(pass));
128
165
 
@@ -320,3 +357,6 @@ export function sayPass(p, by = p.issuer) {
320
357
  const head = w.badge === 'word_kept_99' ? 'kept its word on 99 % or more' : w.badge === 'word_kept_95' ? 'kept its word on 95 % or more' : w.kept_pct != null ? `kept its word on ${w.kept_pct} % (no badge yet)` : 'new, no record yet';
321
358
  return `${p.name} (Word Pass by ${by}): ${head}; ${record}.`;
322
359
  }
360
+
361
+ // Remember: the chain of daily roots, checked without the issuer (SPEC section 14).
362
+ export { GENESIS, headOf, recomputeChain, verifyLink, verifyRememberDocument, checkRemember, checkHead, loadMlDsa, verifyMlDsa, pqKid } from './remember.js';
@@ -0,0 +1,151 @@
1
+ // pazair-verify/remember: Remember ("Forgets nothing. Holds nothing against you."), checked without the issuer.
2
+ // SPEC section 14. The daily roots form one chain: head_d = sha256hex(head_{d-1} + root_d), head_0 = 64 zeros.
3
+ // Every link and the public document carry two signatures: Ed25519 (the receipt key) and ML-DSA-65 (FIPS 204).
4
+ // Ed25519 and SHA-256 come from WebCrypto (zero dependencies). ML-DSA-65 is checked when the optional package
5
+ // @noble/post-quantum is installed next to this one; otherwise it is reported as not checked, never as valid.
6
+ import { canonical, fetchKeysDocument, sha256hex, verifySignature } from './index.js';
7
+
8
+ export const GENESIS = '0'.repeat(64);
9
+ const enc = new TextEncoder();
10
+ const unb64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4)), (c) => c.charCodeAt(0));
11
+ const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
12
+
13
+ /** One link of the chain. */
14
+ export const headOf = (prev, root) => sha256hex(prev + root);
15
+
16
+ /**
17
+ * Recompute the chain from the days as the issuer publishes them (`/v1/remember/chain`, or any list of
18
+ * { day, root, head? }). Returns the head, and the first day whose published head disagrees with the recomputation.
19
+ */
20
+ export async function recomputeChain(days) {
21
+ const sorted = [...days].filter((d) => d && typeof d.day === 'string' && /^[0-9a-f]{64}$/.test(String(d.root))).sort((a, b) => a.day.localeCompare(b.day));
22
+ let head = GENESIS, broken_at = null;
23
+ const heads = [];
24
+ for (const d of sorted) {
25
+ const prev = head; head = await headOf(prev, d.root);
26
+ const published = typeof d.head === 'string' ? d.head : null, ok = published == null ? null : published === head;
27
+ if (ok === false && !broken_at) broken_at = d.day;
28
+ heads.push({ day: d.day, root: d.root, prev, head, published_head: published, published_matches: ok });
29
+ }
30
+ return { head, days: sorted.length, last_day: sorted.length ? sorted[sorted.length - 1].day : null, broken_at, heads };
31
+ }
32
+
33
+ let mlDsa; // the optional ML-DSA-65 implementation, loaded once
34
+ /** ML-DSA-65 when @noble/post-quantum is installed; null otherwise. */
35
+ export async function loadMlDsa() {
36
+ if (mlDsa !== undefined) return mlDsa;
37
+ try { mlDsa = (await import('@noble/post-quantum/ml-dsa.js')).ml_dsa65 ?? null; } catch { mlDsa = null; }
38
+ return mlDsa;
39
+ }
40
+
41
+ /** true / false, or null when ML-DSA-65 cannot be checked here. */
42
+ export async function verifyMlDsa(sigB64u, msgBytes, publicKeyB64u) {
43
+ const m = await loadMlDsa(); if (!m) return null;
44
+ try { return m.verify(unb64u(sigB64u), msgBytes, unb64u(publicKeyB64u)); } catch { return false; }
45
+ }
46
+
47
+ /** kid of an ML-DSA key: sha256hex(hex(public key))[0:16], like the Ed25519 kid is built from x. */
48
+ export const pqKid = async (publicKeyB64u) => (await sha256hex(hex(unb64u(publicKeyB64u)))).slice(0, 16);
49
+
50
+ async function ed25519(jwk, sigB64u, msgBytes) {
51
+ if (!jwk || typeof jwk.x !== 'string') return false;
52
+ if ((await sha256hex(jwk.x)).slice(0, 16) !== jwk.kid) return false;
53
+ try {
54
+ const pub = await crypto.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, { name: 'Ed25519' }, false, ['verify']);
55
+ return await crypto.subtle.verify({ name: 'Ed25519' }, pub, unb64u(sigB64u), msgBytes);
56
+ } catch { return false; }
57
+ }
58
+
59
+ /**
60
+ * Both signatures of one link { day, prev, root, head } (`head_sig`): Ed25519 with the issuer's published key named
61
+ * by `kid`, ML-DSA-65 with the public key the issuer publishes next to the chain (`pqPublicKey`, base64url).
62
+ */
63
+ export async function verifyLink(link, sig, { keys = [], pqPublicKey } = {}) {
64
+ if (!sig || typeof sig !== 'object') return { ed25519: false, ml_dsa_65: false, pq_kid_matches: false };
65
+ const msg = enc.encode(canonical({ day: link.day, prev: link.prev, root: link.root, head: link.head }));
66
+ const kidOk = pqPublicKey ? (await pqKid(pqPublicKey)) === sig.pq_kid : false;
67
+ return {
68
+ ed25519: typeof sig.ed25519 === 'string' ? await ed25519(keys.find((k) => k.kid === sig.kid), sig.ed25519, msg) : false,
69
+ ml_dsa_65: kidOk && typeof sig.ml_dsa_65 === 'string' ? await verifyMlDsa(sig.ml_dsa_65, msg, pqPublicKey) : false,
70
+ pq_kid_matches: kidOk,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The public document (/.well-known/pazair-remember.json): `sig` is Ed25519 over the canonical JSON without "sig" and
76
+ * "pq_sig"; `pq_sig` is ML-DSA-65 over the same bytes with the key the document itself carries, whose kid must be `pq_kid`.
77
+ */
78
+ export async function verifyRememberDocument(doc, { keys = [] } = {}) {
79
+ if (!doc || typeof doc !== 'object') return { ed25519: false, ml_dsa_65: false, pq_kid_matches: false };
80
+ const { pq_sig, ...rest } = doc;
81
+ const { sig, ...body } = rest;
82
+ const pub = doc.keys?.ml_dsa_65?.public_key_b64url;
83
+ const kidOk = typeof pub === 'string' && (await pqKid(pub)) === doc.pq_kid;
84
+ return {
85
+ ed25519: await verifySignature({ ...body, sig }, keys),
86
+ ml_dsa_65: kidOk && typeof pq_sig === 'string' ? await verifyMlDsa(pq_sig, enc.encode(canonical(body)), pub) : false,
87
+ pq_kid_matches: kidOk,
88
+ };
89
+ }
90
+
91
+ const get = async (f, url) => { const r = await f(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(10000) }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); };
92
+
93
+ /**
94
+ * The whole check, without trusting the issuer: the document's two signatures against the issuer's published keys,
95
+ * the chain recomputed from genesis, the document's head against the recomputation, every link's two signatures.
96
+ * Verdicts: consistent, inconsistent, unreachable.
97
+ */
98
+ export async function checkRemember(origin = 'https://pazair.kulalabs.ch', { fetch: f = fetch } = {}) {
99
+ const o = origin.replace(/\/+$/, '');
100
+ const none = (say) => ({ verdict: 'unreachable', origin: o, say, checks: null });
101
+ let doc, chain, kd;
102
+ try { doc = await get(f, `${o}/.well-known/pazair-remember.json`); } catch (e) { return none(`No Remember document at ${o}/.well-known/pazair-remember.json (${e.message}).`); }
103
+ try { kd = await fetchKeysDocument(o, f); } catch { return none(`${o} publishes no keys at /.well-known/pazair-receipts.json.`); }
104
+ try { chain = await get(f, `${o}/v1/remember/chain`); } catch (e) { return none(`No chain at ${o}/v1/remember/chain (${e.message}).`); }
105
+ const docSig = await verifyRememberDocument(doc, { keys: kd.keys });
106
+ const re = await recomputeChain(chain.chain ?? []);
107
+ const pub = chain.keys?.ml_dsa_65_public_key_b64url ?? doc.keys?.ml_dsa_65?.public_key_b64url;
108
+ let linksEd = 0, linksPq = 0, linksChecked = 0, linkFailures = [];
109
+ for (const d of chain.chain ?? []) {
110
+ if (!d.head_sig) continue;
111
+ linksChecked++;
112
+ const r = await verifyLink({ day: d.day, prev: d.prev, root: d.root, head: d.head }, d.head_sig, { keys: kd.keys, pqPublicKey: pub });
113
+ if (r.ed25519) linksEd++; else linkFailures.push(`${d.day}: Ed25519`);
114
+ if (r.ml_dsa_65 === true) linksPq++; else if (r.ml_dsa_65 === false && await loadMlDsa()) linkFailures.push(`${d.day}: ML-DSA-65`);
115
+ }
116
+ const pqAvailable = !!(await loadMlDsa());
117
+ const headMatches = re.days === 0 ? (doc.head?.head === GENESIS || doc.head?.day == null) : doc.head?.head === re.head;
118
+ const checks = {
119
+ document: { ed25519: docSig.ed25519, ml_dsa_65: pqAvailable ? docSig.ml_dsa_65 : null, pq_kid_matches: docSig.pq_kid_matches, kid_in_issuer_keys: kd.keys.some((k) => k.kid === doc.kid) },
120
+ chain: { days: re.days, recomputed_head: re.head, document_head: doc.head?.head ?? null, head_matches: headMatches, broken_at: re.broken_at, published_heads_match: re.heads.every((h) => h.published_matches !== false) },
121
+ links: { with_signature: linksChecked, ed25519_valid: linksEd, ml_dsa_65_valid: pqAvailable ? linksPq : null, failures: linkFailures },
122
+ ml_dsa_65_checked: pqAvailable,
123
+ };
124
+ const bad = [];
125
+ if (!checks.document.ed25519) bad.push('the document\'s Ed25519 signature does not match the issuer\'s published key');
126
+ if (pqAvailable && !docSig.ml_dsa_65) bad.push('the document\'s ML-DSA-65 signature does not verify');
127
+ if (!headMatches) bad.push(`the document names head ${String(doc.head?.head).slice(0, 12)}… but the chain recomputes to ${re.head.slice(0, 12)}…`);
128
+ if (re.broken_at) bad.push(`published heads disagree with the recomputed chain from ${re.broken_at} on`);
129
+ if (linkFailures.length) bad.push(`link signatures fail: ${linkFailures.join(', ')}`);
130
+ const pqNote = pqAvailable ? '' : ' ML-DSA-65 not checked here (npm install @noble/post-quantum to check it too).';
131
+ if (bad.length) return { verdict: 'inconsistent', origin: o, checks, say: `Do not rely on ${o}'s memory: ${bad.join('; ')}.${pqNote}` };
132
+ return { verdict: 'consistent', origin: o, checks, say: `${o}: Remember holds. ${re.days} day${re.days === 1 ? '' : 's'} recomputed from genesis to head ${re.head.slice(0, 16)}…, document signed by the issuer's key${pqAvailable ? ' and by ML-DSA-65' : ''}, ${linksEd}/${linksChecked} links signed${pqAvailable ? ` (${linksPq} post-quantum)` : ''}.${pqNote}` };
133
+ }
134
+
135
+ /**
136
+ * A head you kept ("<day>:<head>", the pazair-head header of an earlier answer), checked against the chain you
137
+ * recompute yourself, not against the issuer's witness tool.
138
+ */
139
+ export async function checkHead(kept, origin = 'https://pazair.kulalabs.ch', { fetch: f = fetch } = {}) {
140
+ const o = origin.replace(/\/+$/, '');
141
+ const m = /^(\d{4}-\d{2}-\d{2}):([0-9a-f]{64})$/.exec(String(kept).trim().toLowerCase());
142
+ if (!m) return { verdict: 'unreachable', say: 'A kept head looks like <day>:<64 hex>, e.g. the pazair-head header of an earlier answer.' };
143
+ const [, day, head] = m;
144
+ let chain;
145
+ try { chain = await get(f, `${o}/v1/remember/chain`); } catch (e) { return { verdict: 'unreachable', say: `No chain at ${o}/v1/remember/chain (${e.message}).` }; }
146
+ const re = await recomputeChain(chain.chain ?? []);
147
+ const d = re.heads.find((h) => h.day === day);
148
+ if (!d) return { verdict: 'inconsistent', day, kept: head, recomputed: null, say: `The chain at ${o} has no day ${day}. Your head is evidence that this day was published and is now gone.` };
149
+ const ok = d.head === head;
150
+ return { verdict: ok ? 'consistent' : 'inconsistent', day, kept: head, recomputed: d.head, published: d.published_head, days_since: re.heads.length - 1 - re.heads.indexOf(d), say: ok ? `Your head of ${day} is what the chain recomputes to today; ${re.heads.length - 1 - re.heads.indexOf(d)} day(s) anchored since.` : `The chain at ${o} recomputes ${day} to ${d.head.slice(0, 8)}…${d.head.slice(-8)}, not to your ${head.slice(0, 8)}…${head.slice(-8)}: the past was changed, or you were served by someone else. Keep your copy; it is evidence.` };
151
+ }