pazair-verify 1.9.0 → 1.11.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 +29 -4
- package/bin/cli.js +27 -3
- package/package.json +1 -1
- package/src/credential.js +159 -0
- package/src/fingerprint.js +6 -4
- package/src/index.d.ts +36 -2
- package/src/index.js +85 -7
- package/src/remember.js +15 -4
package/README.md
CHANGED
|
@@ -61,18 +61,38 @@ const h = await checkHead('2026-10-10:3f9c…'); // the head yo
|
|
|
61
61
|
Nothing here asks the issuer whether its chain is fine. The chain is fetched as data and recomputed; the issuer's
|
|
62
62
|
`witness` tool is for the issuer's own count of witnesses, this is yours. SPEC section 14 has the formulas.
|
|
63
63
|
|
|
64
|
-
## Fingerprint:
|
|
64
|
+
## Fingerprint: two prints, 55 of 64 symbols apart
|
|
65
65
|
|
|
66
66
|
Every pass carries a fingerprint (SPEC section 15): a Reed-Solomon codeword over `sha256(origin, agent, first anchored
|
|
67
|
-
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:
|
|
68
70
|
|
|
69
71
|
```js
|
|
70
72
|
import { fingerprintOf, fingerprintDistance, fingerprintSvg, recordOf } from 'pazair-verify';
|
|
71
73
|
const me = await fingerprintOf({ origin: 'https://pazair.kulalabs.ch', agent: 'ag_xyz', first_leaf: doc.fingerprint.first_leaf });
|
|
72
|
-
fingerprintDistance(me, doc.fingerprint); // 0: the issuer
|
|
74
|
+
fingerprintDistance(me, doc.fingerprint); // 0: the issuer drew the print of this origin, agent and leaf
|
|
73
75
|
const svg = fingerprintSvg(me, recordOf(doc.current)); // the same bytes the issuer serves as fingerprint.svg
|
|
74
76
|
```
|
|
75
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
|
+
|
|
76
96
|
## Lower level
|
|
77
97
|
|
|
78
98
|
```js
|
|
@@ -102,11 +122,16 @@ console.log(await verifyReceipt(receipt, { delivery }));
|
|
|
102
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 }` |
|
|
103
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 }` |
|
|
104
124
|
| `receiptHash(receipt)` | The hash a parent receipt lists in `inputs` |
|
|
105
|
-
| `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 |
|
|
106
126
|
| `fingerprintDistance(a, b)` | How many of the 64 symbols differ: 0 the same agent, below 55 not a fingerprint |
|
|
107
127
|
| `fingerprintSvg(codeword, record?)` | The reference drawing, the same bytes for the same input; `recordOf(pass)` reads the record it grows with |
|
|
108
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 |
|
|
109
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 |
|
|
134
|
+
| `verifyLicence(licence, { keys?, pqKeys?, now? })` / `checkLicence(url, { fetch?, now? })` | Section 19, the buyer's licence from an apprenticeship: both signatures, the rules of its level (a signed licence under its pass mark is invalid), whether it is current; `checkLicence` reads a licence URL against the issuer's own keys (`npx pazair-verify licence <url>`) |
|
|
110
135
|
| `leafOf(pass)`, `canonical(obj)`, `sha256hex(s)` | the building blocks of the spec |
|
|
111
136
|
| `fetchKeys(origin?)` | the issuer's keys from `/.well-known/pazair-receipts.json` |
|
|
112
137
|
|
package/bin/cli.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
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.
|
|
6
|
+
// npx pazair-verify licence <licence url> [--json]: the buyer's licence from the apprenticeship (SPEC 19), against the issuer's keys.
|
|
5
7
|
// Exit codes: 0 trusted / consistent, 1 invalid / inconsistent (do not rely on it), 2 usage, 3 unreachable (could not check).
|
|
6
8
|
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { checkHead, checkRemember, checkWordPass } from '../src/index.js';
|
|
9
|
+
import { checkHead, checkLicence, checkRemember, checkWordPass, verifyCredential } from '../src/index.js';
|
|
8
10
|
|
|
9
11
|
const args = process.argv.slice(2);
|
|
10
12
|
if (args.includes('-v') || args.includes('--version')) {
|
|
@@ -14,10 +16,10 @@ if (args.includes('-v') || args.includes('--version')) {
|
|
|
14
16
|
const words = args.filter((a) => !a.startsWith('-'));
|
|
15
17
|
const help = args.includes('-h') || args.includes('--help');
|
|
16
18
|
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';
|
|
19
|
+
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]\n npx pazair-verify licence <licence url> [--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\n npx pazair-verify licence https://pazair.ch/v1/agents/ag_xyz/licence';
|
|
18
20
|
if (!words.length || help) { (help ? console.log : console.error)(usage); process.exit(help ? 0 : 2); }
|
|
19
21
|
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 };
|
|
22
|
+
const codes = { valid: 0, consistent: 0, expired: 1, none: 1, kept_its_word: 0, no_badge_yet: 0, signed_unanchored: 0, inconsistent: 1, invalid: 1, unreachable: 3 };
|
|
21
23
|
|
|
22
24
|
if (words[0] === 'remember') {
|
|
23
25
|
const r = await checkRemember(words[1] ?? 'https://pazair.kulalabs.ch');
|
|
@@ -32,6 +34,28 @@ if (words[0] === 'witness') {
|
|
|
32
34
|
if (json) console.log(JSON.stringify(r, null, 2));
|
|
33
35
|
process.exit(codes[r.verdict] ?? 1);
|
|
34
36
|
}
|
|
37
|
+
if (words[0] === 'credential') {
|
|
38
|
+
if (!words[1]) { console.error(usage); process.exit(2); }
|
|
39
|
+
let vc;
|
|
40
|
+
try {
|
|
41
|
+
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(); }
|
|
42
|
+
else vc = JSON.parse(readFileSync(words[1], 'utf8'));
|
|
43
|
+
} catch { console.log(clean(`No credential could be read at ${words[1]}.`)); process.exit(3); }
|
|
44
|
+
const r = await verifyCredential(vc);
|
|
45
|
+
console.log(clean(r.say));
|
|
46
|
+
// Proof and pass hold: then the day's root on Bitcoin and Stellar, at the pass URL the credential names.
|
|
47
|
+
const anchors = r.valid ? await checkWordPass(r.subject) : null;
|
|
48
|
+
if (anchors) console.log(clean(anchors.say));
|
|
49
|
+
if (json) console.log(JSON.stringify({ ...r, anchors: anchors ? { ...anchors, pass: undefined } : null }, null, 2));
|
|
50
|
+
process.exit(!r.valid ? (codes[r.trust] ?? 1) : (codes[anchors.trust] ?? 1));
|
|
51
|
+
}
|
|
52
|
+
if (words[0] === 'licence' || words[0] === 'license') {
|
|
53
|
+
if (!words[1]) { console.error(usage); process.exit(2); }
|
|
54
|
+
const r = await checkLicence(words[1]);
|
|
55
|
+
console.log(clean(r.say));
|
|
56
|
+
if (json) console.log(JSON.stringify(r, null, 2));
|
|
57
|
+
process.exit(codes[r.verdict] ?? 1);
|
|
58
|
+
}
|
|
35
59
|
const r = await checkWordPass(words[0]);
|
|
36
60
|
console.log(clean(r.say));
|
|
37
61
|
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.11.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",
|
|
@@ -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
|
@@ -10,6 +10,8 @@ export function fetchKeysDocument(origin?: string, f?: typeof fetch): Promise<{
|
|
|
10
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
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 }>;
|
|
12
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 };
|
|
13
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[] }>;
|
|
14
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[] }>;
|
|
15
17
|
export function receiptHash(receipt: Record<string, unknown>): Promise<string>;
|
|
@@ -19,6 +21,8 @@ export function verifyProof(leaf: string, proof: Step[], root: string): Promise<
|
|
|
19
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 }>;
|
|
20
22
|
export type PassCheck = {
|
|
21
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';
|
|
22
26
|
say: string; issuer: string | null;
|
|
23
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;
|
|
24
28
|
pass: Record<string, unknown> | null;
|
|
@@ -46,12 +50,12 @@ export type RememberCheck = { verdict: 'consistent' | 'inconsistent' | 'unreacha
|
|
|
46
50
|
export function checkRemember(origin?: string, opts?: { fetch?: typeof fetch }): Promise<RememberCheck>;
|
|
47
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 }>;
|
|
48
52
|
|
|
49
|
-
// Fingerprint (SPEC section 15):
|
|
53
|
+
// Fingerprint (SPEC section 15): two different prints differ in at least 55 of 64 symbols, provably.
|
|
50
54
|
export const FINGERPRINT: { n: 64; k: 10; d: 55; field: string; generator: string };
|
|
51
55
|
export function rsEncode(msg: Uint8Array): Uint8Array;
|
|
52
56
|
export type Fingerprint = { seed: string; codeword: string };
|
|
53
57
|
export function fingerprintOf(input: { origin: string; agent: string; first_leaf: string }): Promise<Fingerprint>;
|
|
54
|
-
/** 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. */
|
|
55
59
|
export function fingerprintDistance(a: Fingerprint | string, b: Fingerprint | string): number;
|
|
56
60
|
export type FingerprintRecord = { delivered: number; buyers: number; badge: string | null; disputes_lost: number };
|
|
57
61
|
export function recordOf(pass: Record<string, unknown>): FingerprintRecord;
|
|
@@ -62,3 +66,33 @@ export function fingerprintSvg(codeword: Fingerprint | string, record?: Fingerpr
|
|
|
62
66
|
// The second signature (SPEC section 16).
|
|
63
67
|
/** { signed: false } without pq_sig; valid true / false, or null when it cannot be checked here (library missing, key not published). */
|
|
64
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>;
|
|
91
|
+
|
|
92
|
+
// The buyer's licence (SPEC section 19, the apprenticeship).
|
|
93
|
+
export const LICENCE_LEVELS: Record<'basic' | 'professional' | 'critical', { tasks: number; need: number; valid_days: number }>;
|
|
94
|
+
export type LicenceCheck = { valid: boolean; current: boolean; signature_valid: boolean; pq_signed: boolean; ml_dsa_65: boolean | null; rules_hold: boolean; level: string | null; agent: string | null; valid_until: string | null };
|
|
95
|
+
/** Section 19: both signatures and the rules of its level; `current` says whether it has expired. */
|
|
96
|
+
export function verifyLicence(licence: unknown, opts?: { keys?: Jwk[]; pqKeys?: PqKey[]; now?: Date }): Promise<LicenceCheck>;
|
|
97
|
+
/** Section 19: a licence URL checked against the issuer's own keys. */
|
|
98
|
+
export function checkLicence(url: string, opts?: { fetch?: typeof fetch; now?: Date }): Promise<{ verdict: 'valid' | 'expired' | 'none' | 'invalid' | 'unreachable'; agent?: string | null; level?: string; say: string; licences: LicenceCheck[] }>;
|
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;
|
|
@@ -68,6 +70,17 @@ export function wordOf(pass) {
|
|
|
68
70
|
return { kept_pct: kept, badge: enough && kept >= 99 ? 'word_kept_99' : enough && kept >= 95 ? 'word_kept_95' : null };
|
|
69
71
|
}
|
|
70
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
|
+
|
|
71
84
|
/** Section 3.1: the hash a parent receipt names in `inputs`, the child receipt with its signature. */
|
|
72
85
|
export const receiptHash = (receipt) => sha256hex(canonical(receipt));
|
|
73
86
|
|
|
@@ -84,6 +97,8 @@ export async function verifyReceiptChain(top, { receipts = [], keys, maxDepth =
|
|
|
84
97
|
if (!(await verifySignature(p, keys))) return `${p?.order}: signature`;
|
|
85
98
|
if (p.inputs === undefined) return null;
|
|
86
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`;
|
|
87
102
|
if (p.inputs.length && d >= maxDepth) return `${p.order}: deeper than ${maxDepth}`;
|
|
88
103
|
for (const h of p.inputs) {
|
|
89
104
|
const c = byHash.get(h);
|
|
@@ -184,7 +199,9 @@ export async function verifyPass(anchored, { keys, pqKeys, anchoredBefore } = {}
|
|
|
184
199
|
const in_root = anchored?.proof && anchored?.root?.root ? await verifyProof(await leafOf(pass), anchored.proof, anchored.root.root) : null;
|
|
185
200
|
const signature_valid = pass?.kind === 'word_pass' && (await verifySignature(pass, keys, { anchoredBefore: in_root ? anchoredBefore : undefined }));
|
|
186
201
|
const pq = await verifyPqSignature(pass, pqKeys, { anchoredBefore: in_root ? anchoredBefore : undefined });
|
|
187
|
-
const w = wordOf(pass),
|
|
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));
|
|
188
205
|
return {
|
|
189
206
|
valid: signature_valid && pq.valid !== false && in_root !== false && word_consistent,
|
|
190
207
|
signature_valid,
|
|
@@ -323,7 +340,12 @@ export async function verifyHolderProof(proof, { keys, pqKeys, agent, nonce, aud
|
|
|
323
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.
|
|
324
341
|
* Returns a verdict and one sentence to act on.
|
|
325
342
|
*/
|
|
326
|
-
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 } = {}) {
|
|
327
349
|
const none = (say) => ({ trust: 'unreachable', say, issuer: null, checks: null, pass: null });
|
|
328
350
|
let u;
|
|
329
351
|
try { u = new URL(url); } catch { return none('That is not a URL.'); }
|
|
@@ -353,15 +375,21 @@ export async function checkWordPass(url, { fetch: f = fetch, keys, proof, nonce,
|
|
|
353
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';
|
|
354
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).';
|
|
355
377
|
const anchored_ok = stellar === true || btc?.ok === true;
|
|
356
|
-
|
|
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}` };
|
|
357
381
|
}
|
|
358
382
|
|
|
359
383
|
/** One plain sentence about a pass. */
|
|
360
384
|
export function sayPass(p, by = p.issuer) {
|
|
361
385
|
const s = p.as_seller ?? {}, w = p.word ?? {}, n = (x, one, many = one + 's') => `${x} ${x === 1 ? one : many}`;
|
|
362
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')}`;
|
|
363
|
-
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';
|
|
364
|
-
|
|
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}`;
|
|
365
393
|
}
|
|
366
394
|
|
|
367
395
|
// Remember: the chain of daily roots, checked without the issuer (SPEC section 14).
|
|
@@ -371,5 +399,55 @@ export { GENESIS, headOf, recomputeChain, verifyLink, verifyRememberDocument, ch
|
|
|
371
399
|
export { loadMlDsa, verifyMlDsa, pqKid, verifyPqSignature } from './pq.js';
|
|
372
400
|
import { verifyPqSignature } from './pq.js';
|
|
373
401
|
|
|
374
|
-
// Fingerprint (SPEC section 15):
|
|
402
|
+
// Fingerprint (SPEC section 15): two different prints differ in at least 55 of 64 symbols; the reference drawing.
|
|
375
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';
|
|
407
|
+
|
|
408
|
+
// The buyer's licence (SPEC section 19, the apprenticeship): an agent passed an exam of buying for someone else.
|
|
409
|
+
/** Section 19: the levels: tasks in the exam, the pass mark, the days a licence is valid. */
|
|
410
|
+
export const LICENCE_LEVELS = { basic: { tasks: 5, need: 4, valid_days: 365 }, professional: { tasks: 8, need: 7, valid_days: 180 }, critical: { tasks: 10, need: 10, valid_days: 90 } };
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Section 19: a buyer's licence. Both signatures, and the rules of its level: the score reaches the pass mark, the
|
|
414
|
+
* tasks match the level, and it is valid no longer than the level allows. A signed licence that breaks its own level's
|
|
415
|
+
* rules is invalid. `current` says whether it has expired; an expired licence is genuine but no longer shows anything.
|
|
416
|
+
*/
|
|
417
|
+
export async function verifyLicence(licence, { keys, pqKeys, now = new Date() } = {}) {
|
|
418
|
+
keys ??= await fetchKeys();
|
|
419
|
+
const l = licence && typeof licence === 'object' ? licence : {}, lv = LICENCE_LEVELS[l.level];
|
|
420
|
+
const signature_valid = l.kind === 'buyer_licence' && typeof l.issuer === 'string' && (await verifySignature(l, keys));
|
|
421
|
+
const pq = await verifyPqSignature(l, pqKeys);
|
|
422
|
+
const [right, of] = String(l.score ?? '').split('/').map(Number);
|
|
423
|
+
const days = (Date.parse(l.valid_until) - Date.parse(l.issued_at)) / 864e5;
|
|
424
|
+
const rules_hold = !!lv && of === lv.tasks && right >= lv.need && right <= of && Array.isArray(l.tasks) && l.tasks.length === of && days > 0 && days <= lv.valid_days;
|
|
425
|
+
const current = typeof l.valid_until === 'string' && l.valid_until > now.toISOString();
|
|
426
|
+
return { valid: signature_valid && pq.valid !== false && rules_hold, current, signature_valid, pq_signed: pq.signed, ml_dsa_65: pq.valid, rules_hold, level: l.level ?? null, agent: l.agent ?? null, valid_until: l.valid_until ?? null };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Section 19: "Has this agent passed the apprenticeship?", from a licence URL (e.g. <origin>/v1/agents/<id>/licence),
|
|
431
|
+
* checked against the issuer's own keys. verdict: valid (a current licence), expired, none, invalid or unreachable.
|
|
432
|
+
*/
|
|
433
|
+
export async function checkLicence(url, { fetch: f = fetch, now = new Date() } = {}) {
|
|
434
|
+
let u; try { u = new URL(url); } catch { return { verdict: 'invalid', say: 'Not a URL.', licences: [] }; }
|
|
435
|
+
if (u.protocol !== 'https:') return { verdict: 'invalid', say: 'Only https URLs are checked.', licences: [] };
|
|
436
|
+
let doc, kd;
|
|
437
|
+
try { const r = await f(u.href, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(8000) }); if (!r.ok) throw new Error(); doc = await r.json(); }
|
|
438
|
+
catch { return { verdict: 'unreachable', say: `No licence could be read at ${u.host}.`, licences: [] }; }
|
|
439
|
+
try { kd = await fetchKeysDocument(u.origin, f); } catch { return { verdict: 'unreachable', say: `${u.host} publishes no keys at /.well-known/pazair-receipts.json.`, licences: [] }; }
|
|
440
|
+
const list = Array.isArray(doc?.licences) ? doc.licences.map((x) => x?.licence ?? x) : doc?.kind === 'buyer_licence' ? [doc] : [];
|
|
441
|
+
const agent = typeof doc?.agent_id === 'string' ? doc.agent_id : list[0]?.agent ?? null;
|
|
442
|
+
const licences = await Promise.all(list.map(async (l) => {
|
|
443
|
+
const v = await verifyLicence(l, { keys: kd.keys, pqKeys: kd.pq_keys, now });
|
|
444
|
+
// A licence for another agent, served under this one, proves nothing about this one.
|
|
445
|
+
return agent && l?.agent !== agent ? { ...v, valid: false, rules_hold: false } : v;
|
|
446
|
+
}));
|
|
447
|
+
const day = (t) => String(t ?? '').slice(0, 10);
|
|
448
|
+
if (!licences.length) return { verdict: 'none', agent, say: `${agent ?? 'This agent'} holds no buyer's licence from ${u.host}.`, licences };
|
|
449
|
+
if (licences.some((v) => !v.valid)) return { verdict: 'invalid', agent, say: `Do not rely on it: a licence from ${u.host} does not verify against the issuer's keys or breaks its level's rules.`, licences };
|
|
450
|
+
const best = [...licences].reverse().find((v) => v.current);
|
|
451
|
+
if (!best) return { verdict: 'expired', agent, say: `${agent}: every buyer's licence from ${u.host} has expired (the last on ${day(licences.at(-1).valid_until)}).`, licences };
|
|
452
|
+
return { verdict: 'valid', agent, level: best.level, say: `${agent}: ${best.level} buyer's licence from ${u.host}, signatures valid${best.ml_dsa_65 ? ' (Ed25519 and ML-DSA-65)' : ''}, current until ${day(best.valid_until)}. It shows the exam was passed that day; it is no regulatory certification.`, licences };
|
|
453
|
+
}
|
package/src/remember.js
CHANGED
|
@@ -60,11 +60,14 @@ export async function verifyLink(link, sig, { keys = [], pqPublicKey } = {}) {
|
|
|
60
60
|
* The public document (/.well-known/pazair-remember.json): `sig` is Ed25519 over the canonical JSON without "sig" and
|
|
61
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`.
|
|
62
62
|
*/
|
|
63
|
-
export async function verifyRememberDocument(doc, { keys = [] } = {}) {
|
|
63
|
+
export async function verifyRememberDocument(doc, { keys = [], pqKeys } = {}) {
|
|
64
64
|
if (!doc || typeof doc !== 'object') return { ed25519: false, ml_dsa_65: false, pq_kid_matches: false };
|
|
65
65
|
const { pq_sig, ...rest } = doc;
|
|
66
66
|
const { sig, ...body } = rest;
|
|
67
|
-
|
|
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;
|
|
68
71
|
const kidOk = typeof pub === 'string' && (await pqKid(pub)) === doc.pq_kid;
|
|
69
72
|
return {
|
|
70
73
|
ed25519: await verifySignature({ ...body, sig }, keys),
|
|
@@ -73,6 +76,13 @@ export async function verifyRememberDocument(doc, { keys = [] } = {}) {
|
|
|
73
76
|
};
|
|
74
77
|
}
|
|
75
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
|
+
|
|
76
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(); };
|
|
77
87
|
|
|
78
88
|
/**
|
|
@@ -87,9 +97,10 @@ export async function checkRemember(origin = 'https://pazair.kulalabs.ch', { fet
|
|
|
87
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}).`); }
|
|
88
98
|
try { kd = await fetchKeysDocument(o, f); } catch { return none(`${o} publishes no keys at /.well-known/pazair-receipts.json.`); }
|
|
89
99
|
try { chain = await get(f, `${o}/v1/remember/chain`); } catch (e) { return none(`No chain at ${o}/v1/remember/chain (${e.message}).`); }
|
|
90
|
-
const docSig = await verifyRememberDocument(doc, { keys: kd.keys });
|
|
100
|
+
const docSig = await verifyRememberDocument(doc, { keys: kd.keys, pqKeys: kd.pq_keys });
|
|
91
101
|
const re = await recomputeChain(chain.chain ?? []);
|
|
92
|
-
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;
|
|
93
104
|
let linksEd = 0, linksPq = 0, linksChecked = 0, linkFailures = [];
|
|
94
105
|
for (const d of chain.chain ?? []) {
|
|
95
106
|
if (!d.head_sig) continue;
|