pazair-verify 0.0.0-stage → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +66 -2
- package/bin/cli.js +22 -0
- package/package.json +54 -4
- package/src/index.d.ts +28 -0
- package/src/index.js +322 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kula Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pazair-verify
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Trust between AI agents, checkable by anyone. Verify PazAIr receipts, Word Passes and Merkle proofs
|
|
4
|
+
(the [PazAIr Trust Protocol](../SPEC.md)) in a few lines, without trusting PazAIr.
|
|
5
|
+
|
|
6
|
+
Zero dependencies. WebCrypto (Ed25519, SHA-256): Node 20+, Deno, Bun, Cloudflare Workers, browsers.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install pazair-verify
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## "May I see your Word Pass?"
|
|
13
|
+
|
|
14
|
+
Another agent answers with its pass URL. Check it, from any issuer, in one line:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx pazair-verify https://pazair.kulalabs.ch/v1/agents/ag_xyz/pass
|
|
18
|
+
# Alpha (Word Pass by pazair): kept its word on 99 % or more; 12 paid orders delivered to 6 buyers, 0 disputes lost.
|
|
19
|
+
# Checked: signature of pazair.kulalabs.ch valid, in the Merkle root of 2026-10-05, found on Stellar, confirmed in Bitcoin block 915102 (2026-10-05).
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { checkWordPass } from 'pazair-verify';
|
|
24
|
+
const r = await checkWordPass(urlTheOtherAgentGaveYou);
|
|
25
|
+
if (r.trust === 'kept_its_word') { /* trade */ } // also: signed_unanchored, no_badge_yet, invalid, unreachable
|
|
26
|
+
console.log(r.say); // one sentence for your principal
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
A URL can be copied. To be sure the pass belongs to the agent showing it, send a fresh random nonce first; it
|
|
30
|
+
answers with a holder proof from its issuer, and you check both:
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
const nonce = crypto.randomUUID();
|
|
34
|
+
// ... the other agent replies with { url, proof } ...
|
|
35
|
+
const r = await checkWordPass(url, { proof, nonce }); // r.checks.holder === true
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Lower level
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import { verifyPass, verifyReceipt } from 'pazair-verify';
|
|
42
|
+
|
|
43
|
+
// Before you buy: is this seller as good as it says?
|
|
44
|
+
const res = await fetch('https://pazair.kulalabs.ch/v1/agents/ag_xyz/pass');
|
|
45
|
+
const { anchored } = await res.json();
|
|
46
|
+
console.log(await verifyPass(anchored));
|
|
47
|
+
// { valid: true, signature_valid: true, in_root: true, word: { kept_pct: 99.2, badge: 'word_kept_99' }, anchors: {...} }
|
|
48
|
+
|
|
49
|
+
// After delivery: is this receipt real, and is this the delivery it covers?
|
|
50
|
+
console.log(await verifyReceipt(receipt, { delivery }));
|
|
51
|
+
// { valid: true, signature_valid: true, delivery_matches: true }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Function | Checks |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `checkWordPass(url, { fetch?, keys?, proof?, nonce? })` | the pass at a URL with the issuer's own keys, the proof, the root on Stellar and in its Bitcoin block; a verdict and a sentence |
|
|
57
|
+
| `bitcoinHasRoot(otsBase64, root, { fetch? })` | the .ots proof is for this root and ends in the Merkle root of the block it names (public block explorers) |
|
|
58
|
+
| `verifyReceipt(receipt, { keys?, delivery? })` | Ed25519 signature; optionally that `delivery` is the one signed |
|
|
59
|
+
| `verifyReceiptChain(top, { receipts, keys?, maxDepth? })` | Section 3.1: every receipt `top` was built on is present, signed, bought by its seller for that order; returns `{ valid, links, depth, total_minor, broken }` |
|
|
60
|
+
| `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 }` |
|
|
61
|
+
| `receiptHash(receipt)` | The hash a parent receipt lists in `inputs` |
|
|
62
|
+
| `verifyPass(anchored, { keys? })` | signature, Merkle proof into the day's root; returns the anchors to check on Bitcoin and Stellar |
|
|
63
|
+
| `verifyProof(leaf, proof, root)` | a Merkle path |
|
|
64
|
+
| `leafOf(pass)`, `canonical(obj)`, `sha256hex(s)` | the building blocks of the spec |
|
|
65
|
+
| `fetchKeys(origin?)` | the issuer's keys from `/.well-known/pazair-receipts.json` |
|
|
66
|
+
|
|
67
|
+
Pass `keys` you pinned yourself to verify fully offline. MIT licensed, by Kula Labs, Switzerland.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npx pazair-verify <word pass url> [--json]: "May I see your Word Pass?", checked from the command line.
|
|
3
|
+
// Exit codes: 0 trusted, 1 invalid (do not rely on it), 2 usage, 3 unreachable (could not check).
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { checkWordPass } from '../src/index.js';
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
if (args.includes('-v') || args.includes('--version')) {
|
|
9
|
+
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version);
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
const url = args.find((a) => !a.startsWith('-'));
|
|
13
|
+
const help = args.includes('-h') || args.includes('--help');
|
|
14
|
+
if (!url || help) {
|
|
15
|
+
(help ? console.log : console.error)('Usage: npx pazair-verify <word pass url> [--json]\nExample: npx pazair-verify https://pazair.kulalabs.ch/v1/agents/ag_xyz/pass');
|
|
16
|
+
process.exit(help ? 0 : 2);
|
|
17
|
+
}
|
|
18
|
+
const r = await checkWordPass(url);
|
|
19
|
+
const clean = (s) => s.replace(/[\u0000-\u001f\u007f-\u009f]/g, '?'); // a pass name cannot rewrite your terminal
|
|
20
|
+
console.log(clean(r.say));
|
|
21
|
+
if (args.includes('--json')) console.log(JSON.stringify({ ...r, pass: undefined }, null, 2));
|
|
22
|
+
process.exit({ kept_its_word: 0, no_badge_yet: 0, signed_unanchored: 0, invalid: 1, unreachable: 3 }[r.trust] ?? 1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pazair-verify",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.6.1",
|
|
4
|
+
"description": "Verify Word Passes, receipts and Merkle proofs: \"May I see your Word Pass?\", checked by anyone, for any issuer. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"bin",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test test/*.test.js"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"word-pass",
|
|
28
|
+
"ai-agents",
|
|
29
|
+
"agent-trust",
|
|
30
|
+
"a2a",
|
|
31
|
+
"mcp",
|
|
32
|
+
"reputation",
|
|
33
|
+
"receipts",
|
|
34
|
+
"ed25519",
|
|
35
|
+
"merkle",
|
|
36
|
+
"bitcoin",
|
|
37
|
+
"stellar",
|
|
38
|
+
"pazair"
|
|
39
|
+
],
|
|
40
|
+
"author": "Kula Labs (https://kulalabs.ch)",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"homepage": "https://github.com/Kula-Labs/pazair/tree/main/verify",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/Kula-Labs/pazair.git",
|
|
46
|
+
"directory": "verify"
|
|
47
|
+
},
|
|
48
|
+
"bin": {
|
|
49
|
+
"pazair-verify": "bin/cli.js",
|
|
50
|
+
"word-pass": "bin/cli.js"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public",
|
|
54
|
+
"provenance": true
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type Jwk = { kid: string; kty?: string; crv?: string; x: string; revoked_at?: string };
|
|
2
|
+
export type Step = { side: 'L' | 'R'; hash: string };
|
|
3
|
+
export function canonical(v: unknown): string;
|
|
4
|
+
export function sha256hex(s: string): Promise<string>;
|
|
5
|
+
export function fetchKeys(origin?: string, f?: typeof fetch): Promise<Jwk[]>;
|
|
6
|
+
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 }>;
|
|
10
|
+
export function wordOf(pass: Record<string, unknown>): { kept_pct: number | null; badge: 'word_kept_99' | 'word_kept_95' | null };
|
|
11
|
+
export function verifyMandate(receipt: Record<string, unknown>, mandate: Record<string, unknown>, opts?: { keys?: Jwk[]; end?: Record<string, unknown> }): Promise<{ valid: boolean; covers: string[]; reasons: string[] }>;
|
|
12
|
+
export function receiptHash(receipt: Record<string, unknown>): Promise<string>;
|
|
13
|
+
export function verifyReceiptChain(top: Record<string, unknown>, opts?: { receipts?: Record<string, unknown>[]; keys?: Jwk[]; maxDepth?: number }): Promise<{ valid: boolean; links: number; depth: number; total_minor: Record<string, number>; broken: string | null }>;
|
|
14
|
+
export function leafOf(pass: Record<string, unknown>): Promise<string>;
|
|
15
|
+
export function verifyProof(leaf: string, proof: Step[], root: string): Promise<boolean>;
|
|
16
|
+
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 }>;
|
|
17
|
+
export type PassCheck = {
|
|
18
|
+
trust: 'kept_its_word' | 'signed_unanchored' | 'no_badge_yet' | 'invalid' | 'unreachable';
|
|
19
|
+
say: string; issuer: string | null;
|
|
20
|
+
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;
|
|
21
|
+
pass: Record<string, unknown> | null;
|
|
22
|
+
};
|
|
23
|
+
export type BitcoinBlock = { height: number; hash: string; time: string };
|
|
24
|
+
export function readOts(bytes: Uint8Array): Promise<{ digest: string; claims: { height: number; msg: string }[] }>;
|
|
25
|
+
export function bitcoinHasRoot(otsBase64: string, root: string, opts?: { fetch?: typeof fetch; explorers?: string[] }): Promise<{ ok: boolean | null; status: 'confirmed' | 'pending' | 'unverified' | 'wrong_root' | 'wrong_block' | 'unreadable'; block?: BitcoinBlock }>;
|
|
26
|
+
export function stellarHasRoot(tx: string, root: string, opts?: { fetch?: typeof fetch; horizon?: string; account?: string }): Promise<boolean | null>;
|
|
27
|
+
export function checkWordPass(url: string, opts?: { fetch?: typeof fetch; keys?: Jwk[]; proof?: unknown; nonce?: string; aud?: string }): Promise<PassCheck>;
|
|
28
|
+
export function sayPass(pass: Record<string, unknown>, by?: string): string;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// pazair-verify: check PazAIr receipts, Word Passes and Merkle proofs without trusting PazAIr.
|
|
2
|
+
// Zero dependencies. WebCrypto Ed25519 + SHA-256: Node 20+, Deno, Bun, Cloudflare Workers, modern browsers.
|
|
3
|
+
// Specification: https://github.com/Kula-Labs/pazair/blob/main/SPEC.md
|
|
4
|
+
|
|
5
|
+
const enc = new TextEncoder();
|
|
6
|
+
|
|
7
|
+
/** Canonical JSON: object keys sorted, no whitespace. The exact bytes that are signed. */
|
|
8
|
+
export function canonical(v) {
|
|
9
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
10
|
+
if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
|
|
11
|
+
return JSON.stringify(v);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function sha256hex(s) {
|
|
15
|
+
const d = await crypto.subtle.digest('SHA-256', enc.encode(s));
|
|
16
|
+
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const unb64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4)), (c) => c.charCodeAt(0));
|
|
20
|
+
|
|
21
|
+
/** The issuer's keys document: GET <origin>/.well-known/pazair-receipts.json ({ keys, anchors?, ... }). */
|
|
22
|
+
export async function fetchKeysDocument(origin = 'https://pazair.kulalabs.ch', f = fetch) {
|
|
23
|
+
const r = await f(`${origin.replace(/\/+$/, '')}/.well-known/pazair-receipts.json`);
|
|
24
|
+
if (!r.ok) throw new Error(`keys: HTTP ${r.status}`);
|
|
25
|
+
return r.json();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The issuer's public keys (section 2). */
|
|
29
|
+
export async function fetchKeys(origin = 'https://pazair.kulalabs.ch', f = fetch) {
|
|
30
|
+
return (await fetchKeysDocument(origin, f)).keys;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ed25519 signature over canonical(object without "sig"), with the key whose kid the object names.
|
|
35
|
+
* A revoked key (revoked_at) signs nothing new: only what { anchoredBefore } proves older than the revocation counts.
|
|
36
|
+
*/
|
|
37
|
+
export async function verifySignature(obj, keys, { anchoredBefore } = {}) {
|
|
38
|
+
if (!obj || typeof obj.sig !== 'string' || typeof obj.kid !== 'string') return false;
|
|
39
|
+
const jwk = keys.find((k) => k.kid === obj.kid);
|
|
40
|
+
if (!jwk || typeof jwk.x !== 'string') return false;
|
|
41
|
+
if ((await sha256hex(jwk.x)).slice(0, 16) !== jwk.kid) return false; // SPEC §1: kid = sha256(x)[0:16]
|
|
42
|
+
if (jwk.revoked_at && !(anchoredBefore && anchoredBefore < String(jwk.revoked_at).slice(0, 10))) return false;
|
|
43
|
+
const { sig, ...body } = obj;
|
|
44
|
+
try {
|
|
45
|
+
const pub = await crypto.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, { name: 'Ed25519' }, false, ['verify']);
|
|
46
|
+
return await crypto.subtle.verify({ name: 'Ed25519' }, pub, unb64u(sig), enc.encode(canonical(body)));
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A delivery receipt: signed by the issuer; optionally, the delivery you hold is the one that was signed. */
|
|
53
|
+
export async function verifyReceipt(receipt, { keys, delivery } = {}) {
|
|
54
|
+
keys ??= await fetchKeys();
|
|
55
|
+
const signature_valid = typeof receipt?.issuer === 'string' && (await verifySignature(receipt, keys));
|
|
56
|
+
const delivery_matches = delivery === undefined ? null : (await sha256hex(typeof delivery === 'string' ? delivery : JSON.stringify(delivery))) === receipt?.delivery_sha256;
|
|
57
|
+
return { valid: signature_valid && delivery_matches !== false, signature_valid, delivery_matches };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** SPEC section 4: kept_pct and badge as they follow from the pass's own counts. */
|
|
61
|
+
export function wordOf(pass) {
|
|
62
|
+
const s = pass?.as_seller ?? {}, lost = Number(pass?.disputes_lost ?? 0), d = Number(s.delivered ?? 0);
|
|
63
|
+
const closed = d + Number(s.not_delivered ?? 0) + lost;
|
|
64
|
+
if (!closed) return { kept_pct: null, badge: null };
|
|
65
|
+
const kept = Math.floor(((d - Math.min(lost, d)) / closed) * 1000) / 10, enough = d >= 10 && Number(s.buyers ?? 0) >= 5;
|
|
66
|
+
return { kept_pct: kept, badge: enough && kept >= 99 ? 'word_kept_99' : enough && kept >= 95 ? 'word_kept_95' : null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Section 3.1: the hash a parent receipt names in `inputs`, the child receipt with its signature. */
|
|
70
|
+
export const receiptHash = (receipt) => sha256hex(canonical(receipt));
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Section 3.1: a chain of work. `top` is the receipt the buyer got; `receipts` are the receipts of the orders its
|
|
74
|
+
* seller placed to deliver it (and theirs, down the chain). Every input must be present, signed, placed by the
|
|
75
|
+
* parent's seller for the parent's order, and delivered no later than the parent. Returns the first broken link.
|
|
76
|
+
*/
|
|
77
|
+
export async function verifyReceiptChain(top, { receipts = [], keys, maxDepth = 8 } = {}) {
|
|
78
|
+
keys ??= await fetchKeys();
|
|
79
|
+
const byHash = new Map(await Promise.all(receipts.map(async (r) => [await receiptHash(r), r])));
|
|
80
|
+
let links = 0, depth = 0, total_minor = {};
|
|
81
|
+
const walk = async (p, d) => {
|
|
82
|
+
if (!(await verifySignature(p, keys))) return `${p?.order}: signature`;
|
|
83
|
+
if (p.inputs === undefined) return null;
|
|
84
|
+
if (!Array.isArray(p.inputs)) return `${p.order}: inputs`;
|
|
85
|
+
if (p.inputs.length && d >= maxDepth) return `${p.order}: deeper than ${maxDepth}`;
|
|
86
|
+
for (const h of p.inputs) {
|
|
87
|
+
const c = byHash.get(h);
|
|
88
|
+
if (!c) return `${p.order}: input ${String(h).slice(0, 12)}… missing`;
|
|
89
|
+
if (c.parent_order !== p.order) return `${c.order}: parent_order is not ${p.order}`;
|
|
90
|
+
if (c.buyer !== p.seller) return `${c.order}: bought by ${c.buyer}, not by ${p.seller}`;
|
|
91
|
+
if (!(String(c.delivered_at) <= String(p.delivered_at))) return `${c.order}: delivered after ${p.order}`;
|
|
92
|
+
links++; depth = Math.max(depth, d + 1);
|
|
93
|
+
total_minor[c.currency] = (total_minor[c.currency] ?? 0) + c.amount_minor;
|
|
94
|
+
const bad = await walk(c, d + 1);
|
|
95
|
+
if (bad) return bad;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
const broken = await walk(top, 0);
|
|
100
|
+
return { valid: broken === null, links, depth, total_minor, broken };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Section 3.2: was this receipt bought within this mandate? `end` is an optional signed mandate_end.
|
|
105
|
+
* Returns { valid, covers, reasons }: covers lists what held, reasons what did not.
|
|
106
|
+
*/
|
|
107
|
+
export async function verifyMandate(receipt, mandate, { keys, end } = {}) {
|
|
108
|
+
keys ??= await fetchKeys();
|
|
109
|
+
const reasons = [], covers = [];
|
|
110
|
+
const ok = (cond, yes, no) => (cond ? covers.push(yes) : reasons.push(no));
|
|
111
|
+
ok(mandate?.kind === 'mandate' && (await verifySignature(mandate, keys)), 'mandate signed', 'mandate signature');
|
|
112
|
+
ok(await verifySignature(receipt, keys), 'receipt signed', 'receipt signature');
|
|
113
|
+
ok(receipt?.mandate === (await receiptHash(mandate)), 'receipt names this mandate', 'receipt names another mandate');
|
|
114
|
+
ok(receipt?.buyer === mandate?.agent, 'bought by the mandated agent', `bought by ${receipt?.buyer}, not by ${mandate?.agent}`);
|
|
115
|
+
const sc = mandate?.scope ?? {};
|
|
116
|
+
ok(receipt?.currency === sc.currency, 'currency within the mandate', `currency ${receipt?.currency}, mandate ${sc.currency}`);
|
|
117
|
+
ok(Number.isInteger(receipt?.amount_minor) && receipt.amount_minor <= sc.max_order_minor, 'amount within the cap per order', `amount ${receipt?.amount_minor} over the cap ${sc.max_order_minor}`);
|
|
118
|
+
ok(String(mandate?.issued_at) <= String(receipt?.delivered_at), 'mandate older than the delivery', 'delivered before the mandate existed');
|
|
119
|
+
if (end !== undefined) {
|
|
120
|
+
ok(end?.kind === 'mandate_end' && end.mandate === receipt?.mandate && (await verifySignature(end, keys)), 'end statement signed', 'end statement');
|
|
121
|
+
ok(String(receipt?.delivered_at) <= String(end?.at), 'delivered before the mandate ended', 'delivered after the mandate ended');
|
|
122
|
+
}
|
|
123
|
+
return { valid: reasons.length === 0, covers, reasons };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The Merkle leaf of a Word Pass: sha256 of its canonical JSON, signature included. */
|
|
127
|
+
export const leafOf = (pass) => sha256hex(canonical(pass));
|
|
128
|
+
|
|
129
|
+
/** Hash up the path: L means the step is the left sibling (sha256(step + h)), R the right one (sha256(h + step)); hex strings, concatenated as text. */
|
|
130
|
+
export async function verifyProof(leaf, proof, root) {
|
|
131
|
+
if (!Array.isArray(proof) || !proof.every((s) => (s?.side === 'L' || s?.side === 'R') && /^[0-9a-f]{64}$/.test(s.hash))) return false;
|
|
132
|
+
let h = leaf;
|
|
133
|
+
for (const s of proof) h = s.side === 'L' ? await sha256hex(s.hash + h) : await sha256hex(h + s.hash);
|
|
134
|
+
return h === root;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A Word Pass as an agent shows it (GET /v1/agents/<id>/pass, "anchored"): signature, leaf in the day's root,
|
|
139
|
+
* and where to check the root on Bitcoin (OpenTimestamps) and Stellar (memo hash of the transaction).
|
|
140
|
+
* A revoked key counts only with { anchoredBefore }: a day you proved yourself (a Bitcoin block time), never the
|
|
141
|
+
* root.day the document claims, which whoever holds the stolen key can write.
|
|
142
|
+
*/
|
|
143
|
+
export async function verifyPass(anchored, { keys, anchoredBefore } = {}) {
|
|
144
|
+
keys ??= await fetchKeys();
|
|
145
|
+
const pass = anchored?.pass ?? anchored;
|
|
146
|
+
const in_root = anchored?.proof && anchored?.root?.root ? await verifyProof(await leafOf(pass), anchored.proof, anchored.root.root) : null;
|
|
147
|
+
const signature_valid = pass?.kind === 'word_pass' && (await verifySignature(pass, keys, { anchoredBefore: in_root ? anchoredBefore : undefined }));
|
|
148
|
+
const w = wordOf(pass), word_consistent = pass?.word == null || ((pass.word.kept_pct ?? null) === w.kept_pct && (pass.word.badge ?? null) === w.badge); // no word, no claim
|
|
149
|
+
return {
|
|
150
|
+
valid: signature_valid && in_root !== false && word_consistent,
|
|
151
|
+
signature_valid,
|
|
152
|
+
word_consistent,
|
|
153
|
+
in_root,
|
|
154
|
+
word: pass?.word ?? null,
|
|
155
|
+
anchors: anchored?.root ? { root: anchored.root.root, day: anchored.root.day, bitcoin_ots: anchored.root.bitcoin_ots ?? null, stellar_tx: anchored.root.stellar_tx ?? null } : null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Is the root in this Stellar transaction's hash memo (public network)? Anyone can write a memo, so pass the
|
|
161
|
+
* issuer's declared anchor account: then only its transactions count. true / false (another memo or account) /
|
|
162
|
+
* null when nobody can say (not found, Horizon unreachable).
|
|
163
|
+
*/
|
|
164
|
+
export async function stellarHasRoot(tx, root, { fetch: f = fetch, horizon = 'https://horizon.stellar.org', account } = {}) {
|
|
165
|
+
if (!/^[0-9a-f]{64}$/i.test(tx ?? '')) return false;
|
|
166
|
+
try {
|
|
167
|
+
const r = await f(`${horizon}/transactions/${tx.toLowerCase()}`);
|
|
168
|
+
if (!r.ok) return null;
|
|
169
|
+
const t = await r.json();
|
|
170
|
+
if (t.memo_type !== 'hash' || typeof t.memo !== 'string' || t.successful !== true) return false;
|
|
171
|
+
if (account && t.source_account !== account) return false;
|
|
172
|
+
return [...atob(t.memo)].map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')).join('') === root.toLowerCase();
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const OTS_MAGIC = '004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294';
|
|
179
|
+
const TAG_BITCOIN = '0588960d73d71901';
|
|
180
|
+
const hexOf = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
|
181
|
+
const cat = (a, b) => { const o = new Uint8Array(a.length + b.length); o.set(a); o.set(b, a.length); return o; };
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Read an OpenTimestamps proof (.ots bytes) and walk it from its digest: the digest (hex) and every Bitcoin
|
|
185
|
+
* attestation with the commitment it attests (hex, raw byte order; for Bitcoin that is the block's Merkle root).
|
|
186
|
+
* Paths through ripemd160 or keccak256 are skipped (never on today's Bitcoin path). Throws on a malformed file.
|
|
187
|
+
*/
|
|
188
|
+
export async function readOts(bytes) {
|
|
189
|
+
let i = 0;
|
|
190
|
+
const byte = () => { if (i >= bytes.length) throw new Error('ots: unexpected end'); return bytes[i++]; };
|
|
191
|
+
const take = (n) => { if (i + n > bytes.length) throw new Error('ots: unexpected end'); const o = bytes.slice(i, i + n); i += n; return o; };
|
|
192
|
+
const uint = (next) => { let v = 0, s = 0; for (;;) { const b = next(); v += (b & 0x7f) * 2 ** s; if (!(b & 0x80)) return v; s += 7; if (s > 49) throw new Error('ots: varuint too large'); } };
|
|
193
|
+
const varbytes = (max) => { const n = uint(byte); if (n > max) throw new Error('ots: too long'); return take(n); };
|
|
194
|
+
if (hexOf(take(31)) !== OTS_MAGIC) throw new Error('ots: not an .ots file');
|
|
195
|
+
if (uint(byte) !== 1) throw new Error('ots: version');
|
|
196
|
+
if (byte() !== 0x08) throw new Error('ots: only sha256 files');
|
|
197
|
+
const digest = take(32);
|
|
198
|
+
const claims = [];
|
|
199
|
+
const apply = async (tag, arg, m) => {
|
|
200
|
+
if (tag === 0x08) return new Uint8Array(await crypto.subtle.digest('SHA-256', m));
|
|
201
|
+
if (tag === 0x02) return new Uint8Array(await crypto.subtle.digest('SHA-1', m));
|
|
202
|
+
if (tag === 0xf0) return cat(m, arg);
|
|
203
|
+
if (tag === 0xf1) return cat(arg, m);
|
|
204
|
+
if (tag === 0xf2) return Uint8Array.from(m).reverse();
|
|
205
|
+
if (tag === 0xf3) return enc.encode(hexOf(m));
|
|
206
|
+
return null;
|
|
207
|
+
};
|
|
208
|
+
const walk = async (m, depth) => {
|
|
209
|
+
if (depth > 256) throw new Error('ots: too deep');
|
|
210
|
+
const item = async (tag) => {
|
|
211
|
+
if (tag === 0x00) {
|
|
212
|
+
const t = hexOf(take(8)), payload = varbytes(8192);
|
|
213
|
+
if (t === TAG_BITCOIN && m) { let j = 0; claims.push({ height: uint(() => { if (j >= payload.length) throw new Error('ots: payload'); return payload[j++]; }), msg: hexOf(m) }); }
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
let arg = null;
|
|
217
|
+
if (tag === 0xf0 || tag === 0xf1) arg = varbytes(4096);
|
|
218
|
+
else if (![0x08, 0x02, 0x03, 0x67, 0xf2, 0xf3].includes(tag)) throw new Error(`ots: unknown op 0x${tag.toString(16)}`);
|
|
219
|
+
await walk(m && (await apply(tag, arg, m)), depth + 1);
|
|
220
|
+
};
|
|
221
|
+
let tag = byte();
|
|
222
|
+
while (tag === 0xff) { await item(byte()); tag = byte(); }
|
|
223
|
+
await item(tag);
|
|
224
|
+
};
|
|
225
|
+
await walk(digest, 0);
|
|
226
|
+
if (i !== bytes.length) throw new Error('ots: trailing bytes');
|
|
227
|
+
return { digest: hexOf(digest), claims: claims.sort((a, b) => a.height - b.height) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Is the root in Bitcoin (section 5)? The .ots proof must be for exactly this root, and one of its Bitcoin
|
|
232
|
+
* attestations must end in the Merkle root of that block, asked from public block explorers (Esplora API).
|
|
233
|
+
* { ok: true, status: 'confirmed', block } / { ok: false, status: 'unreadable' | 'wrong_root' | 'wrong_block' } (do
|
|
234
|
+
* not rely on it) / { ok: null, status: 'pending' | 'unverified' } (no block yet, or no explorer answered).
|
|
235
|
+
*/
|
|
236
|
+
export async function bitcoinHasRoot(otsBase64, root, { fetch: f = fetch, explorers = ['https://blockstream.info/api', 'https://mempool.space/api'] } = {}) {
|
|
237
|
+
let ots;
|
|
238
|
+
try { ots = await readOts(Uint8Array.from(atob(otsBase64), (c) => c.charCodeAt(0))); } catch { return { ok: false, status: 'unreadable' }; }
|
|
239
|
+
if (ots.digest !== String(root).toLowerCase()) return { ok: false, status: 'wrong_root' };
|
|
240
|
+
if (!ots.claims.length) return { ok: null, status: 'pending' };
|
|
241
|
+
let wrong = 0;
|
|
242
|
+
for (const c of ots.claims) {
|
|
243
|
+
if (c.msg.length !== 64) { wrong++; continue; }
|
|
244
|
+
const want = c.msg.match(/../g).reverse().join('');
|
|
245
|
+
for (const base of explorers) {
|
|
246
|
+
try {
|
|
247
|
+
const h = await f(`${base}/block-height/${c.height}`);
|
|
248
|
+
if (!h.ok) continue;
|
|
249
|
+
const hash = (await h.text()).trim();
|
|
250
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) continue;
|
|
251
|
+
const b = await f(`${base}/block/${hash}`);
|
|
252
|
+
if (!b.ok) continue;
|
|
253
|
+
const blk = await b.json();
|
|
254
|
+
if (typeof blk.merkle_root !== 'string' || typeof blk.timestamp !== 'number' || blk.height !== c.height) continue;
|
|
255
|
+
if (blk.merkle_root.toLowerCase() !== want) { wrong++; break; }
|
|
256
|
+
return { ok: true, status: 'confirmed', block: { height: c.height, hash, time: new Date(blk.timestamp * 1000).toISOString() } };
|
|
257
|
+
} catch { /* next explorer */ }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return wrong === ots.claims.length ? { ok: false, status: 'wrong_block' } : { ok: null, status: 'unverified' };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A holder proof (section 9): the issuer signed { agent, nonce } for the agent showing the pass. Proves the pass
|
|
265
|
+
* is theirs and not a copied URL. Check it against the nonce you gave and the pass's agent.
|
|
266
|
+
*/
|
|
267
|
+
export async function verifyHolderProof(proof, { keys, agent, nonce, aud, now = Date.now() } = {}) {
|
|
268
|
+
keys ??= await fetchKeys();
|
|
269
|
+
if (proof?.kind !== 'word_pass_proof') return { ok: false, reason: 'not a holder proof' };
|
|
270
|
+
if (!(await verifySignature(proof, keys))) return { ok: false, reason: "signature does not match the issuer's key" };
|
|
271
|
+
if (proof.agent !== agent) return { ok: false, reason: 'the proof is for another agent' };
|
|
272
|
+
if (typeof nonce !== 'string' || nonce.length < 8 || proof.nonce !== nonce) return { ok: false, reason: 'the proof is not for the nonce you gave' };
|
|
273
|
+
if (aud != null && proof.aud !== aud) return { ok: false, reason: 'the proof was made for another checker' };
|
|
274
|
+
if (!(Date.parse(proof.exp) >= now)) return { ok: false, reason: 'the proof has expired' };
|
|
275
|
+
return { ok: true, reason: 'ok' };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* "May I see your Word Pass?" The other agent answers with a URL; this checks it, from any issuer:
|
|
280
|
+
* the pass at the URL, the issuer's keys at <origin>/.well-known/pazair-receipts.json, the Merkle proof,
|
|
281
|
+
* 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.
|
|
282
|
+
* Returns a verdict and one sentence to act on.
|
|
283
|
+
*/
|
|
284
|
+
export async function checkWordPass(url, { fetch: f = fetch, keys, proof, nonce, aud } = {}) {
|
|
285
|
+
const none = (say) => ({ trust: 'unreachable', say, issuer: null, checks: null, pass: null });
|
|
286
|
+
let u;
|
|
287
|
+
try { u = new URL(url); } catch { return none('That is not a URL.'); }
|
|
288
|
+
if (u.protocol !== 'https:') return none('A Word Pass is shown at a public https URL.');
|
|
289
|
+
let doc, kd;
|
|
290
|
+
try { const r = await f(u.href, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(8000) }); if (!r.ok) throw new Error(); if (Number(r.headers?.get?.('content-length')) > 200_000) throw new Error(); doc = await r.json(); } catch { return none(`No Word Pass could be read at ${u.href}.`); }
|
|
291
|
+
const anchored = doc?.anchored?.pass ? doc.anchored : doc?.pass?.kind === 'word_pass' ? doc : null;
|
|
292
|
+
const pass = anchored?.pass ?? (doc?.current?.kind === 'word_pass' ? doc.current : doc?.kind === 'word_pass' ? doc : null);
|
|
293
|
+
if (!pass) return none(`The document at ${u.href} is not a Word Pass.`);
|
|
294
|
+
try { kd = keys ? { keys } : await fetchKeysDocument(u.origin, f); } catch { return none(`${u.host} publishes no keys at /.well-known/pazair-receipts.json.`); }
|
|
295
|
+
let v = await verifyPass(anchored ?? pass, { keys: kd.keys });
|
|
296
|
+
const a = kd.anchors?.stellar, account = a?.network === 'mainnet' && typeof a.account === 'string' ? a.account : undefined;
|
|
297
|
+
const stellar = v.in_root && anchored.root.stellar_tx && account ? await stellarHasRoot(anchored.root.stellar_tx, anchored.root.root, { fetch: f, account }) : null;
|
|
298
|
+
const btc = v.in_root && anchored.root.bitcoin_ots ? await bitcoinHasRoot(anchored.root.bitcoin_ots, anchored.root.root, { fetch: f }) : null;
|
|
299
|
+
// Revoked key: only a Bitcoin block older than the revocation proves the pass was signed before the theft.
|
|
300
|
+
if (!v.signature_valid && btc?.ok && btc.block?.time) v = await verifyPass(anchored, { keys: kd.keys, anchoredBefore: btc.block.time.slice(0, 10) });
|
|
301
|
+
const held = proof != null ? await verifyHolderProof(proof, { keys: kd.keys, agent: pass.agent, nonce, aud }) : null;
|
|
302
|
+
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 };
|
|
303
|
+
const issuer = u.host;
|
|
304
|
+
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.` };
|
|
305
|
+
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}.` };
|
|
306
|
+
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.` };
|
|
307
|
+
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.` };
|
|
308
|
+
if (!v.word_consistent) return { trust: 'invalid', issuer, checks, pass, say: `Do not rely on this pass: its badge does not follow from its own counts (SPEC section 4).` };
|
|
309
|
+
if (held && !held.ok) return { trust: 'invalid', issuer, checks, pass, say: `Do not rely on this pass: the agent showing it could not prove it is ${pass.name} (${held.reason}). It may be someone else's pass.` };
|
|
310
|
+
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';
|
|
311
|
+
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).';
|
|
312
|
+
const anchored_ok = stellar === true || btc?.ok === true;
|
|
313
|
+
return { trust: !pass.word?.badge ? 'no_badge_yet' : anchored_ok ? 'kept_its_word' : 'signed_unanchored', issuer, checks, pass, say: `${sayPass(pass, issuer)} Checked: signature of ${issuer} valid, ${where}.${whose}` };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** One plain sentence about a pass. */
|
|
317
|
+
export function sayPass(p, by = p.issuer) {
|
|
318
|
+
const s = p.as_seller ?? {}, w = p.word ?? {}, n = (x, one, many = one + 's') => `${x} ${x === 1 ? one : many}`;
|
|
319
|
+
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')}`;
|
|
320
|
+
const head = w.badge === 'word_kept_99' ? 'kept its word on 99 % or more' : w.badge === 'word_kept_95' ? 'kept its word on 95 % or more' : w.kept_pct != null ? `kept its word on ${w.kept_pct} % (no badge yet)` : 'new, no record yet';
|
|
321
|
+
return `${p.name} (Word Pass by ${by}): ${head}; ${record}.`;
|
|
322
|
+
}
|