pazair-verify 1.8.0 → 1.10.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 +34 -8
- package/bin/cli.js +19 -3
- package/package.json +4 -2
- package/src/credential.js +159 -0
- package/src/fingerprint.js +6 -4
- package/src/index.d.ts +39 -7
- package/src/index.js +58 -17
- package/src/pq.js +42 -0
- package/src/remember.js +17 -21
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
|
|
8
|
-
they are reported as *not checked*,
|
|
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
|
|
@@ -60,18 +61,38 @@ const h = await checkHead('2026-10-10:3f9c…'); // the head yo
|
|
|
60
61
|
Nothing here asks the issuer whether its chain is fine. The chain is fetched as data and recomputed; the issuer's
|
|
61
62
|
`witness` tool is for the issuer's own count of witnesses, this is yours. SPEC section 14 has the formulas.
|
|
62
63
|
|
|
63
|
-
## Fingerprint:
|
|
64
|
+
## Fingerprint: two prints, 55 of 64 symbols apart
|
|
64
65
|
|
|
65
66
|
Every pass carries a fingerprint (SPEC section 15): a Reed-Solomon codeword over `sha256(origin, agent, first anchored
|
|
66
|
-
leaf)`.
|
|
67
|
+
leaf)`. Two different prints differ in at least 55 of 64 symbols, a theorem of the code; that two agents get different
|
|
68
|
+
prints is a probability (the print encodes 80 bits of the hash). Identity is origin, agent id and signature; the print
|
|
69
|
+
is how people and agents recognise a pass. Draw it, compare it:
|
|
67
70
|
|
|
68
71
|
```js
|
|
69
72
|
import { fingerprintOf, fingerprintDistance, fingerprintSvg, recordOf } from 'pazair-verify';
|
|
70
73
|
const me = await fingerprintOf({ origin: 'https://pazair.kulalabs.ch', agent: 'ag_xyz', first_leaf: doc.fingerprint.first_leaf });
|
|
71
|
-
fingerprintDistance(me, doc.fingerprint); // 0: the issuer
|
|
74
|
+
fingerprintDistance(me, doc.fingerprint); // 0: the issuer drew the print of this origin, agent and leaf
|
|
72
75
|
const svg = fingerprintSvg(me, recordOf(doc.current)); // the same bytes the issuer serves as fingerprint.svg
|
|
73
76
|
```
|
|
74
77
|
|
|
78
|
+
## The W3C way: the pass as a Verifiable Credential
|
|
79
|
+
|
|
80
|
+
An issuer that implements SPEC section 18 serves every pass a second time, as a W3C Verifiable Credential 2.0 with a
|
|
81
|
+
Data Integrity proof (`eddsa-jcs-2022`) from its did:web. A wallet or framework that speaks VCs can hold it; this
|
|
82
|
+
checks the proof, the pass inside and, from the command line, its anchors. It reproduces the W3C Recommendation's own
|
|
83
|
+
`eddsa-jcs-2022` test vector byte for byte.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npx pazair-verify credential https://pazair.ch/v1/agents/ag_xyz/pass.vc
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
import { verifyCredential } from 'pazair-verify';
|
|
91
|
+
const vc = await (await fetch('https://pazair.ch/v1/agents/ag_xyz/pass.vc')).json();
|
|
92
|
+
const r = await verifyCredential(vc); // resolves did:web, checks the proof, the subject's origin and the pass inside
|
|
93
|
+
// { valid: true, trust: 'valid', say: '…', issuer: 'did:web:pazair.ch', subject: '<pass URL>', same_key: true, … }
|
|
94
|
+
```
|
|
95
|
+
|
|
75
96
|
## Lower level
|
|
76
97
|
|
|
77
98
|
```js
|
|
@@ -95,16 +116,21 @@ console.log(await verifyReceipt(receipt, { delivery }));
|
|
|
95
116
|
| `checkHead("<day>:<head>", origin, { fetch? })` | a head you kept (`pazair-head`) against the chain you recompute; consistent, inconsistent, unreachable |
|
|
96
117
|
| `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
118
|
| `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
|
|
119
|
+
| `verifyReceipt(receipt, { keys?, pqKeys?, delivery? })` | Ed25519 signature, the second signature when present (section 16), optionally that `delivery` is the one signed |
|
|
120
|
+
| `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
121
|
| `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
122
|
| `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
123
|
| `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 }` |
|
|
102
124
|
| `receiptHash(receipt)` | The hash a parent receipt lists in `inputs` |
|
|
103
|
-
| `fingerprintOf({ origin, agent, first_leaf })` | Section 15: the seed and the 64-byte codeword of an agent's fingerprint; two
|
|
125
|
+
| `fingerprintOf({ origin, agent, first_leaf })` | Section 15: the seed and the 64-byte codeword of an agent's fingerprint; two different prints differ in at least 55 symbols |
|
|
104
126
|
| `fingerprintDistance(a, b)` | How many of the 64 symbols differ: 0 the same agent, below 55 not a fingerprint |
|
|
105
127
|
| `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 |
|
|
128
|
+
| `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
129
|
| `verifyProof(leaf, proof, root)` | a Merkle path |
|
|
130
|
+
| `buyerWordOf(pass)` | Section 4, the buyer's word: `kept_pct` and `pays_99` / `pays_95` from `as_buyer` (paid orders, chargebacks); `checkWordPass` returns it as `buyer_trust` next to the seller's `trust` |
|
|
131
|
+
| `verifyCredential(vc, { fetch?, didDocument?, keysDocument?, now? })` | Section 18: the pass as a W3C Verifiable Credential: VC 2.0, the did:web resolved, the `eddsa-jcs-2022` proof, the subject on the issuer's origin, `validFrom`/`validUntil`, the pass inside; a verdict and a sentence |
|
|
132
|
+
| `verifyDataIntegrity(doc, didDocument)` / `signDataIntegrity(doc, privateKey, verificationMethod, created?)` | any W3C Data Integrity proof with `eddsa-jcs-2022`, checked or made (the W3C Recommendation's vector holds) |
|
|
133
|
+
| `resolveDidWeb(did, fetch?)`, `didWebUrl(did)`, `multikeyOf(x)`, `multikeyToX(multibase)`, `base58btcEncode`/`base58btcDecode` | did:web, Multikey (Ed25519) and base58btc, the pieces of section 18 |
|
|
108
134
|
| `leafOf(pass)`, `canonical(obj)`, `sha256hex(s)` | the building blocks of the spec |
|
|
109
135
|
| `fetchKeys(origin?)` | the issuer's keys from `/.well-known/pazair-receipts.json` |
|
|
110
136
|
|
package/bin/cli.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// npx pazair-verify <word pass url> [--json]: "May I see your Word Pass?", checked from the command line.
|
|
3
3
|
// npx pazair-verify remember [origin] [--json]: Remember, the issuer's chain of daily roots, recomputed here.
|
|
4
4
|
// npx pazair-verify witness <day:head> [origin] [--json]: a head you kept, against the chain you recompute.
|
|
5
|
+
// npx pazair-verify credential <credential url or file> [--json]: the Word Pass as a W3C Verifiable Credential (SPEC 18), then its anchors.
|
|
5
6
|
// Exit codes: 0 trusted / consistent, 1 invalid / inconsistent (do not rely on it), 2 usage, 3 unreachable (could not check).
|
|
6
7
|
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { checkHead, checkRemember, checkWordPass } from '../src/index.js';
|
|
8
|
+
import { checkHead, checkRemember, checkWordPass, verifyCredential } from '../src/index.js';
|
|
8
9
|
|
|
9
10
|
const args = process.argv.slice(2);
|
|
10
11
|
if (args.includes('-v') || args.includes('--version')) {
|
|
@@ -14,10 +15,10 @@ if (args.includes('-v') || args.includes('--version')) {
|
|
|
14
15
|
const words = args.filter((a) => !a.startsWith('-'));
|
|
15
16
|
const help = args.includes('-h') || args.includes('--help');
|
|
16
17
|
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
|
+
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]\n npx pazair-verify credential <credential url or file> [--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\n npx pazair-verify credential https://pazair.ch/v1/agents/ag_xyz/pass.vc';
|
|
18
19
|
if (!words.length || help) { (help ? console.log : console.error)(usage); process.exit(help ? 0 : 2); }
|
|
19
20
|
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
|
+
const codes = { valid: 0, consistent: 0, kept_its_word: 0, no_badge_yet: 0, signed_unanchored: 0, inconsistent: 1, invalid: 1, unreachable: 3 };
|
|
21
22
|
|
|
22
23
|
if (words[0] === 'remember') {
|
|
23
24
|
const r = await checkRemember(words[1] ?? 'https://pazair.kulalabs.ch');
|
|
@@ -32,6 +33,21 @@ if (words[0] === 'witness') {
|
|
|
32
33
|
if (json) console.log(JSON.stringify(r, null, 2));
|
|
33
34
|
process.exit(codes[r.verdict] ?? 1);
|
|
34
35
|
}
|
|
36
|
+
if (words[0] === 'credential') {
|
|
37
|
+
if (!words[1]) { console.error(usage); process.exit(2); }
|
|
38
|
+
let vc;
|
|
39
|
+
try {
|
|
40
|
+
if (/^https:\/\//.test(words[1])) { const res = await fetch(words[1], { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(8000) }); if (!res.ok) throw new Error(); vc = await res.json(); }
|
|
41
|
+
else vc = JSON.parse(readFileSync(words[1], 'utf8'));
|
|
42
|
+
} catch { console.log(clean(`No credential could be read at ${words[1]}.`)); process.exit(3); }
|
|
43
|
+
const r = await verifyCredential(vc);
|
|
44
|
+
console.log(clean(r.say));
|
|
45
|
+
// Proof and pass hold: then the day's root on Bitcoin and Stellar, at the pass URL the credential names.
|
|
46
|
+
const anchors = r.valid ? await checkWordPass(r.subject) : null;
|
|
47
|
+
if (anchors) console.log(clean(anchors.say));
|
|
48
|
+
if (json) console.log(JSON.stringify({ ...r, anchors: anchors ? { ...anchors, pass: undefined } : null }, null, 2));
|
|
49
|
+
process.exit(!r.valid ? (codes[r.trust] ?? 1) : (codes[anchors.trust] ?? 1));
|
|
50
|
+
}
|
|
35
51
|
const r = await checkWordPass(words[0]);
|
|
36
52
|
console.log(clean(r.say));
|
|
37
53
|
if (json) console.log(JSON.stringify({ ...r, pass: undefined }, null, 2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pazair-verify",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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": {
|
|
33
|
+
"@noble/post-quantum": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
34
36
|
},
|
|
35
37
|
"keywords": [
|
|
36
38
|
"word-pass",
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The Word Pass as a W3C Verifiable Credential 2.0 (SPEC section 18): the issuer as did:web, a Data Integrity proof
|
|
2
|
+
// (cryptosuite eddsa-jcs-2022) around the pass document of section 5. Zero dependencies, WebCrypto Ed25519.
|
|
3
|
+
// A VC verifier checks the proof without knowing Word Pass; this module checks the proof and the pass inside.
|
|
4
|
+
import { canonical, fetchKeysDocument, sayPass, verifyPass } from './index.js';
|
|
5
|
+
|
|
6
|
+
const enc = new TextEncoder();
|
|
7
|
+
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
8
|
+
const b64u = (b) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
9
|
+
const unb64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4)), (c) => c.charCodeAt(0));
|
|
10
|
+
const sha256 = async (s) => new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(s)));
|
|
11
|
+
const same = (a, b) => { try { return canonical(a) === canonical(b); } catch { return false; } };
|
|
12
|
+
|
|
13
|
+
/** Base58 with the Bitcoin alphabet, as multibase "z" writes it. */
|
|
14
|
+
export function base58btcEncode(bytes) {
|
|
15
|
+
let n = 0n;
|
|
16
|
+
for (const b of bytes) n = n * 256n + BigInt(b);
|
|
17
|
+
let s = '';
|
|
18
|
+
while (n > 0n) { s = B58[Number(n % 58n)] + s; n /= 58n; }
|
|
19
|
+
for (const b of bytes) { if (b !== 0) break; s = '1' + s; }
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function base58btcDecode(s) {
|
|
24
|
+
let n = 0n;
|
|
25
|
+
for (const c of String(s)) { const i = B58.indexOf(c); if (i < 0) throw new Error('not base58btc'); n = n * 58n + BigInt(i); }
|
|
26
|
+
const out = [];
|
|
27
|
+
while (n > 0n) { out.unshift(Number(n % 256n)); n /= 256n; }
|
|
28
|
+
for (const c of String(s)) { if (c !== '1') break; out.unshift(0); }
|
|
29
|
+
return Uint8Array.from(out);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** An Ed25519 public key (JWK x) as a Multikey: "z" + base58btc(0xed 0x01 + the 32 key bytes). */
|
|
33
|
+
export function multikeyOf(x) {
|
|
34
|
+
const raw = unb64u(x), b = new Uint8Array(2 + raw.length);
|
|
35
|
+
b[0] = 0xed; b[1] = 0x01; b.set(raw, 2);
|
|
36
|
+
return 'z' + base58btcEncode(b);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The Ed25519 key (JWK x) in a Multikey, or null when it is not one. */
|
|
40
|
+
export function multikeyToX(mb) {
|
|
41
|
+
if (typeof mb !== 'string' || mb[0] !== 'z') return null;
|
|
42
|
+
let b;
|
|
43
|
+
try { b = base58btcDecode(mb.slice(1)); } catch { return null; }
|
|
44
|
+
return b.length === 34 && b[0] === 0xed && b[1] === 0x01 ? b64u(b.slice(2)) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** did:web:<host>[:path] → the https URL of its DID document (did:web method, read operation). */
|
|
48
|
+
export function didWebUrl(did) {
|
|
49
|
+
const m = /^did:web:([^:#?/]+)((?::[^:#?/]+)*)$/.exec(String(did));
|
|
50
|
+
if (!m) throw new Error('not a did:web');
|
|
51
|
+
const host = decodeURIComponent(m[1]);
|
|
52
|
+
if (!/^[a-z0-9.-]+(:\d{1,5})?$/i.test(host)) throw new Error('did:web: not a host');
|
|
53
|
+
const segs = m[2] ? m[2].slice(1).split(':').map(decodeURIComponent) : ['.well-known'];
|
|
54
|
+
if (segs.some((x) => !x || x === '.' || x === '..' || /[/\\?#]/.test(x))) throw new Error('did:web: not a path');
|
|
55
|
+
return `https://${host}/${segs.join('/')}/did.json`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fetch a did:web document; it must name the DID it was fetched for. */
|
|
59
|
+
export async function resolveDidWeb(did, f = fetch) {
|
|
60
|
+
const r = await f(didWebUrl(did), { headers: { accept: 'application/did+json, application/json' }, signal: AbortSignal.timeout(8000) });
|
|
61
|
+
if (!r.ok) throw new Error(`did: HTTP ${r.status}`);
|
|
62
|
+
const doc = await r.json();
|
|
63
|
+
if (doc?.id !== did) throw new Error('did: the document is for another DID');
|
|
64
|
+
return doc;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The Ed25519 key (JWK x) a DID document lists for assertions under this id, or null. */
|
|
68
|
+
function assertionKey(didDocument, vmId) {
|
|
69
|
+
const listed = (didDocument?.assertionMethod ?? []).map((m) => (typeof m === 'string' ? m : m?.id));
|
|
70
|
+
if (!listed.includes(vmId)) return null;
|
|
71
|
+
const vm = [...(didDocument.verificationMethod ?? []), ...(didDocument.assertionMethod ?? []).filter((m) => typeof m === 'object')].find((m) => m?.id === vmId);
|
|
72
|
+
if (!vm || (vm.controller !== undefined && vm.controller !== didDocument.id)) return null;
|
|
73
|
+
if (vm.publicKeyMultibase) return multikeyToX(vm.publicKeyMultibase);
|
|
74
|
+
const j = vm.publicKeyJwk;
|
|
75
|
+
return j?.kty === 'OKP' && j.crv === 'Ed25519' && typeof j.x === 'string' && !j.d ? j.x : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A Data Integrity proof with cryptosuite eddsa-jcs-2022 (W3C Recommendation), checked against a DID document:
|
|
80
|
+
* Ed25519 over sha256(JCS(proof without proofValue)) || sha256(JCS(document without proof)). The canonical JSON of
|
|
81
|
+
* section 1 is JCS for these documents. Returns { valid, reason, verificationMethod }.
|
|
82
|
+
*/
|
|
83
|
+
export async function verifyDataIntegrity(doc, didDocument) {
|
|
84
|
+
const no = (reason) => ({ valid: false, reason, verificationMethod: doc?.proof?.verificationMethod ?? null });
|
|
85
|
+
const proof = doc?.proof;
|
|
86
|
+
if (!proof || typeof proof !== 'object' || Array.isArray(proof)) return no('no proof (one Data Integrity proof is expected)');
|
|
87
|
+
const { proofValue, ...config } = proof;
|
|
88
|
+
if (config.type !== 'DataIntegrityProof' || config.cryptosuite !== 'eddsa-jcs-2022') return no('not an eddsa-jcs-2022 Data Integrity proof');
|
|
89
|
+
if (config.proofPurpose !== 'assertionMethod') return no('the proof is not for assertionMethod');
|
|
90
|
+
if (config.created !== undefined && !(Date.parse(config.created) > 0)) return no('created is not a date');
|
|
91
|
+
if (typeof proofValue !== 'string' || proofValue[0] !== 'z') return no('proofValue is not multibase base58btc');
|
|
92
|
+
const { proof: _proof, ...unsecured } = doc;
|
|
93
|
+
if (config['@context'] !== undefined) {
|
|
94
|
+
// The proof's context must be where the document's context starts, in the same order; it then stands for it.
|
|
95
|
+
const dc = [].concat(unsecured['@context'] ?? []), pc = [].concat(config['@context']);
|
|
96
|
+
if (pc.length > dc.length || pc.some((c, i) => !same(c, dc[i]))) return no('the proof @context is not the document @context');
|
|
97
|
+
unsecured['@context'] = config['@context'];
|
|
98
|
+
}
|
|
99
|
+
const vmId = String(config.verificationMethod ?? '');
|
|
100
|
+
if (!didDocument?.id || !vmId.startsWith(`${didDocument.id}#`)) return no('the key is not one of the DID document');
|
|
101
|
+
const x = assertionKey(didDocument, vmId);
|
|
102
|
+
if (!x) return no('the DID document lists no Ed25519 assertion key under that id');
|
|
103
|
+
let sig;
|
|
104
|
+
try { sig = base58btcDecode(proofValue.slice(1)); } catch { return no('proofValue is not base58btc'); }
|
|
105
|
+
if (sig.length !== 64) return no('proofValue is not an Ed25519 signature');
|
|
106
|
+
try {
|
|
107
|
+
const data = new Uint8Array(64);
|
|
108
|
+
data.set(await sha256(canonical(config)), 0);
|
|
109
|
+
data.set(await sha256(canonical(unsecured)), 32);
|
|
110
|
+
const key = await crypto.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x }, { name: 'Ed25519' }, false, ['verify']);
|
|
111
|
+
const ok = await crypto.subtle.verify({ name: 'Ed25519' }, key, sig, data);
|
|
112
|
+
return ok ? { valid: true, reason: 'ok', verificationMethod: vmId } : no('the signature does not match the key');
|
|
113
|
+
} catch {
|
|
114
|
+
return no('the document cannot be checked (not canonical JSON)');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A Word Pass credential (SPEC 18): the Data Integrity proof against the issuer's did:web document, the subject a
|
|
120
|
+
* pass URL on the issuer's own origin, and the pass inside checked as section 5 says (signature, Merkle proof,
|
|
121
|
+
* word), with the keys document of the same origin. The anchors on Bitcoin and Stellar are checkWordPass's job:
|
|
122
|
+
* call it with `subject`. Returns { valid, trust, say, issuer, subject, proof, pass, same_key }.
|
|
123
|
+
*/
|
|
124
|
+
export async function verifyCredential(vc, { fetch: f = fetch, didDocument, keysDocument, now = Date.now() } = {}) {
|
|
125
|
+
const out = (trust, say, extra = {}) => ({ valid: trust === 'valid', trust, say, issuer: typeof vc?.issuer === 'string' ? vc.issuer : null, subject: vc?.credentialSubject?.id ?? null, proof: null, pass: null, same_key: null, ...extra });
|
|
126
|
+
if (!vc || typeof vc !== 'object' || [].concat(vc['@context'] ?? [])[0] !== 'https://www.w3.org/ns/credentials/v2') return out('invalid', 'This is not a Verifiable Credential 2.0.');
|
|
127
|
+
if (!Array.isArray(vc.type) || !vc.type.includes('VerifiableCredential') || !vc.type.includes('WordPassCredential')) return out('invalid', 'This credential is not a Word Pass credential.');
|
|
128
|
+
const issuer = vc.issuer;
|
|
129
|
+
let origin;
|
|
130
|
+
try { origin = new URL(didWebUrl(issuer)).origin; } catch { return out('invalid', 'The issuer of a Word Pass credential is a did:web.'); }
|
|
131
|
+
let dd = didDocument, kd = keysDocument;
|
|
132
|
+
try { dd ??= await resolveDidWeb(issuer, f); } catch { return out('unreachable', `The DID document of ${issuer} could not be read.`); }
|
|
133
|
+
const proof = await verifyDataIntegrity(vc, dd);
|
|
134
|
+
if (!proof.valid) return out('invalid', `Do not rely on this credential: ${proof.reason}.`, { proof });
|
|
135
|
+
const { id: subject, wordPass } = vc.credentialSubject ?? {};
|
|
136
|
+
const pass = wordPass?.pass;
|
|
137
|
+
let u;
|
|
138
|
+
try { u = new URL(subject); } catch { u = null; }
|
|
139
|
+
if (!u || u.protocol !== 'https:' || u.origin !== origin) return out('invalid', `Do not rely on this credential: its subject is not a pass URL on ${origin}.`, { proof });
|
|
140
|
+
if (pass?.kind !== 'word_pass') return out('invalid', 'Do not rely on this credential: it carries no Word Pass.', { proof });
|
|
141
|
+
if (vc.validUntil !== undefined && !(Date.parse(vc.validUntil) >= now)) return out('invalid', 'Do not rely on this credential: it has expired (validUntil).', { proof });
|
|
142
|
+
if (vc.validFrom !== undefined && !(Date.parse(vc.validFrom) <= now + 300_000)) return out('invalid', 'Do not rely on this credential: it is not valid yet (validFrom).', { proof });
|
|
143
|
+
try { kd ??= await fetchKeysDocument(origin, f); } catch { return out('unreachable', `${u.host} publishes no keys at /.well-known/pazair-receipts.json.`, { proof }); }
|
|
144
|
+
const v = await verifyPass(wordPass, { keys: kd.keys ?? [], pqKeys: kd.pq_keys });
|
|
145
|
+
const passKey = (kd.keys ?? []).find((k) => k.kid === pass.kid);
|
|
146
|
+
const same_key = !!passKey && passKey.x === assertionKey(dd, proof.verificationMethod);
|
|
147
|
+
if (!v.valid) return out('invalid', `Do not rely on this credential: the Word Pass inside does not verify (${!v.signature_valid ? 'signature' : v.ml_dsa_65 === false ? 'second signature' : v.in_root === false ? 'Merkle proof' : 'badge'}).`, { proof, pass: v, same_key });
|
|
148
|
+
const where = v.in_root ? `in the Merkle root of ${v.anchors.day}` : 'signed today, not anchored yet';
|
|
149
|
+
return out('valid', `${sayPass(pass, u.host)} Credential by ${issuer}: proof eddsa-jcs-2022 valid, pass signature valid${v.ml_dsa_65 ? ' (Ed25519 and ML-DSA-65)' : ''}, ${where}. Anchors on Bitcoin and Stellar: checkWordPass(${subject}).`, { proof, pass: v, same_key });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** For issuers (and the test vector): sign a document with eddsa-jcs-2022. `priv` is an Ed25519 CryptoKey. */
|
|
153
|
+
export async function signDataIntegrity(doc, priv, verificationMethod, created = new Date()) {
|
|
154
|
+
const proof = { ...(doc['@context'] !== undefined ? { '@context': doc['@context'] } : {}), type: 'DataIntegrityProof', cryptosuite: 'eddsa-jcs-2022', created: created.toISOString().replace(/\.\d{3}Z$/, 'Z'), verificationMethod, proofPurpose: 'assertionMethod' };
|
|
155
|
+
const data = new Uint8Array(64);
|
|
156
|
+
data.set(await sha256(canonical(proof)), 0);
|
|
157
|
+
data.set(await sha256(canonical(doc)), 32);
|
|
158
|
+
return { ...doc, proof: { ...proof, proofValue: 'z' + base58btcEncode(new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, priv, data))) } };
|
|
159
|
+
}
|
package/src/fingerprint.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Fingerprint (SPEC section 15):
|
|
1
|
+
// Fingerprint (SPEC section 15): two different prints differ in at least 55 of 64 symbols, provably.
|
|
2
2
|
// seed = sha256(origin "\n" agent "\n" first_leaf); the first 10 bytes are the message of a Reed-Solomon code over
|
|
3
|
-
// GF(256) with n = 64, k = 10, so any two prints differ in at least d = n - k + 1 = 55 of 64
|
|
4
|
-
//
|
|
3
|
+
// GF(256) with n = 64, k = 10, so any two prints of different messages differ in at least d = n - k + 1 = 55 of 64
|
|
4
|
+
// symbols. Seeds that share their first 10 bytes share the print: that two agents get different messages is a
|
|
5
|
+
// probability (80 bits of a hash), not part of the theorem. Each symbol is one sector of the drawing. The record is
|
|
6
|
+
// drawn over it and never changes the identity.
|
|
5
7
|
import { sha256hex } from './index.js';
|
|
6
8
|
|
|
7
9
|
const N = 64, K = 10, POLY = 0x11d;
|
|
@@ -33,7 +35,7 @@ export async function fingerprintOf({ origin, agent, first_leaf }) {
|
|
|
33
35
|
return { seed, codeword: hex(rsEncode(unhex(seed))) };
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
/** How many of the 64 symbols differ
|
|
38
|
+
/** How many of the 64 symbols differ: 0 for the same message, never 1 to 54 for two fingerprints of section 15. */
|
|
37
39
|
export function fingerprintDistance(a, b) {
|
|
38
40
|
const x = unhex(a.codeword ?? a), y = unhex(b.codeword ?? b);
|
|
39
41
|
if (x.length !== N || y.length !== N) throw new TypeError('fingerprintDistance: a codeword has 64 bytes');
|
package/src/index.d.ts
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
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 };
|
|
13
|
+
/** SPEC section 4, the buyer's word: from as_buyer (paid orders, chargebacks); pays_99 / pays_95 from 10 paid orders and 5 sellers. */
|
|
14
|
+
export function buyerWordOf(pass: Record<string, unknown>): { kept_pct: number | null; badge: 'pays_99' | 'pays_95' | null };
|
|
11
15
|
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
16
|
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[] }>;
|
|
13
17
|
export function receiptHash(receipt: Record<string, unknown>): Promise<string>;
|
|
14
18
|
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
19
|
export function leafOf(pass: Record<string, unknown>): Promise<string>;
|
|
16
20
|
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 }>;
|
|
21
|
+
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
22
|
export type PassCheck = {
|
|
19
23
|
trust: 'kept_its_word' | 'signed_unanchored' | 'no_badge_yet' | 'invalid' | 'unreachable';
|
|
24
|
+
/** The buyer's word (SPEC 4), for whoever sells to this agent; absent when the pass is invalid or unreachable. */
|
|
25
|
+
buyer_trust?: 'pays_its_way' | 'signed_unanchored' | 'no_buyer_badge_yet';
|
|
20
26
|
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;
|
|
27
|
+
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
28
|
pass: Record<string, unknown> | null;
|
|
23
29
|
};
|
|
24
30
|
export type BitcoinBlock = { height: number; hash: string; time: string };
|
|
@@ -44,15 +50,41 @@ export type RememberCheck = { verdict: 'consistent' | 'inconsistent' | 'unreacha
|
|
|
44
50
|
export function checkRemember(origin?: string, opts?: { fetch?: typeof fetch }): Promise<RememberCheck>;
|
|
45
51
|
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 }>;
|
|
46
52
|
|
|
47
|
-
// Fingerprint (SPEC section 15):
|
|
53
|
+
// Fingerprint (SPEC section 15): two different prints differ in at least 55 of 64 symbols, provably.
|
|
48
54
|
export const FINGERPRINT: { n: 64; k: 10; d: 55; field: string; generator: string };
|
|
49
55
|
export function rsEncode(msg: Uint8Array): Uint8Array;
|
|
50
56
|
export type Fingerprint = { seed: string; codeword: string };
|
|
51
57
|
export function fingerprintOf(input: { origin: string; agent: string; first_leaf: string }): Promise<Fingerprint>;
|
|
52
|
-
/** Symbols that differ, 0 … 64
|
|
58
|
+
/** Symbols that differ, 0 … 64: 0 for the same message (the first 10 bytes of the seed), otherwise at least 55. */
|
|
53
59
|
export function fingerprintDistance(a: Fingerprint | string, b: Fingerprint | string): number;
|
|
54
60
|
export type FingerprintRecord = { delivered: number; buyers: number; badge: string | null; disputes_lost: number };
|
|
55
61
|
export function recordOf(pass: Record<string, unknown>): FingerprintRecord;
|
|
56
62
|
export function ridgesOf(delivered: number): number;
|
|
57
63
|
/** The reference drawing, the same bytes for the same input. */
|
|
58
64
|
export function fingerprintSvg(codeword: Fingerprint | string, record?: FingerprintRecord, opts?: { size?: number; ink?: string; gold?: string; background?: string }): string;
|
|
65
|
+
|
|
66
|
+
// The second signature (SPEC section 16).
|
|
67
|
+
/** { signed: false } without pq_sig; valid true / false, or null when it cannot be checked here (library missing, key not published). */
|
|
68
|
+
export function verifyPqSignature(obj: Record<string, unknown>, pqKeys?: PqKey[], opts?: { anchoredBefore?: string }): Promise<{ signed: boolean; valid: boolean | null }>;
|
|
69
|
+
|
|
70
|
+
// The Word Pass as a W3C Verifiable Credential 2.0 (SPEC section 18).
|
|
71
|
+
export type DidDocument = { id: string; verificationMethod?: Record<string, unknown>[]; assertionMethod?: (string | Record<string, unknown>)[]; [k: string]: unknown };
|
|
72
|
+
export function base58btcEncode(bytes: Uint8Array): string;
|
|
73
|
+
export function base58btcDecode(s: string): Uint8Array;
|
|
74
|
+
/** An Ed25519 public key (JWK x) as a Multikey ("z6Mk…"). */
|
|
75
|
+
export function multikeyOf(x: string): string;
|
|
76
|
+
/** The Ed25519 key (JWK x) in a Multikey, or null. */
|
|
77
|
+
export function multikeyToX(multibase: string): string | null;
|
|
78
|
+
/** did:web → the https URL of its DID document; throws for anything that is not a did:web or leaves its host. */
|
|
79
|
+
export function didWebUrl(did: string): string;
|
|
80
|
+
export function resolveDidWeb(did: string, f?: typeof fetch): Promise<DidDocument>;
|
|
81
|
+
/** A Data Integrity proof with cryptosuite eddsa-jcs-2022, against a DID document. */
|
|
82
|
+
export function verifyDataIntegrity(doc: Record<string, unknown>, didDocument: DidDocument): Promise<{ valid: boolean; reason: string; verificationMethod: string | null }>;
|
|
83
|
+
/** Sign a document with eddsa-jcs-2022 (the proof carries the document's @context). */
|
|
84
|
+
export function signDataIntegrity<T extends Record<string, unknown>>(doc: T, privateKey: CryptoKey, verificationMethod: string, created?: Date): Promise<T & { proof: Record<string, unknown> }>;
|
|
85
|
+
export type CredentialCheck = {
|
|
86
|
+
valid: boolean; trust: 'valid' | 'invalid' | 'unreachable'; say: string; issuer: string | null; subject: string | null;
|
|
87
|
+
proof: { valid: boolean; reason: string; verificationMethod: string | null } | null;
|
|
88
|
+
pass: Awaited<ReturnType<typeof verifyPass>> | null; same_key: boolean | null;
|
|
89
|
+
};
|
|
90
|
+
export function verifyCredential(vc: unknown, opts?: { fetch?: typeof fetch; didDocument?: DidDocument; keysDocument?: { keys: Jwk[]; pq_keys?: PqKey[] }; now?: number }): Promise<CredentialCheck>;
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,8 @@ const enc = new TextEncoder();
|
|
|
9
9
|
export function canonical(v) {
|
|
10
10
|
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
11
11
|
if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
|
|
12
|
+
// 1e400 parses to Infinity, which JSON.stringify writes as null: a signed null must never read back as a number.
|
|
13
|
+
if (typeof v === 'number' && !Number.isFinite(v)) throw new TypeError('canonical: number out of range');
|
|
12
14
|
return JSON.stringify(v);
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -37,7 +39,7 @@ export async function fetchKeys(origin = 'https://pazair.kulalabs.ch', f = fetch
|
|
|
37
39
|
*/
|
|
38
40
|
export async function verifySignature(obj, keys, { anchoredBefore } = {}) {
|
|
39
41
|
if (!obj || typeof obj.sig !== 'string' || typeof obj.kid !== 'string') return false;
|
|
40
|
-
const jwk = keys.find((k) => k.kid === obj.kid);
|
|
42
|
+
const jwk = Array.isArray(keys) ? keys.find((k) => k && typeof k === 'object' && k.kid === obj.kid) : undefined;
|
|
41
43
|
if (!jwk || typeof jwk.x !== 'string') return false;
|
|
42
44
|
if ((await sha256hex(jwk.x)).slice(0, 16) !== jwk.kid) return false; // SPEC §1: kid = sha256(x)[0:16]
|
|
43
45
|
if (jwk.revoked_at && !(anchoredBefore && anchoredBefore < String(jwk.revoked_at).slice(0, 10))) return false;
|
|
@@ -51,11 +53,12 @@ export async function verifySignature(obj, keys, { anchoredBefore } = {}) {
|
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** 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 } = {}) {
|
|
56
|
+
export async function verifyReceipt(receipt, { keys, pqKeys, delivery } = {}) {
|
|
55
57
|
keys ??= await fetchKeys();
|
|
56
58
|
const signature_valid = typeof receipt?.issuer === 'string' && (await verifySignature(receipt, keys));
|
|
59
|
+
const pq = await verifyPqSignature(receipt, pqKeys);
|
|
57
60
|
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 };
|
|
61
|
+
return { valid: signature_valid && pq.valid !== false && delivery_matches !== false, signature_valid, pq_signed: pq.signed, ml_dsa_65: pq.valid, delivery_matches };
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
/** SPEC section 4: kept_pct and badge as they follow from the pass's own counts. */
|
|
@@ -67,6 +70,17 @@ export function wordOf(pass) {
|
|
|
67
70
|
return { kept_pct: kept, badge: enough && kept >= 99 ? 'word_kept_99' : enough && kept >= 95 ? 'word_kept_95' : null };
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
/**
|
|
74
|
+
* SPEC section 4, the buyer's word: paid for what was delivered. Broken only by a chargeback on a delivered order;
|
|
75
|
+
* pays_99 / pays_95 from 10 paid orders and 5 different sellers, as they follow from as_buyer.
|
|
76
|
+
*/
|
|
77
|
+
export function buyerWordOf(pass) {
|
|
78
|
+
const b = pass?.as_buyer ?? {}, paid = Number(b.paid_orders ?? 0), closed = paid + Number(b.chargebacks ?? 0);
|
|
79
|
+
if (!closed) return { kept_pct: null, badge: null };
|
|
80
|
+
const kept = Math.floor((paid / closed) * 1000) / 10, enough = paid >= 10 && Number(b.sellers ?? 0) >= 5;
|
|
81
|
+
return { kept_pct: kept, badge: enough && kept >= 99 ? 'pays_99' : enough && kept >= 95 ? 'pays_95' : null };
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
/** Section 3.1: the hash a parent receipt names in `inputs`, the child receipt with its signature. */
|
|
71
85
|
export const receiptHash = (receipt) => sha256hex(canonical(receipt));
|
|
72
86
|
|
|
@@ -83,6 +97,8 @@ export async function verifyReceiptChain(top, { receipts = [], keys, maxDepth =
|
|
|
83
97
|
if (!(await verifySignature(p, keys))) return `${p?.order}: signature`;
|
|
84
98
|
if (p.inputs === undefined) return null;
|
|
85
99
|
if (!Array.isArray(p.inputs)) return `${p.order}: inputs`;
|
|
100
|
+
// Each bought input counts once: a repeated hash would inflate the total (and the walk) without another purchase.
|
|
101
|
+
if (new Set(p.inputs).size !== p.inputs.length) return `${p.order}: an input is named twice`;
|
|
86
102
|
if (p.inputs.length && d >= maxDepth) return `${p.order}: deeper than ${maxDepth}`;
|
|
87
103
|
for (const h of p.inputs) {
|
|
88
104
|
const c = byHash.get(h);
|
|
@@ -177,15 +193,20 @@ export async function verifyProof(leaf, proof, root) {
|
|
|
177
193
|
* A revoked key counts only with { anchoredBefore }: a day you proved yourself (a Bitcoin block time), never the
|
|
178
194
|
* root.day the document claims, which whoever holds the stolen key can write.
|
|
179
195
|
*/
|
|
180
|
-
export async function verifyPass(anchored, { keys, anchoredBefore } = {}) {
|
|
196
|
+
export async function verifyPass(anchored, { keys, pqKeys, anchoredBefore } = {}) {
|
|
181
197
|
keys ??= await fetchKeys();
|
|
182
198
|
const pass = anchored?.pass ?? anchored;
|
|
183
199
|
const in_root = anchored?.proof && anchored?.root?.root ? await verifyProof(await leafOf(pass), anchored.proof, anchored.root.root) : null;
|
|
184
200
|
const signature_valid = pass?.kind === 'word_pass' && (await verifySignature(pass, keys, { anchoredBefore: in_root ? anchoredBefore : undefined }));
|
|
185
|
-
const
|
|
201
|
+
const pq = await verifyPqSignature(pass, pqKeys, { anchoredBefore: in_root ? anchoredBefore : undefined });
|
|
202
|
+
const w = wordOf(pass), bw = buyerWordOf(pass);
|
|
203
|
+
const word_consistent = (pass?.word == null || ((pass.word.kept_pct ?? null) === w.kept_pct && (pass.word.badge ?? null) === w.badge)) // no word, no claim
|
|
204
|
+
&& (pass?.buyer_word == null || ((pass.buyer_word.kept_pct ?? null) === bw.kept_pct && (pass.buyer_word.badge ?? null) === bw.badge));
|
|
186
205
|
return {
|
|
187
|
-
valid: signature_valid && in_root !== false && word_consistent,
|
|
206
|
+
valid: signature_valid && pq.valid !== false && in_root !== false && word_consistent,
|
|
188
207
|
signature_valid,
|
|
208
|
+
pq_signed: pq.signed,
|
|
209
|
+
ml_dsa_65: pq.valid,
|
|
189
210
|
word_consistent,
|
|
190
211
|
in_root,
|
|
191
212
|
word: pass?.word ?? null,
|
|
@@ -301,10 +322,11 @@ export async function bitcoinHasRoot(otsBase64, root, { fetch: f = fetch, explor
|
|
|
301
322
|
* A holder proof (section 9): the issuer signed { agent, nonce } for the agent showing the pass. Proves the pass
|
|
302
323
|
* is theirs and not a copied URL. Check it against the nonce you gave and the pass's agent.
|
|
303
324
|
*/
|
|
304
|
-
export async function verifyHolderProof(proof, { keys, agent, nonce, aud, now = Date.now() } = {}) {
|
|
325
|
+
export async function verifyHolderProof(proof, { keys, pqKeys, agent, nonce, aud, now = Date.now() } = {}) {
|
|
305
326
|
keys ??= await fetchKeys();
|
|
306
327
|
if (proof?.kind !== 'word_pass_proof') return { ok: false, reason: 'not a holder proof' };
|
|
307
328
|
if (!(await verifySignature(proof, keys))) return { ok: false, reason: "signature does not match the issuer's key" };
|
|
329
|
+
if ((await verifyPqSignature(proof, pqKeys)).valid === false) return { ok: false, reason: "second signature (ML-DSA-65) does not match the issuer's key" };
|
|
308
330
|
if (proof.agent !== agent) return { ok: false, reason: 'the proof is for another agent' };
|
|
309
331
|
if (typeof nonce !== 'string' || nonce.length < 8 || proof.nonce !== nonce) return { ok: false, reason: 'the proof is not for the nonce you gave' };
|
|
310
332
|
if (aud != null && proof.aud !== aud) return { ok: false, reason: 'the proof was made for another checker' };
|
|
@@ -318,7 +340,12 @@ export async function verifyHolderProof(proof, { keys, agent, nonce, aud, now =
|
|
|
318
340
|
* the root on Stellar from the issuer's anchor account, the root in its Bitcoin block and, given { proof, nonce }, that the pass is theirs.
|
|
319
341
|
* Returns a verdict and one sentence to act on.
|
|
320
342
|
*/
|
|
321
|
-
export async function checkWordPass(url,
|
|
343
|
+
export async function checkWordPass(url, opts = {}) {
|
|
344
|
+
// A hostile issuer (keys that are not a list, a pass nested thousands deep) gets a verdict, never a crash.
|
|
345
|
+
try { return await checkWordPassUnsafe(url, opts); }
|
|
346
|
+
catch { return { trust: 'unreachable', say: 'The Word Pass or its keys could not be read as a valid document.', issuer: null, checks: null, pass: null }; }
|
|
347
|
+
}
|
|
348
|
+
async function checkWordPassUnsafe(url, { fetch: f = fetch, keys, proof, nonce, aud } = {}) {
|
|
322
349
|
const none = (say) => ({ trust: 'unreachable', say, issuer: null, checks: null, pass: null });
|
|
323
350
|
let u;
|
|
324
351
|
try { u = new URL(url); } catch { return none('That is not a URL.'); }
|
|
@@ -329,16 +356,17 @@ export async function checkWordPass(url, { fetch: f = fetch, keys, proof, nonce,
|
|
|
329
356
|
const pass = anchored?.pass ?? (doc?.current?.kind === 'word_pass' ? doc.current : doc?.kind === 'word_pass' ? doc : null);
|
|
330
357
|
if (!pass) return none(`The document at ${u.href} is not a Word Pass.`);
|
|
331
358
|
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 });
|
|
359
|
+
let v = await verifyPass(anchored ?? pass, { keys: kd.keys, pqKeys: kd.pq_keys });
|
|
333
360
|
const a = kd.anchors?.stellar, account = a?.network === 'mainnet' && typeof a.account === 'string' ? a.account : undefined;
|
|
334
361
|
const stellar = v.in_root && anchored.root.stellar_tx && account ? await stellarHasRoot(anchored.root.stellar_tx, anchored.root.root, { fetch: f, account }) : null;
|
|
335
362
|
const btc = v.in_root && anchored.root.bitcoin_ots ? await bitcoinHasRoot(anchored.root.bitcoin_ots, anchored.root.root, { fetch: f }) : null;
|
|
336
363
|
// 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 };
|
|
364
|
+
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) });
|
|
365
|
+
const held = proof != null ? await verifyHolderProof(proof, { keys: kd.keys, pqKeys: kd.pq_keys, agent: pass.agent, nonce, aud }) : null;
|
|
366
|
+
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
367
|
const issuer = u.host;
|
|
341
368
|
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.` };
|
|
369
|
+
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
370
|
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
371
|
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
372
|
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,19 +375,32 @@ export async function checkWordPass(url, { fetch: f = fetch, keys, proof, nonce,
|
|
|
347
375
|
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
376
|
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
377
|
const anchored_ok = stellar === true || btc?.ok === true;
|
|
350
|
-
|
|
378
|
+
// trust is the seller's verdict, as it always was; buyer_trust is the buyer's word (SPEC 4), for whoever sells to it.
|
|
379
|
+
const buyer_trust = !pass.buyer_word?.badge ? 'no_buyer_badge_yet' : anchored_ok ? 'pays_its_way' : 'signed_unanchored';
|
|
380
|
+
return { trust: !pass.word?.badge ? 'no_badge_yet' : anchored_ok ? 'kept_its_word' : 'signed_unanchored', buyer_trust, issuer, checks, pass, say: `${sayPass(pass, issuer)} Checked: signature of ${issuer} valid${checks.ml_dsa_65 ? ' (Ed25519 and ML-DSA-65)' : ''}, ${where}.${whose}` };
|
|
351
381
|
}
|
|
352
382
|
|
|
353
383
|
/** One plain sentence about a pass. */
|
|
354
384
|
export function sayPass(p, by = p.issuer) {
|
|
355
385
|
const s = p.as_seller ?? {}, w = p.word ?? {}, n = (x, one, many = one + 's') => `${x} ${x === 1 ? one : many}`;
|
|
356
386
|
const record = s.delivered ? `${n(s.delivered, 'paid order')} delivered to ${n(s.buyers, 'buyer')}, ${n(p.disputes_lost ?? 0, 'dispute')} lost` : `no sales yet, ${n(p.as_buyer?.paid_orders ?? 0, 'paid purchase')}`;
|
|
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';
|
|
358
|
-
|
|
387
|
+
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)` : p.as_buyer?.paid_orders || p.as_buyer?.chargebacks ? 'no record as a seller yet' : 'new, no record yet';
|
|
388
|
+
const bw = p.buyer_word, b = p.as_buyer ?? {}, cb = Number(b.chargebacks ?? 0);
|
|
389
|
+
const buyer = bw && bw.kept_pct != null
|
|
390
|
+
? ` As a buyer: ${bw.badge === 'pays_99' ? 'paid for 99 % or more' : bw.badge === 'pays_95' ? 'paid for 95 % or more' : `paid for ${bw.kept_pct} % (no buyer's badge yet)`} of ${n(Number(b.paid_orders ?? 0) + cb, 'delivered order')} from ${n(Number(b.sellers ?? 0), 'seller')}, ${n(cb, 'chargeback')}.`
|
|
391
|
+
: '';
|
|
392
|
+
return `${p.name} (Word Pass by ${by}): ${head}; ${record}.${buyer}`;
|
|
359
393
|
}
|
|
360
394
|
|
|
361
395
|
// Remember: the chain of daily roots, checked without the issuer (SPEC section 14).
|
|
362
|
-
export { GENESIS, headOf, recomputeChain, verifyLink, verifyRememberDocument, checkRemember, checkHead
|
|
396
|
+
export { GENESIS, headOf, recomputeChain, verifyLink, verifyRememberDocument, checkRemember, checkHead } from './remember.js';
|
|
363
397
|
|
|
364
|
-
//
|
|
398
|
+
// The second signature (SPEC section 16): ML-DSA-65 on passes, receipts and holder proofs; checked when @noble/post-quantum is installed.
|
|
399
|
+
export { loadMlDsa, verifyMlDsa, pqKid, verifyPqSignature } from './pq.js';
|
|
400
|
+
import { verifyPqSignature } from './pq.js';
|
|
401
|
+
|
|
402
|
+
// Fingerprint (SPEC section 15): two different prints differ in at least 55 of 64 symbols; the reference drawing.
|
|
365
403
|
export { FINGERPRINT, rsEncode, fingerprintOf, fingerprintDistance, fingerprintSvg, recordOf, ridgesOf } from './fingerprint.js';
|
|
404
|
+
|
|
405
|
+
// The Word Pass as a W3C Verifiable Credential 2.0 (SPEC section 18): did:web, Multikey, Data Integrity eddsa-jcs-2022.
|
|
406
|
+
export { base58btcEncode, base58btcDecode, multikeyOf, multikeyToX, didWebUrl, resolveDidWeb, verifyDataIntegrity, verifyCredential, signDataIntegrity } from './credential.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
|
-
|
|
34
|
-
|
|
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;
|
|
@@ -75,11 +60,14 @@ export async function verifyLink(link, sig, { keys = [], pqPublicKey } = {}) {
|
|
|
75
60
|
* The public document (/.well-known/pazair-remember.json): `sig` is Ed25519 over the canonical JSON without "sig" and
|
|
76
61
|
* "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
62
|
*/
|
|
78
|
-
export async function verifyRememberDocument(doc, { keys = [] } = {}) {
|
|
63
|
+
export async function verifyRememberDocument(doc, { keys = [], pqKeys } = {}) {
|
|
79
64
|
if (!doc || typeof doc !== 'object') return { ed25519: false, ml_dsa_65: false, pq_kid_matches: false };
|
|
80
65
|
const { pq_sig, ...rest } = doc;
|
|
81
66
|
const { sig, ...body } = rest;
|
|
82
|
-
|
|
67
|
+
// When the issuer publishes its ML-DSA keys (pq_keys), only that key counts: one the document brings itself proves
|
|
68
|
+
// nothing against someone who could forge the Ed25519 signature, which is what the second signature is for.
|
|
69
|
+
const published = publishedPq(pqKeys, doc.pq_kid);
|
|
70
|
+
const pub = published !== undefined ? published : doc.keys?.ml_dsa_65?.public_key_b64url;
|
|
83
71
|
const kidOk = typeof pub === 'string' && (await pqKid(pub)) === doc.pq_kid;
|
|
84
72
|
return {
|
|
85
73
|
ed25519: await verifySignature({ ...body, sig }, keys),
|
|
@@ -88,6 +76,13 @@ export async function verifyRememberDocument(doc, { keys = [] } = {}) {
|
|
|
88
76
|
};
|
|
89
77
|
}
|
|
90
78
|
|
|
79
|
+
/** The issuer's published ML-DSA key for a kid: undefined when it publishes none, null when it publishes others. */
|
|
80
|
+
const publishedPq = (pqKeys, kid) => {
|
|
81
|
+
if (!Array.isArray(pqKeys) || !pqKeys.length) return undefined;
|
|
82
|
+
const k = pqKeys.find((x) => x && typeof x === 'object' && x.pq_kid === kid && !x.revoked_at);
|
|
83
|
+
return typeof k?.public_key_b64url === 'string' ? k.public_key_b64url : null;
|
|
84
|
+
};
|
|
85
|
+
|
|
91
86
|
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
87
|
|
|
93
88
|
/**
|
|
@@ -102,9 +97,10 @@ export async function checkRemember(origin = 'https://pazair.kulalabs.ch', { fet
|
|
|
102
97
|
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
98
|
try { kd = await fetchKeysDocument(o, f); } catch { return none(`${o} publishes no keys at /.well-known/pazair-receipts.json.`); }
|
|
104
99
|
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 });
|
|
100
|
+
const docSig = await verifyRememberDocument(doc, { keys: kd.keys, pqKeys: kd.pq_keys });
|
|
106
101
|
const re = await recomputeChain(chain.chain ?? []);
|
|
107
|
-
const
|
|
102
|
+
const published = publishedPq(kd.pq_keys, doc.pq_kid);
|
|
103
|
+
const pub = published !== undefined ? published : chain.keys?.ml_dsa_65_public_key_b64url ?? doc.keys?.ml_dsa_65?.public_key_b64url;
|
|
108
104
|
let linksEd = 0, linksPq = 0, linksChecked = 0, linkFailures = [];
|
|
109
105
|
for (const d of chain.chain ?? []) {
|
|
110
106
|
if (!d.head_sig) continue;
|