pazair-verify 1.8.0 → 1.9.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,8 +4,9 @@ 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
+ To also check the post-quantum signatures (ML-DSA-65, FIPS 204, on Remember's links and on twice-signed passes,
8
+ receipts and holder proofs) install `@noble/post-quantum` next to it; without it they are reported as *not checked*,
9
+ never as valid.
9
10
 
10
11
  ```sh
11
12
  npm install pazair-verify
@@ -95,7 +96,8 @@ console.log(await verifyReceipt(receipt, { delivery }));
95
96
  | `checkHead("<day>:<head>", origin, { fetch? })` | a head you kept (`pazair-head`) against the chain you recompute; consistent, inconsistent, unreachable |
96
97
  | `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 |
97
98
  | `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) |
98
- | `verifyReceipt(receipt, { keys?, delivery? })` | Ed25519 signature; optionally that `delivery` is the one signed |
99
+ | `verifyReceipt(receipt, { keys?, pqKeys?, delivery? })` | Ed25519 signature, the second signature when present (section 16), optionally that `delivery` is the one signed |
100
+ | `verifyPqSignature(obj, pqKeys?)` | Section 16: `pq_sig` (ML-DSA-65) with the issuer's `pq_keys`; `{ signed, valid }`, valid null when it cannot be checked here |
99
101
  | `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 }` |
100
102
  | `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 }` |
101
103
  | `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 }` |
@@ -103,7 +105,7 @@ console.log(await verifyReceipt(receipt, { delivery }));
103
105
  | `fingerprintOf({ origin, agent, first_leaf })` | Section 15: the seed and the 64-byte codeword of an agent's fingerprint; two agents differ in at least 55 symbols |
104
106
  | `fingerprintDistance(a, b)` | How many of the 64 symbols differ: 0 the same agent, below 55 not a fingerprint |
105
107
  | `fingerprintSvg(codeword, record?)` | The reference drawing, the same bytes for the same input; `recordOf(pass)` reads the record it grows with |
106
- | `verifyPass(anchored, { keys? })` | signature, Merkle proof into the day's root; returns the anchors to check on Bitcoin and Stellar |
108
+ | `verifyPass(anchored, { keys?, pqKeys? })` | signature, the second signature when present, Merkle proof into the day's root; returns the anchors to check on Bitcoin and Stellar |
107
109
  | `verifyProof(leaf, proof, root)` | a Merkle path |
108
110
  | `leafOf(pass)`, `canonical(obj)`, `sha256hex(s)` | the building blocks of the spec |
109
111
  | `fetchKeys(origin?)` | the issuer's keys from `/.well-known/pazair-receipts.json` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pazair-verify",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
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",
@@ -30,7 +30,9 @@
30
30
  "@noble/post-quantum": ">=0.7.0"
31
31
  },
32
32
  "peerDependenciesMeta": {
33
- "@noble/post-quantum": { "optional": true }
33
+ "@noble/post-quantum": {
34
+ "optional": true
35
+ }
34
36
  },
35
37
  "keywords": [
36
38
  "word-pass",
package/src/index.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  export type Jwk = { kid: string; kty?: string; crv?: string; x: string; revoked_at?: string };
2
+ /** Section 16: an ML-DSA-65 public key as the keys document lists it under pq_keys. */
3
+ export type PqKey = { pq_kid: string; alg?: 'ML-DSA-65'; public_key_b64url: string; revoked_at?: string };
2
4
  export type Step = { side: 'L' | 'R'; hash: string };
3
5
  export function canonical(v: unknown): string;
4
6
  export function sha256hex(s: string): Promise<string>;
5
7
  export function fetchKeys(origin?: string, f?: typeof fetch): Promise<Jwk[]>;
6
8
  export function verifySignature(obj: { sig?: string; kid?: string } & Record<string, unknown>, keys: Jwk[], opts?: { anchoredBefore?: string }): Promise<boolean>;
7
- export function fetchKeysDocument(origin?: string, f?: typeof fetch): Promise<{ keys: Jwk[]; anchors?: { stellar?: { account: string; network: string } } }>;
8
- export function verifyHolderProof(proof: unknown, opts: { keys?: Jwk[]; agent: string; nonce: string; aud?: string; now?: number }): Promise<{ ok: boolean; reason: string }>;
9
- export function verifyReceipt(receipt: Record<string, unknown>, opts?: { keys?: Jwk[]; delivery?: unknown }): Promise<{ valid: boolean; signature_valid: boolean; delivery_matches: boolean | null }>;
9
+ export function fetchKeysDocument(origin?: string, f?: typeof fetch): Promise<{ keys: Jwk[]; pq_keys?: PqKey[]; anchors?: { stellar?: { account: string; network: string } } }>;
10
+ export function verifyHolderProof(proof: unknown, opts: { keys?: Jwk[]; pqKeys?: PqKey[]; agent: string; nonce: string; aud?: string; now?: number }): Promise<{ ok: boolean; reason: string }>;
11
+ export function verifyReceipt(receipt: Record<string, unknown>, opts?: { keys?: Jwk[]; pqKeys?: PqKey[]; delivery?: unknown }): Promise<{ valid: boolean; signature_valid: boolean; pq_signed: boolean; ml_dsa_65: boolean | null; delivery_matches: boolean | null }>;
10
12
  export function wordOf(pass: Record<string, unknown>): { kept_pct: number | null; badge: 'word_kept_99' | 'word_kept_95' | null };
11
13
  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
14
  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[] }>;
@@ -14,11 +16,11 @@ export function receiptHash(receipt: Record<string, unknown>): Promise<string>;
14
16
  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 }>;
15
17
  export function leafOf(pass: Record<string, unknown>): Promise<string>;
16
18
  export function verifyProof(leaf: string, proof: Step[], root: string): Promise<boolean>;
17
- export function verifyPass(anchored: Record<string, unknown>, opts?: { keys?: Jwk[]; anchoredBefore?: string }): Promise<{ valid: boolean; signature_valid: boolean; word_consistent: boolean; in_root: boolean | null; word: unknown; anchors: { root: string; day: string; bitcoin_ots: string | null; stellar_tx: string | null } | null }>;
19
+ export function verifyPass(anchored: Record<string, unknown>, opts?: { keys?: Jwk[]; pqKeys?: PqKey[]; anchoredBefore?: string }): Promise<{ valid: boolean; signature_valid: boolean; pq_signed: boolean; ml_dsa_65: boolean | null; word_consistent: boolean; in_root: boolean | null; word: unknown; anchors: { root: string; day: string; bitcoin_ots: string | null; stellar_tx: string | null } | null }>;
18
20
  export type PassCheck = {
19
21
  trust: 'kept_its_word' | 'signed_unanchored' | 'no_badge_yet' | 'invalid' | 'unreachable';
20
22
  say: string; issuer: string | null;
21
- checks: { signature: boolean; in_root: boolean | null; day: string | null; stellar: boolean | null; stellar_account_bound: boolean; bitcoin_ots: boolean; bitcoin: boolean | null; bitcoin_block?: BitcoinBlock; holder: boolean | null; word: boolean } | null;
23
+ checks: { signature: boolean; ml_dsa_65: boolean | null; in_root: boolean | null; day: string | null; stellar: boolean | null; stellar_account_bound: boolean; bitcoin_ots: boolean; bitcoin: boolean | null; bitcoin_block?: BitcoinBlock; holder: boolean | null; word: boolean } | null;
22
24
  pass: Record<string, unknown> | null;
23
25
  };
24
26
  export type BitcoinBlock = { height: number; hash: string; time: string };
@@ -56,3 +58,7 @@ export function recordOf(pass: Record<string, unknown>): FingerprintRecord;
56
58
  export function ridgesOf(delivered: number): number;
57
59
  /** The reference drawing, the same bytes for the same input. */
58
60
  export function fingerprintSvg(codeword: Fingerprint | string, record?: FingerprintRecord, opts?: { size?: number; ink?: string; gold?: string; background?: string }): string;
61
+
62
+ // The second signature (SPEC section 16).
63
+ /** { signed: false } without pq_sig; valid true / false, or null when it cannot be checked here (library missing, key not published). */
64
+ export function verifyPqSignature(obj: Record<string, unknown>, pqKeys?: PqKey[], opts?: { anchoredBefore?: string }): Promise<{ signed: boolean; valid: boolean | null }>;
package/src/index.js CHANGED
@@ -51,11 +51,12 @@ export async function verifySignature(obj, keys, { anchoredBefore } = {}) {
51
51
  }
52
52
 
53
53
  /** A delivery receipt: signed by the issuer; optionally, the delivery you hold is the one that was signed. */
54
- export async function verifyReceipt(receipt, { keys, delivery } = {}) {
54
+ export async function verifyReceipt(receipt, { keys, pqKeys, delivery } = {}) {
55
55
  keys ??= await fetchKeys();
56
56
  const signature_valid = typeof receipt?.issuer === 'string' && (await verifySignature(receipt, keys));
57
+ const pq = await verifyPqSignature(receipt, pqKeys);
57
58
  const delivery_matches = delivery === undefined ? null : (await sha256hex(typeof delivery === 'string' ? delivery : JSON.stringify(delivery))) === receipt?.delivery_sha256;
58
- return { valid: signature_valid && delivery_matches !== false, signature_valid, delivery_matches };
59
+ return { valid: signature_valid && pq.valid !== false && delivery_matches !== false, signature_valid, pq_signed: pq.signed, ml_dsa_65: pq.valid, delivery_matches };
59
60
  }
60
61
 
61
62
  /** SPEC section 4: kept_pct and badge as they follow from the pass's own counts. */
@@ -177,15 +178,18 @@ export async function verifyProof(leaf, proof, root) {
177
178
  * A revoked key counts only with { anchoredBefore }: a day you proved yourself (a Bitcoin block time), never the
178
179
  * root.day the document claims, which whoever holds the stolen key can write.
179
180
  */
180
- export async function verifyPass(anchored, { keys, anchoredBefore } = {}) {
181
+ export async function verifyPass(anchored, { keys, pqKeys, anchoredBefore } = {}) {
181
182
  keys ??= await fetchKeys();
182
183
  const pass = anchored?.pass ?? anchored;
183
184
  const in_root = anchored?.proof && anchored?.root?.root ? await verifyProof(await leafOf(pass), anchored.proof, anchored.root.root) : null;
184
185
  const signature_valid = pass?.kind === 'word_pass' && (await verifySignature(pass, keys, { anchoredBefore: in_root ? anchoredBefore : undefined }));
186
+ const pq = await verifyPqSignature(pass, pqKeys, { anchoredBefore: in_root ? anchoredBefore : undefined });
185
187
  const w = wordOf(pass), word_consistent = pass?.word == null || ((pass.word.kept_pct ?? null) === w.kept_pct && (pass.word.badge ?? null) === w.badge); // no word, no claim
186
188
  return {
187
- valid: signature_valid && in_root !== false && word_consistent,
189
+ valid: signature_valid && pq.valid !== false && in_root !== false && word_consistent,
188
190
  signature_valid,
191
+ pq_signed: pq.signed,
192
+ ml_dsa_65: pq.valid,
189
193
  word_consistent,
190
194
  in_root,
191
195
  word: pass?.word ?? null,
@@ -301,10 +305,11 @@ export async function bitcoinHasRoot(otsBase64, root, { fetch: f = fetch, explor
301
305
  * A holder proof (section 9): the issuer signed { agent, nonce } for the agent showing the pass. Proves the pass
302
306
  * is theirs and not a copied URL. Check it against the nonce you gave and the pass's agent.
303
307
  */
304
- export async function verifyHolderProof(proof, { keys, agent, nonce, aud, now = Date.now() } = {}) {
308
+ export async function verifyHolderProof(proof, { keys, pqKeys, agent, nonce, aud, now = Date.now() } = {}) {
305
309
  keys ??= await fetchKeys();
306
310
  if (proof?.kind !== 'word_pass_proof') return { ok: false, reason: 'not a holder proof' };
307
311
  if (!(await verifySignature(proof, keys))) return { ok: false, reason: "signature does not match the issuer's key" };
312
+ if ((await verifyPqSignature(proof, pqKeys)).valid === false) return { ok: false, reason: "second signature (ML-DSA-65) does not match the issuer's key" };
308
313
  if (proof.agent !== agent) return { ok: false, reason: 'the proof is for another agent' };
309
314
  if (typeof nonce !== 'string' || nonce.length < 8 || proof.nonce !== nonce) return { ok: false, reason: 'the proof is not for the nonce you gave' };
310
315
  if (aud != null && proof.aud !== aud) return { ok: false, reason: 'the proof was made for another checker' };
@@ -329,16 +334,17 @@ export async function checkWordPass(url, { fetch: f = fetch, keys, proof, nonce,
329
334
  const pass = anchored?.pass ?? (doc?.current?.kind === 'word_pass' ? doc.current : doc?.kind === 'word_pass' ? doc : null);
330
335
  if (!pass) return none(`The document at ${u.href} is not a Word Pass.`);
331
336
  try { kd = keys ? { keys } : await fetchKeysDocument(u.origin, f); } catch { return none(`${u.host} publishes no keys at /.well-known/pazair-receipts.json.`); }
332
- let v = await verifyPass(anchored ?? pass, { keys: kd.keys });
337
+ let v = await verifyPass(anchored ?? pass, { keys: kd.keys, pqKeys: kd.pq_keys });
333
338
  const a = kd.anchors?.stellar, account = a?.network === 'mainnet' && typeof a.account === 'string' ? a.account : undefined;
334
339
  const stellar = v.in_root && anchored.root.stellar_tx && account ? await stellarHasRoot(anchored.root.stellar_tx, anchored.root.root, { fetch: f, account }) : null;
335
340
  const btc = v.in_root && anchored.root.bitcoin_ots ? await bitcoinHasRoot(anchored.root.bitcoin_ots, anchored.root.root, { fetch: f }) : null;
336
341
  // Revoked key: only a Bitcoin block older than the revocation proves the pass was signed before the theft.
337
- if (!v.signature_valid && btc?.ok && btc.block?.time) v = await verifyPass(anchored, { keys: kd.keys, anchoredBefore: btc.block.time.slice(0, 10) });
338
- const held = proof != null ? await verifyHolderProof(proof, { keys: kd.keys, agent: pass.agent, nonce, aud }) : null;
339
- const checks = { signature: v.signature_valid, in_root: v.in_root, day: v.anchors?.day ?? null, stellar, stellar_account_bound: !!(stellar && account), bitcoin_ots: !!(v.in_root && anchored.root.bitcoin_ots), bitcoin: btc ? btc.ok : null, ...(btc?.block ? { bitcoin_block: btc.block } : {}), holder: held ? held.ok : null, word: v.word_consistent };
342
+ if ((!v.signature_valid || v.ml_dsa_65 === false) && btc?.ok && btc.block?.time) v = await verifyPass(anchored, { keys: kd.keys, pqKeys: kd.pq_keys, anchoredBefore: btc.block.time.slice(0, 10) });
343
+ const held = proof != null ? await verifyHolderProof(proof, { keys: kd.keys, pqKeys: kd.pq_keys, agent: pass.agent, nonce, aud }) : null;
344
+ const checks = { signature: v.signature_valid, ml_dsa_65: v.pq_signed ? v.ml_dsa_65 : null, in_root: v.in_root, day: v.anchors?.day ?? null, stellar, stellar_account_bound: !!(stellar && account), bitcoin_ots: !!(v.in_root && anchored.root.bitcoin_ots), bitcoin: btc ? btc.ok : null, ...(btc?.block ? { bitcoin_block: btc.block } : {}), holder: held ? held.ok : null, word: v.word_consistent };
340
345
  const issuer = u.host;
341
346
  if (!checks.signature) return { trust: 'invalid', issuer, checks, pass, say: `Do not rely on this pass: its signature does not match ${issuer}'s published key.` };
347
+ if (checks.ml_dsa_65 === false) return { trust: 'invalid', issuer, checks, pass, say: `Do not rely on this pass: its second signature (ML-DSA-65) does not match ${issuer}'s published key.` };
342
348
  if (checks.in_root === false) return { trust: 'invalid', issuer, checks, pass, say: `Do not rely on this pass: its Merkle proof does not lead to the root of ${checks.day}.` };
343
349
  if (stellar === false) return { trust: 'invalid', issuer, checks, pass, say: `Do not rely on this pass: the Stellar transaction it names does not carry the root of ${checks.day} from ${issuer}'s anchor account.` };
344
350
  if (btc?.ok === false) return { trust: 'invalid', issuer, checks, pass, say: `Do not rely on this pass: the Bitcoin proof it names for the root of ${checks.day} is not for that root or not in the block it claims.` };
@@ -347,7 +353,7 @@ export async function checkWordPass(url, { fetch: f = fetch, keys, proof, nonce,
347
353
  const where = checks.in_root ? `in the Merkle root of ${checks.day}${stellar ? (account ? ", found on Stellar from the issuer's anchor account" : ', found on Stellar') : ''}${btc?.block ? `, confirmed in Bitcoin block ${btc.block.height} (${btc.block.time.slice(0, 10)})` : checks.bitcoin_ots ? ', stamped in Bitcoin (block confirmation pending)' : ''}` : 'not anchored yet';
348
354
  const whose = held?.ok ? ' The agent showing it proved it is this agent.' : ' To be sure it is theirs, give them a fresh nonce and ask for a holder proof (prove_word_pass).';
349
355
  const anchored_ok = stellar === true || btc?.ok === true;
350
- return { trust: !pass.word?.badge ? 'no_badge_yet' : anchored_ok ? 'kept_its_word' : 'signed_unanchored', issuer, checks, pass, say: `${sayPass(pass, issuer)} Checked: signature of ${issuer} valid, ${where}.${whose}` };
356
+ return { trust: !pass.word?.badge ? 'no_badge_yet' : anchored_ok ? 'kept_its_word' : 'signed_unanchored', issuer, checks, pass, say: `${sayPass(pass, issuer)} Checked: signature of ${issuer} valid${checks.ml_dsa_65 ? ' (Ed25519 and ML-DSA-65)' : ''}, ${where}.${whose}` };
351
357
  }
352
358
 
353
359
  /** One plain sentence about a pass. */
@@ -359,7 +365,11 @@ export function sayPass(p, by = p.issuer) {
359
365
  }
360
366
 
361
367
  // 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';
368
+ export { GENESIS, headOf, recomputeChain, verifyLink, verifyRememberDocument, checkRemember, checkHead } from './remember.js';
369
+
370
+ // The second signature (SPEC section 16): ML-DSA-65 on passes, receipts and holder proofs; checked when @noble/post-quantum is installed.
371
+ export { loadMlDsa, verifyMlDsa, pqKid, verifyPqSignature } from './pq.js';
372
+ import { verifyPqSignature } from './pq.js';
363
373
 
364
374
  // Fingerprint (SPEC section 15): a print no other agent can carry, provably; the reference drawing.
365
375
  export { FINGERPRINT, rsEncode, fingerprintOf, fingerprintDistance, fingerprintSvg, recordOf, ridgesOf } from './fingerprint.js';
package/src/pq.js ADDED
@@ -0,0 +1,42 @@
1
+ // ML-DSA-65 (FIPS 204), the second signature. Checked when the optional package @noble/post-quantum is installed
2
+ // next to pazair-verify; otherwise reported as not checked (null), never as valid. Used by Remember (section 14)
3
+ // and by the second signature on passes, receipts and holder proofs (section 16).
4
+ import { canonical, sha256hex } from './index.js';
5
+
6
+ const enc = new TextEncoder();
7
+ const unb64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4)), (c) => c.charCodeAt(0));
8
+ const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
9
+
10
+ let mlDsa; // the optional ML-DSA-65 implementation, loaded once
11
+ /** ML-DSA-65 when @noble/post-quantum is installed; null otherwise. */
12
+ export async function loadMlDsa() {
13
+ if (mlDsa !== undefined) return mlDsa;
14
+ try { mlDsa = (await import('@noble/post-quantum/ml-dsa.js')).ml_dsa65 ?? null; } catch { mlDsa = null; }
15
+ return mlDsa;
16
+ }
17
+
18
+ /** true / false, or null when ML-DSA-65 cannot be checked here. */
19
+ export async function verifyMlDsa(sigB64u, msgBytes, publicKeyB64u) {
20
+ const m = await loadMlDsa(); if (!m) return null;
21
+ try { return m.verify(unb64u(sigB64u), msgBytes, unb64u(publicKeyB64u)); } catch { return false; }
22
+ }
23
+
24
+ /** kid of an ML-DSA key: sha256hex(hex(public key))[0:16], like the Ed25519 kid is built from x. */
25
+ export const pqKid = async (publicKeyB64u) => (await sha256hex(hex(unb64u(publicKeyB64u)))).slice(0, 16);
26
+
27
+ /**
28
+ * Section 16: the second signature of an object. `pq_sig` is ML-DSA-65 over canonical(object without "sig" and
29
+ * "pq_sig") with the issuer's key named by `pq_kid` (`pq_keys` in the keys document). Returns
30
+ * { signed, valid }: signed false when the object carries no pq_sig; valid true / false, or null when it cannot be
31
+ * checked here (no library, or the key is not published). A revoked pq key signs nothing valid.
32
+ */
33
+ export async function verifyPqSignature(obj, pqKeys = [], { anchoredBefore } = {}) {
34
+ if (!obj || typeof obj.pq_sig !== 'string') return { signed: false, valid: null };
35
+ if (typeof obj.pq_kid !== 'string') return { signed: true, valid: false };
36
+ const key = (pqKeys ?? []).find((k) => k?.pq_kid === obj.pq_kid);
37
+ if (!key || typeof key.public_key_b64url !== 'string') return { signed: true, valid: null };
38
+ if ((await pqKid(key.public_key_b64url)) !== key.pq_kid) return { signed: true, valid: false };
39
+ if (key.revoked_at && !(anchoredBefore && anchoredBefore < String(key.revoked_at).slice(0, 10))) return { signed: true, valid: false };
40
+ const { sig, pq_sig, ...body } = obj;
41
+ return { signed: true, valid: await verifyMlDsa(pq_sig, enc.encode(canonical(body)), key.public_key_b64url) };
42
+ }
package/src/remember.js CHANGED
@@ -8,7 +8,6 @@ import { canonical, fetchKeysDocument, sha256hex, verifySignature } from './inde
8
8
  export const GENESIS = '0'.repeat(64);
9
9
  const enc = new TextEncoder();
10
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
11
 
13
12
  /** One link of the chain. */
14
13
  export const headOf = (prev, root) => sha256hex(prev + root);
@@ -30,22 +29,8 @@ export async function recomputeChain(days) {
30
29
  return { head, days: sorted.length, last_day: sorted.length ? sorted[sorted.length - 1].day : null, broken_at, heads };
31
30
  }
32
31
 
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);
32
+ export { loadMlDsa, verifyMlDsa, pqKid } from './pq.js';
33
+ import { loadMlDsa, verifyMlDsa, pqKid } from './pq.js';
49
34
 
50
35
  async function ed25519(jwk, sigB64u, msgBytes) {
51
36
  if (!jwk || typeof jwk.x !== 'string') return false;