agent-receipt-verify 0.0.0-stage → 0.2.2

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.
Files changed (40) hide show
  1. package/CONFORMANCE.md +110 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +39 -0
  4. package/README.md +273 -2
  5. package/bench/eip3009-beleid.mjs +113 -0
  6. package/bench/robustness.mjs +264 -0
  7. package/bin/cli.mjs +95 -0
  8. package/examples/receipt-live-base-sepolia-2.json +21 -0
  9. package/examples/receipt-live-base-sepolia-3.json +21 -0
  10. package/examples/receipt-live-base-sepolia.json +21 -0
  11. package/package.json +61 -4
  12. package/profiles/profile.schema.json +115 -0
  13. package/profiles/x402-deployed-ladder-v1.json +178 -0
  14. package/src/eip3009-autorisatie.mjs +228 -0
  15. package/src/inam-countersigned.mjs +162 -0
  16. package/src/mcp-server.mjs +262 -0
  17. package/src/profiel.mjs +145 -0
  18. package/src/x402-conformance-check.mjs +194 -0
  19. package/src/x402-conformance-treden.mjs +246 -0
  20. package/src/x402-discovery-census.mjs +247 -0
  21. package/src/x402-index-liveness.mjs +201 -0
  22. package/src/x402-link-onchain.mjs +280 -0
  23. package/src/x402-metingen-bundel.mjs +96 -0
  24. package/src/x402-offer-receipt.mjs +84 -0
  25. package/src/x402-poortwachters.mjs +241 -0
  26. package/tools/bundel-bouw.mjs +126 -0
  27. package/tools/pack-inhoud.mjs +96 -0
  28. package/vectors/inam/BRON-README.md +38 -0
  29. package/vectors/inam/canonical-vectors.json +122 -0
  30. package/vectors/inam/vectors.json +96 -0
  31. package/vectors/kanon/amount-insufficient-001.json +52 -0
  32. package/vectors/kanon/cross-chain-replay-001.json +54 -0
  33. package/vectors/kanon/cross-contract-replay-001.json +54 -0
  34. package/vectors/kanon/domain-name-mismatch-001.json +53 -0
  35. package/vectors/kanon/expired-001.json +51 -0
  36. package/vectors/kanon/network-mismatch-001.json +52 -0
  37. package/vectors/kanon/nonce-replay-001.json +56 -0
  38. package/vectors/kanon/not-yet-valid-001.json +51 -0
  39. package/vectors/kanon/sig-malleable-high-s-001.json +54 -0
  40. package/vectors/kanon/valid-baseline-001.json +52 -0
@@ -0,0 +1,194 @@
1
+ // Meet een x402-eindpunt tegen de conformance-ladder uit CONFORMANCE.md.
2
+ //
3
+ // WAAROM DIT BESTAAT, en het is geen getal maar een EENHEID. Er zijn metingen van
4
+ // de x402-wereld in omloop ("zoveel duizend resources", "nul declareren de
5
+ // offer-receipt-extensie") en geen enkele zegt wat er precies geteld is. Zonder
6
+ // gedefinieerde eenheid is elk percentage onvergelijkbaar: twintig eindpunten van
7
+ // een operator tellen dan twintig keer zo zwaar als een operator met een
8
+ // eindpunt, en "werkt" betekent voor iedere meter iets anders. Dat is precies hoe
9
+ // een eerdere eigen meting een onzinnige kop opleverde (15 procent, volledig
10
+ // bepaald door een partij met honderden paden) tot er per operator geteld werd.
11
+ //
12
+ // Dit bestand is daarom de meetlat, niet de uitslag. Wie een andere uitslag
13
+ // krijgt kan hier nalezen wat wij geteld hebben en waar hij het anders doet.
14
+ //
15
+ // GEDRAGSREGELS, hard, want dit raakt eindpunten van anderen:
16
+ // - precies een GET of POST per eindpunt, zoals een gewone koper
17
+ // - NOOIT betalen, nooit fuzzen, nooit herhalen om iets af te dwingen
18
+ // - een eerlijke user-agent zodat iedereen in zijn log ziet wie dit was
19
+ // - rustig tempo tussen eindpunten
20
+ //
21
+ // WAT NIET MEETBAAR IS ZONDER TE BETALEN, en dat staat met opzet in de uitslag in
22
+ // plaats van weggelaten: trede 3 tot 5 vereisen een echte afrekening. Een
23
+ // eindpunt dat daar "ongemeten" scoort is NIET afgekeurd; het is niet getoetst.
24
+
25
+ import { verifyOfferSignature, TREDEN } from './x402-conformance-treden.mjs'
26
+
27
+ const UA = 'x402-conformance-check/1 (read-only, github.com/holistis/agent-receipt-verify)'
28
+
29
+ const slaap = (ms) => new Promise((r) => setTimeout(r, ms))
30
+
31
+ /** Base64-JSON uit een header halen zonder op een kapotte header te klappen. */
32
+ function decodeerHeader(waarde) {
33
+ if (!waarde) return null
34
+ try {
35
+ return JSON.parse(Buffer.from(waarde, 'base64').toString('utf8'))
36
+ } catch {
37
+ try {
38
+ return JSON.parse(waarde)
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Trede 0 tot 2 van de ladder, meetbaar zonder te betalen.
47
+ *
48
+ * Geeft per trede HAALT / HAALT_NIET / ONGEMETEN terug plus het bewijs waarop dat
49
+ * oordeel rust, zodat iemand anders hetzelfde eindpunt kan nameten en kan zien
50
+ * waar hij afwijkt.
51
+ */
52
+ export async function meetEindpunt(url, { fetchImpl = fetch, methode = 'POST', lichaam = null } = {}) {
53
+ const r = { url, treden: {}, bewijs: {} }
54
+ let resp
55
+ try {
56
+ resp = await fetchImpl(url, {
57
+ method: methode,
58
+ headers: { 'User-Agent': UA, Accept: 'application/json', 'Content-Type': 'application/json' },
59
+ // Een gedeclareerd voorbeeldlichaam als de index er een geeft. Een lege {} naar
60
+ // een route die velden eist, levert een 400 die niets over x402 zegt: dan meet
61
+ // je je eigen verzoek in plaats van het eindpunt.
62
+ body: methode === 'GET' || methode === 'DELETE' ? undefined : lichaam != null ? JSON.stringify(lichaam) : '{}',
63
+ signal: AbortSignal.timeout(15000),
64
+ })
65
+ } catch (e) {
66
+ r.treden.t0 = 'ONGEMETEN'
67
+ r.bewijs.t0 = 'not reachable: ' + String(e.message).slice(0, 80)
68
+ return r
69
+ }
70
+
71
+ r.bewijs.status = resp.status
72
+ const tekst = await resp.text().catch(() => '')
73
+ const kopNaam = [...resp.headers.keys()].find((k) => /^payment-required$/i.test(k))
74
+ const uitKop = decodeerHeader(kopNaam ? resp.headers.get(kopNaam) : null)
75
+ const uitLichaam = tekst.trim() ? decodeerHeader(tekst) : null
76
+ const aanbod = uitKop ?? uitLichaam
77
+
78
+ // TREDE 0: vraagt dit eindpunt uberhaupt om betaling, op de manier die x402
79
+ // voorschrijft? Een 402 met niets erin haalt dit NIET: de eisen horen mee te
80
+ // komen, anders weet een koper niet wat hij moet betalen.
81
+ if (resp.status !== 402) {
82
+ r.treden.t0 = 'HAALT_NIET'
83
+ r.bewijs.t0 = 'status ' + resp.status + ' instead of 402'
84
+ return r
85
+ }
86
+ const accepts = aanbod?.accepts ?? aanbod?.paymentRequirements
87
+ if (!Array.isArray(accepts) || accepts.length === 0) {
88
+ r.treden.t0 = 'HAALT_NIET'
89
+ r.bewijs.t0 = kopNaam
90
+ ? 'a 402 with a Payment-Required header but no usable accepts'
91
+ : 'a 402 with no payment requirements, neither in the header nor in the body'
92
+ return r
93
+ }
94
+ const a = accepts[0]
95
+ const mist = ['scheme', 'network', 'payTo'].filter((k) => !a?.[k])
96
+ if (!(a?.maxAmountRequired ?? a?.amount)) mist.push('an amount')
97
+ if (mist.length) {
98
+ r.treden.t0 = 'HAALT_NIET'
99
+ r.bewijs.t0 = 'accepts[0] is missing: ' + mist.join(', ')
100
+ return r
101
+ }
102
+ r.treden.t0 = 'HAALT'
103
+ r.bewijs.t0 = a.scheme + ' on ' + a.network + ', ' + (a.maxAmountRequired ?? a.amount) + ' to ' + a.payTo
104
+ r.bewijs.accepts0 = { scheme: a.scheme, network: a.network, asset: a.asset ?? null, payTo: a.payTo }
105
+
106
+ // TREDE 1: declareert het de offer-receipt-extensie? Dat is de stap van "ik wil
107
+ // geld" naar "ik geef een bewijsstuk af".
108
+ const ext = aanbod?.extensions ?? {}
109
+ const orSleutel = Object.keys(ext).find((k) => /offer-?receipt/i.test(k))
110
+ if (!orSleutel) {
111
+ r.treden.t1 = 'HAALT_NIET'
112
+ r.bewijs.t1 = 'no offer-receipt in extensions (' + (Object.keys(ext).join(', ') || 'no extensions') + ')'
113
+ return r
114
+ }
115
+ r.treden.t1 = 'HAALT'
116
+ r.bewijs.t1 = 'declares ' + orSleutel
117
+
118
+ // TREDE 2: zit er een ONDERTEKEND aanbod in, en klopt die handtekening? Een
119
+ // declaratie zonder geldige handtekening is een bewering over een bewering.
120
+ const aanbiedingen = ext[orSleutel]?.info?.offers ?? []
121
+ if (!Array.isArray(aanbiedingen) || aanbiedingen.length === 0) {
122
+ r.treden.t2 = 'HAALT_NIET'
123
+ r.bewijs.t2 = 'declares the extension but carries no signed offer'
124
+ return r
125
+ }
126
+ const controle = await verifyOfferSignature(aanbiedingen[0])
127
+ // ONGEMETEN, niet HAALT_NIET, als de controle niet KON draaien: een onbekend
128
+ // algoritme of een sleutel die niet op te halen is, zegt niets over de partij.
129
+ // Dit bestand beweert dat beginsel in zijn eigen kop al en volgde het hier niet.
130
+ r.treden.t2 = controle.ok ? 'HAALT' : controle.ongemeten ? 'ONGEMETEN' : 'HAALT_NIET'
131
+ r.bewijs.t2 = controle.ok
132
+ ? 'offer signed by ' + controle.signer + (controle.codering ? ', key as ' + controle.codering : '')
133
+ : controle.ongemeten
134
+ ? 'the signature was not checked: ' + controle.reden
135
+ : 'the signature on the offer does not verify: ' + controle.reden
136
+ // Een afwijkende sleutelcodering is een eigen uitslag: de handtekening klopt, maar
137
+ // een verifieerder die zich aan de specificatie houdt weigert de sleutel. Dat mag
138
+ // niet verdwijnen achter een groene trede.
139
+ if (controle.afwijking) r.bewijs.t2afwijking = controle.afwijking
140
+
141
+ // TREDE 3 tot 5 vereisen een echte betaling. Ongemeten is niet afgekeurd, en
142
+ // dat onderscheid hoort in de uitslag te staan in plaats van te verdwijnen.
143
+ for (const t of ['t3', 't4', 't5']) {
144
+ r.treden[t] = 'ONGEMETEN'
145
+ r.bewijs[t] = 'needs a real settlement, not attempted'
146
+ }
147
+ return r
148
+ }
149
+
150
+ /** De hoogste trede die dit eindpunt aantoonbaar haalt. */
151
+ export function hoogsteTrede(meting) {
152
+ let hoogste = -1
153
+ for (const t of TREDEN) {
154
+ if (meting.treden[t.sleutel] === 'HAALT') hoogste = t.nummer
155
+ else break
156
+ }
157
+ return hoogste
158
+ }
159
+
160
+ export async function meetLijst(urls, { fetchImpl = fetch, pauzeMs = 1200 } = {}) {
161
+ const uit = []
162
+ for (const u of urls) {
163
+ uit.push(await meetEindpunt(u, { fetchImpl }))
164
+ if (pauzeMs) await slaap(pauzeMs)
165
+ }
166
+ return uit
167
+ }
168
+
169
+ if (process.argv[1]?.endsWith('x402-conformance-check.mjs')) {
170
+ const urls = process.argv.slice(2).filter((a) => a.startsWith('http'))
171
+ if (urls.length === 0) {
172
+ console.log('usage: node src/x402-conformance-check.mjs <url> [url ...]')
173
+ console.log('')
174
+ console.log('Measures levels 0 to 2 of the ladder in CONFORMANCE.md without paying.')
175
+ console.log('Levels 3 to 5 need a real settlement and are reported as not measured.')
176
+ process.exitCode = 2
177
+ } else {
178
+ const metingen = await meetLijst(urls)
179
+ for (const m of metingen) {
180
+ const h = hoogsteTrede(m)
181
+ console.log((h >= 0 ? 'L' + h : 'L-') + ' ' + m.url)
182
+ for (const t of TREDEN) {
183
+ const staat = m.treden[t.sleutel]
184
+ if (!staat) continue
185
+ const merk = staat === 'HAALT' ? ' ok ' : staat === 'HAALT_NIET' ? 'FAIL' : ' .. '
186
+ console.log(' [' + merk + '] L' + t.nummer + ' ' + t.naam + ': ' + (m.bewijs[t.sleutel] ?? ''))
187
+ }
188
+ }
189
+ const gehaald = metingen.filter((m) => hoogsteTrede(m) >= 0).length
190
+ console.log('')
191
+ console.log(gehaald + ' of ' + metingen.length + ' endpoint(s) reach level 0 or higher.')
192
+ console.log('Definition and what is deliberately not measured: CONFORMANCE.md')
193
+ }
194
+ }
@@ -0,0 +1,246 @@
1
+ // De conformance-ladder, en de optionele crypto die een deel ervan nodig heeft.
2
+ //
3
+ // De treden staan hier als DATA en niet als losse regels code, omdat de ladder de
4
+ // eenheid is: wie een ander getal publiceert moet kunnen aanwijzen welke trede hij
5
+ // anders heeft gelegd. Verandert de ladder, dan verandert de editie, en dan hoort
6
+ // dat met een datum opgeschreven te worden in CONFORMANCE.md.
7
+ //
8
+ // WIJZIGING 2026-10-17, en dit is een gebrek in onze eigen eenheid dat een meting
9
+ // blootlegde, geen uitbreiding uit enthousiasme. Trede 2 eiste een eip712-artefact.
10
+ // Een live meting vond een partij die zijn aanbod wel degelijk ondertekent, maar in
11
+ // JWS met een EdDSA-handtekening en een did:web-sleutel. Onze meetlat noemde dat een
12
+ // mislukking. Dat is niet hun fout maar onze fout: een ladder die een geldige vorm
13
+ // niet kent, meet die partij verkeerd en zou een gepubliceerd cijfer onjuist maken.
14
+ // Trede 2 vraagt daarom nu om een ONDERTEKEND aanbod waarvan de handtekening
15
+ // verifieert, ongeacht in welke van beide vormen.
16
+
17
+ import { createPublicKey, verify as edVerify } from 'node:crypto'
18
+ import { laadProfiel, tredenUitProfiel } from './profiel.mjs'
19
+
20
+ // De treden komen nu UIT het profiel en staan niet meer hier. Zie src/profiel.mjs
21
+ // voor waarom: een meetlat die in een .mjs opgesloten zit, kan geen tweede
22
+ // implementatie voeden, en dat is precies de scheefgroei die x402#3396 benoemt.
23
+ // De veldnamen zijn met opzet onveranderd gelaten, zodat geen enkele aanroeper
24
+ // hoeft te wijzigen en de omzetting dus aantoonbaar niets aan het gedrag verandert.
25
+ // Nieuw per trede, alleen toegevoegd: profielId, clausule, citeertVoorstel en
26
+ // terugvalAlsNietTeZien.
27
+ export const PROFIEL = laadProfiel()
28
+ export const TREDEN = tredenUitProfiel(PROFIEL)
29
+
30
+ /**
31
+ * ethers, als het er is.
32
+ *
33
+ * Een BARE import, en bewust niet een pad op iemands schijf. Een vorige versie van
34
+ * dit project importeerde ethers vanaf file:///C:/Code/... en dat werkte alleen op
35
+ * de machine van de schrijver: voor iedere vreemde viel de controle stil weg en hij
36
+ * kreeg zonder het te weten een zwakkere uitslag dan wij. Dat is precies het
37
+ * tegenovergestelde van wat een onafhankelijke verifieerder hoort te zijn.
38
+ *
39
+ * Niet aanwezig betekent ONGEMETEN en nooit stil overgeslagen: de aanroeper krijgt
40
+ * een reden terug die hij in zijn uitslag kan laten zien.
41
+ */
42
+ let ethersCache
43
+ export async function laadEthers() {
44
+ if (ethersCache !== undefined) return ethersCache
45
+ try {
46
+ ethersCache = await import('ethers')
47
+ } catch {
48
+ ethersCache = null
49
+ }
50
+ return ethersCache
51
+ }
52
+
53
+ /** §4.3, normatief. Reist niet over de lijn, dus een verifier haalt het uit de spec. */
54
+ export const OFFER_EIP712_DOMAIN = { name: 'x402 offer', version: '1', chainId: 1 }
55
+ export const OFFER_EIP712_TYPES = {
56
+ Offer: [
57
+ { name: 'version', type: 'uint256' },
58
+ { name: 'resourceUrl', type: 'string' },
59
+ { name: 'scheme', type: 'string' },
60
+ { name: 'network', type: 'string' },
61
+ { name: 'asset', type: 'string' },
62
+ { name: 'payTo', type: 'string' },
63
+ { name: 'amount', type: 'string' },
64
+ { name: 'validUntil', type: 'uint256' },
65
+ ],
66
+ }
67
+
68
+ const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
69
+
70
+ function base58decode(s) {
71
+ let getal = 0n
72
+ for (const teken of s) {
73
+ const i = B58.indexOf(teken)
74
+ if (i < 0) throw new Error('not base58: ' + teken)
75
+ getal = getal * 58n + BigInt(i)
76
+ }
77
+ const bytes = []
78
+ while (getal > 0n) {
79
+ bytes.unshift(Number(getal & 255n))
80
+ getal >>= 8n
81
+ }
82
+ for (const teken of s) {
83
+ if (teken === '1') bytes.unshift(0)
84
+ else break
85
+ }
86
+ return Buffer.from(bytes)
87
+ }
88
+
89
+ /**
90
+ * did:web naar de URL van zijn document, volgens de did:web-methode.
91
+ *
92
+ * Zonder pad staat het document op /.well-known/did.json; met padsegmenten staat
93
+ * het op dat pad. Een poort zit in het eerste segment als %3A.
94
+ */
95
+ export function didWebNaarUrl(did) {
96
+ if (!did.startsWith('did:web:')) throw new Error('not a did:web')
97
+ const delen = did.slice('did:web:'.length).split(':').map(decodeURIComponent)
98
+ const host = delen[0]
99
+ if (!host || host.includes('/')) throw new Error('unusable did:web host')
100
+ return delen.length === 1
101
+ ? 'https://' + host + '/.well-known/did.json'
102
+ : 'https://' + host + '/' + delen.slice(1).join('/') + '/did.json'
103
+ }
104
+
105
+ /**
106
+ * Een did:web-document ophalen.
107
+ *
108
+ * DIT HAALT EEN URL OP DIE DE GEMETEN PARTIJ ZELF BEPAALT, en dat is precies waarom
109
+ * er grenzen om heen staan. Dit bestand wordt door vreemden gedraaid, dus het mag
110
+ * nooit een omleiding naar een intern adres volgen of naar iets anders dan https:
111
+ * - alleen https, afgedwongen door didWebNaarUrl
112
+ * - redirect: 'error', zodat een omleiding een fout is en niet stil gevolgd wordt
113
+ * - een harde tijdslimiet
114
+ * Dit lost niet elk denkbaar geval op (een host die zelf naar een intern adres
115
+ * resolveert blijft mogelijk), en dat staat hier zodat het een bekende grens is in
116
+ * plaats van een stille aanname.
117
+ */
118
+ async function haalDidDocument(did, { fetchImpl = fetch } = {}) {
119
+ const url = didWebNaarUrl(did)
120
+ const resp = await fetchImpl(url, {
121
+ headers: {
122
+ 'User-Agent': 'x402-conformance-check/1 (read-only, github.com/holistis/agent-receipt-verify)',
123
+ Accept: 'application/did+json, application/json',
124
+ },
125
+ redirect: 'error',
126
+ signal: AbortSignal.timeout(15000),
127
+ })
128
+ if (!resp.ok) throw new Error('did document at ' + url + ' returned ' + resp.status)
129
+ return await resp.json()
130
+ }
131
+
132
+ /**
133
+ * Een Ed25519 publieke sleutel uit een verificationMethod halen.
134
+ *
135
+ * Geeft {sleutel, codering} terug. De codering is ZELF een uitslag: did-core wil bij
136
+ * publicKeyMultibase een multibase-multicodec-waarde (0xed01 voor Ed25519, die als
137
+ * z6Mk... rendert). Een rauwe sleutel van 32 bytes zonder die prefix is afwijkend,
138
+ * maar de handtekening eronder kan prima kloppen. Dat onderscheid hoort gemeld te
139
+ * worden in plaats van opgelost of doodgezwegen: wie zich aan de specificatie houdt
140
+ * weigert zo een sleutel, en de eigenaar hoort dat te weten.
141
+ */
142
+ export function sleutelUitVerificationMethod(vm) {
143
+ if (vm?.publicKeyJwk) {
144
+ return { sleutel: createPublicKey({ key: vm.publicKeyJwk, format: 'jwk' }), codering: 'publicKeyJwk' }
145
+ }
146
+ const mb = vm?.publicKeyMultibase
147
+ if (typeof mb !== 'string' || !mb.startsWith('z')) {
148
+ throw new Error('no usable publicKeyJwk or base58btc publicKeyMultibase')
149
+ }
150
+ const ruw = base58decode(mb.slice(1))
151
+ const omhul = (k) =>
152
+ createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), k]), format: 'der', type: 'spki' })
153
+ if (ruw.length === 34 && ruw[0] === 0xed && ruw[1] === 0x01) {
154
+ return { sleutel: omhul(ruw.subarray(2)), codering: 'multicodec ed25519-pub' }
155
+ }
156
+ if (ruw.length === 32) {
157
+ return {
158
+ sleutel: omhul(ruw),
159
+ codering: 'raw 32 bytes',
160
+ afwijking:
161
+ 'publicKeyMultibase decodes to 32 raw bytes with no multicodec prefix; did-core requires the 0xed01 ed25519-pub prefix for a Multikey, which renders as z6Mk...',
162
+ }
163
+ }
164
+ throw new Error('publicKeyMultibase decodes to ' + ruw.length + ' bytes, which is neither 32 nor 0xed01 plus 32')
165
+ }
166
+
167
+ /**
168
+ * Controleer een JWS-aanbod.
169
+ *
170
+ * Alleen EdDSA (Ed25519), want dat is wat er in het veld is aangetroffen. Een ander
171
+ * alg geeft een expliciete reden terug in plaats van een stille mislukking: niet
172
+ * ondersteund is iets anders dan ongeldig, en dat verschil hoort in de uitslag.
173
+ */
174
+ export async function verifyJwsOffer(artefact, { fetchImpl = fetch } = {}) {
175
+ const jws = typeof artefact.signature === 'string' ? artefact.signature : null
176
+ if (!jws || jws.split('.').length !== 3) return { ok: false, reden: 'jws artifact without a three-part compact signature' }
177
+ const [h, p, s] = jws.split('.')
178
+ let kop
179
+ try {
180
+ kop = JSON.parse(Buffer.from(h, 'base64url').toString('utf8'))
181
+ } catch {
182
+ return { ok: false, reden: 'jws header is not json' }
183
+ }
184
+ if (kop.alg !== 'EdDSA') {
185
+ return { ok: false, ongemeten: true, reden: 'alg ' + kop.alg + ' is not supported by this checker, so the signature was NOT checked' }
186
+ }
187
+ const kid = kop.kid
188
+ if (typeof kid !== 'string' || !kid.startsWith('did:web:')) {
189
+ return { ok: false, ongemeten: true, reden: 'kid is not a did:web, so the key could not be resolved: ' + String(kid).slice(0, 60) }
190
+ }
191
+ let doc
192
+ try {
193
+ doc = await haalDidDocument(kid.split('#')[0], { fetchImpl })
194
+ } catch (e) {
195
+ return { ok: false, ongemeten: true, reden: 'could not resolve the key: ' + String(e.message).slice(0, 90) }
196
+ }
197
+ const vms = Array.isArray(doc?.verificationMethod) ? doc.verificationMethod : []
198
+ const vm = vms.find((v) => v?.id === kid) ?? (kid.includes('#') ? vms.find((v) => String(v?.id).endsWith('#' + kid.split('#')[1])) : null)
199
+ if (!vm) return { ok: false, reden: 'the did document has no verificationMethod with id ' + kid.slice(0, 70) }
200
+ let uit
201
+ try {
202
+ uit = sleutelUitVerificationMethod(vm)
203
+ } catch (e) {
204
+ return { ok: false, reden: 'key in the did document unusable: ' + String(e.message).slice(0, 110) }
205
+ }
206
+ const ok = edVerify(null, Buffer.from(h + '.' + p, 'ascii'), uit.sleutel, Buffer.from(s, 'base64url'))
207
+ if (!ok) return { ok: false, reden: 'the EdDSA signature does not verify against the key in the did document' }
208
+ let lading = null
209
+ try {
210
+ lading = JSON.parse(Buffer.from(p, 'base64url').toString('utf8'))
211
+ } catch {
212
+ return { ok: false, reden: 'signature verifies but the payload is not json' }
213
+ }
214
+ return { ok: true, signer: kid, codering: uit.codering, afwijking: uit.afwijking, payload: lading }
215
+ }
216
+
217
+ /**
218
+ * Controleer de handtekening op een aangeboden offer.
219
+ *
220
+ * Geeft {ok, signer} of {ok:false, reden}. Zonder ethers komt er bij een eip712-
221
+ * artefact expliciet ongemeten terug, want een overgeslagen controle mag nooit als
222
+ * geslaagde controle lezen.
223
+ */
224
+ export async function verifyOfferSignature(artefact, opties = {}) {
225
+ if (!artefact || typeof artefact !== 'object') {
226
+ return { ok: false, reden: 'geen eip712-artefact met payload en signature' }
227
+ }
228
+ if (artefact.format === 'jws') return await verifyJwsOffer(artefact, opties)
229
+ if (artefact.format !== 'eip712' || !artefact.payload || !artefact.signature) {
230
+ // De oude tekst blijft staan voor het geval dat er al een test op leunt, met
231
+ // de werkelijk aangetroffen vorm erachter zodat een uitslag bruikbaar blijft:
232
+ // "onbekend formaat" is iets anders dan "ongeldige handtekening".
233
+ const vorm = artefact.format ? 'format "' + String(artefact.format).slice(0, 20) + '"' : 'no format field'
234
+ return { ok: false, reden: 'geen eip712-artefact met payload en signature (' + vorm + ')' }
235
+ }
236
+ const ethers = await laadEthers()
237
+ if (!ethers) {
238
+ return { ok: false, ongemeten: true, reden: 'ethers niet beschikbaar, dus de handtekening is NIET gecontroleerd' }
239
+ }
240
+ try {
241
+ const signer = ethers.verifyTypedData(OFFER_EIP712_DOMAIN, OFFER_EIP712_TYPES, artefact.payload, artefact.signature)
242
+ return { ok: true, signer }
243
+ } catch (e) {
244
+ return { ok: false, reden: String(e.message).slice(0, 90) }
245
+ }
246
+ }
@@ -0,0 +1,247 @@
1
+ // Census of the DECLARED layer of one x402 discovery index, counted per host.
2
+ //
3
+ // WAAROM DIT APART STAAT VAN x402-conformance-check.mjs. Dat bestand meet een
4
+ // eindpunt LIVE: het stuurt een verzoek en leest wat er terugkomt. Dit bestand
5
+ // raakt geen enkel eindpunt van iemand anders aan. Het leest alleen wat partijen
6
+ // ZELF in een discovery-index hebben laten registreren. Dat zijn twee
7
+ // verschillende beweringen en ze mogen nooit tot een cijfer samengeknepen worden:
8
+ //
9
+ // gedeclareerd = wat in de index staat, door de partij zelf aangemeld
10
+ // live = wat het eindpunt vandaag werkelijk antwoordt
11
+ //
12
+ // Een census van de eerste is goedkoop en volledig. Een meting van de tweede kost
13
+ // een verzoek aan iemand anders en kan daarom alleen op een steekproef. Daarom
14
+ // deze volgorde: eerst de census, en de census bepaalt waar de live-steekproef
15
+ // naar kijkt. Een willekeurige steekproef trekken voor je weet wat er te vinden
16
+ // is, is verzoeken verspillen aan andermans server.
17
+ //
18
+ // DE EENHEID IS DE HOST, NIET DE RESOURCE. Een partij met driehonderd paden zou
19
+ // anders driehonderd keer zo zwaar wegen als een partij met een pad. Zie
20
+ // CONFORMANCE.md; dit bestand telt op dezelfde eenheid.
21
+ //
22
+ // WAT DIT NIET IS, en dit hoort in elke uitslag die eruit komt:
23
+ // - geen ecosysteem-meting. Het is EEN index, op EEN dag.
24
+ // - host is niet hetzelfde als operator. Twee subdomeinen van hetzelfde bedrijf
25
+ // tellen hier als twee. Dat is een bekende grens, niet stilzwijgend opgelost.
26
+ // - een registratie zegt niets over of het eindpunt vandaag nog leeft.
27
+
28
+ const UA = 'x402-conformance-check/1 (read-only, github.com/holistis/agent-receipt-verify)'
29
+ const INDEX = 'https://facilitator.payai.network/discovery/resources'
30
+ const PAGINA = 1000
31
+
32
+ const slaap = (ms) => new Promise((r) => setTimeout(r, ms))
33
+
34
+ /**
35
+ * De door de partij zelf gedeclareerde invoer: padparameters en voorbeeldlichaam.
36
+ *
37
+ * Toegevoegd 2026-10-17, nadat de eerste editie bleek dat de index SJABLOON-URL's
38
+ * bevat (".../v1/ip/:ip") en dat wij die letterlijk opvroegen. De index levert de
39
+ * waarde ernaast; wij gebruikten hem niet. Dat gaf een 400 die niets over x402 zei.
40
+ * Dit veld staat er zodat een volgende meting dat niet overdoet.
41
+ */
42
+ export function gedeclareerdeInvoer(item) {
43
+ const ext = item?.extensions ?? {}
44
+ for (const k of Object.keys(ext)) {
45
+ const inp = ext[k]?.info?.input
46
+ if (inp && typeof inp === 'object') {
47
+ return {
48
+ pathParams: inp.pathParams ?? null,
49
+ queryParams: inp.queryParams ?? null,
50
+ body: inp.body ?? null,
51
+ }
52
+ }
53
+ }
54
+ return { pathParams: null, queryParams: null, body: null }
55
+ }
56
+
57
+ /** De door de partij zelf gedeclareerde HTTP-methode, of null als hij niets zegt. */
58
+ export function gedeclareerdeMethode(item) {
59
+ const ext = item?.extensions ?? {}
60
+ for (const k of Object.keys(ext)) {
61
+ const m = ext[k]?.info?.input?.method
62
+ if (typeof m === 'string' && /^(GET|POST|PUT|PATCH|DELETE)$/i.test(m.trim())) {
63
+ return m.trim().toUpperCase()
64
+ }
65
+ }
66
+ return null
67
+ }
68
+
69
+ function host(url) {
70
+ try {
71
+ return new URL(url).host.toLowerCase()
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Haalt de hele index op, pagina voor pagina.
79
+ *
80
+ * Een pagina die faalt WERPT, in plaats van stil een korter resultaat te geven.
81
+ * Een census met een onbekend gat erin is geen census, en een halve uitslag die
82
+ * zich als heel voordoet is precies de fout die deze hele meetlat moet vermijden.
83
+ */
84
+ export async function haalIndex({ fetchImpl = fetch, pauzeMs = 800, maxPaginas = 60 } = {}) {
85
+ const items = []
86
+ let offset = 0
87
+ let gemeldTotaal = null
88
+ for (let p = 0; p < maxPaginas; p++) {
89
+ const url = INDEX + '?limit=' + PAGINA + '&offset=' + offset
90
+ const resp = await fetchImpl(url, {
91
+ headers: { 'User-Agent': UA, Accept: 'application/json' },
92
+ signal: AbortSignal.timeout(60000),
93
+ })
94
+ if (!resp.ok) throw new Error('index page at offset ' + offset + ' returned ' + resp.status)
95
+ const j = await resp.json()
96
+ const batch = Array.isArray(j.items) ? j.items : []
97
+ if (gemeldTotaal === null) gemeldTotaal = j.pagination?.total ?? null
98
+ // De echte lusindex, niet indexOf: twee registraties kunnen inhoudelijk gelijk
99
+ // zijn en dan geeft indexOf beide dezelfde positie. De positie bepaalt welke
100
+ // resource per host de live-steekproef in gaat, dus een verschuiving hier
101
+ // maakt de steekproef onnavolgbaar voor iemand die hem natrekt.
102
+ for (let i = 0; i < batch.length; i++) items.push({ item: batch[i], offset: offset + i })
103
+ offset += batch.length
104
+ if (batch.length < PAGINA) return { items, gemeldTotaal, paginas: p + 1 }
105
+ if (pauzeMs) await slaap(pauzeMs)
106
+ }
107
+ throw new Error('index did not end within ' + maxPaginas + ' pages')
108
+ }
109
+
110
+ /** Vouwt de ruwe index samen tot een telling per host. */
111
+ export function censusPerHost(ruw) {
112
+ const perHost = new Map()
113
+ let zonderHost = 0
114
+ const gezieneResources = new Set()
115
+ let dubbeleResources = 0
116
+
117
+ for (const { item, offset } of ruw) {
118
+ const res = item?.resource
119
+ const h = host(res)
120
+ if (!h) {
121
+ zonderHost++
122
+ continue
123
+ }
124
+ if (gezieneResources.has(res)) dubbeleResources++
125
+ else gezieneResources.add(res)
126
+
127
+ if (!perHost.has(h)) {
128
+ perHost.set(h, {
129
+ host: h,
130
+ aantalRegistraties: 0,
131
+ eersteOffset: offset,
132
+ eersteResource: res,
133
+ eersteMethode: gedeclareerdeMethode(item),
134
+ eersteInvoer: gedeclareerdeInvoer(item),
135
+ extensieSleutels: new Set(),
136
+ netwerken: new Set(),
137
+ schemes: new Set(),
138
+ x402Versies: new Set(),
139
+ declareertOfferReceipt: false,
140
+ })
141
+ }
142
+ const o = perHost.get(h)
143
+ o.aantalRegistraties++
144
+ // Eerste in index-volgorde is de deterministische keuze voor de live-meting,
145
+ // zodat iemand anders exact dezelfde steekproef kan natrekken.
146
+ if (offset < o.eersteOffset) {
147
+ o.eersteOffset = offset
148
+ o.eersteResource = res
149
+ o.eersteMethode = gedeclareerdeMethode(item)
150
+ o.eersteInvoer = gedeclareerdeInvoer(item)
151
+ }
152
+ for (const k of Object.keys(item?.extensions ?? {})) {
153
+ o.extensieSleutels.add(k)
154
+ if (/offer-?receipt/i.test(k)) o.declareertOfferReceipt = true
155
+ }
156
+ const a = Array.isArray(item?.accepts) ? item.accepts[0] : null
157
+ if (a?.network) o.netwerken.add(String(a.network))
158
+ if (a?.scheme) o.schemes.add(String(a.scheme))
159
+ if (item?.x402Version != null) o.x402Versies.add(String(item.x402Version))
160
+ }
161
+
162
+ const operators = [...perHost.values()]
163
+ .map((o) => ({
164
+ ...o,
165
+ extensieSleutels: [...o.extensieSleutels].sort(),
166
+ netwerken: [...o.netwerken].sort(),
167
+ schemes: [...o.schemes].sort(),
168
+ x402Versies: [...o.x402Versies].sort(),
169
+ }))
170
+ .sort((a, b) => b.aantalRegistraties - a.aantalRegistraties || a.host.localeCompare(b.host))
171
+
172
+ return { operators, zonderHost, dubbeleResources, uniekeResources: gezieneResources.size }
173
+ }
174
+
175
+ if (process.argv[1]?.endsWith('x402-discovery-census.mjs')) {
176
+ const uitPad = process.argv[2]
177
+ const ruw = await haalIndex()
178
+ const c = censusPerHost(ruw.items)
179
+
180
+ const extTelling = new Map()
181
+ for (const o of c.operators) {
182
+ for (const k of o.extensieSleutels) extTelling.set(k, (extTelling.get(k) ?? 0) + 1)
183
+ }
184
+ const netwerkTelling = new Map()
185
+ for (const o of c.operators) {
186
+ for (const n of o.netwerken) netwerkTelling.set(n, (netwerkTelling.get(n) ?? 0) + 1)
187
+ }
188
+
189
+ console.log('source : ' + INDEX)
190
+ console.log('reported : ' + ruw.gemeldTotaal + ' resources, in ' + ruw.paginas + ' pages')
191
+ console.log('seen : ' + ruw.items.length + ' records, ' + c.uniekeResources + ' distinct resource URLs')
192
+ if (c.dubbeleResources) console.log('duplicates : ' + c.dubbeleResources + ' records repeat a resource URL already seen')
193
+ if (c.zonderHost) console.log('unparseable : ' + c.zonderHost + ' records without a usable URL')
194
+ console.log('hosts : ' + c.operators.length)
195
+ console.log('')
196
+ console.log('registrations per host, the ten largest:')
197
+ for (const o of c.operators.slice(0, 10)) {
198
+ console.log(
199
+ ' ' + String(o.aantalRegistraties).padStart(6) + ' ' + o.host +
200
+ ' [' + (o.extensieSleutels.join(', ') || 'no extensions') + ']'
201
+ )
202
+ }
203
+ const een = c.operators.filter((o) => o.aantalRegistraties === 1).length
204
+ console.log('')
205
+ console.log(' ' + een + ' of ' + c.operators.length + ' hosts registered exactly one resource')
206
+ console.log(' largest host holds ' + c.operators[0].aantalRegistraties + ' of ' + c.uniekeResources +
207
+ ' resources (' + ((c.operators[0].aantalRegistraties / c.uniekeResources) * 100).toFixed(1) + ' percent)')
208
+ console.log('')
209
+ console.log('extensions declared, counted per host:')
210
+ for (const [k, v] of [...extTelling].sort((a, b) => b[1] - a[1])) {
211
+ console.log(' ' + String(v).padStart(6) + ' ' + k)
212
+ }
213
+ const or = c.operators.filter((o) => o.declareertOfferReceipt)
214
+ console.log('')
215
+ console.log('offer-receipt declared by ' + or.length + ' of ' + c.operators.length + ' hosts')
216
+ for (const o of or.slice(0, 25)) console.log(' ' + o.host + ' ' + o.eersteResource)
217
+ console.log('')
218
+ console.log('networks, counted per host:')
219
+ for (const [k, v] of [...netwerkTelling].sort((a, b) => b[1] - a[1]).slice(0, 12)) {
220
+ console.log(' ' + String(v).padStart(6) + ' ' + k)
221
+ }
222
+
223
+ if (uitPad) {
224
+ const { writeFileSync, mkdirSync } = await import('node:fs')
225
+ const { dirname } = await import('node:path')
226
+ mkdirSync(dirname(uitPad), { recursive: true })
227
+ writeFileSync(
228
+ uitPad,
229
+ JSON.stringify(
230
+ {
231
+ bron: INDEX,
232
+ gemeldTotaal: ruw.gemeldTotaal,
233
+ paginas: ruw.paginas,
234
+ recordsGezien: ruw.items.length,
235
+ uniekeResources: c.uniekeResources,
236
+ dubbeleResources: c.dubbeleResources,
237
+ zonderHost: c.zonderHost,
238
+ operators: c.operators,
239
+ },
240
+ null,
241
+ 2
242
+ )
243
+ )
244
+ console.log('')
245
+ console.log('written: ' + uitPad)
246
+ }
247
+ }