agent-receipt-verify 0.0.0-stage → 0.2.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.
Files changed (37) hide show
  1. package/CONFORMANCE.md +110 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +39 -0
  4. package/README.md +256 -2
  5. package/bin/cli.mjs +89 -0
  6. package/examples/receipt-live-base-sepolia-2.json +21 -0
  7. package/examples/receipt-live-base-sepolia-3.json +21 -0
  8. package/examples/receipt-live-base-sepolia.json +21 -0
  9. package/package.json +59 -4
  10. package/profiles/profile.schema.json +115 -0
  11. package/profiles/x402-deployed-ladder-v1.json +178 -0
  12. package/src/eip3009-autorisatie.mjs +195 -0
  13. package/src/inam-countersigned.mjs +162 -0
  14. package/src/mcp-server.mjs +262 -0
  15. package/src/profiel.mjs +145 -0
  16. package/src/x402-conformance-check.mjs +194 -0
  17. package/src/x402-conformance-treden.mjs +246 -0
  18. package/src/x402-discovery-census.mjs +247 -0
  19. package/src/x402-index-liveness.mjs +201 -0
  20. package/src/x402-link-onchain.mjs +280 -0
  21. package/src/x402-metingen-bundel.mjs +96 -0
  22. package/src/x402-offer-receipt.mjs +84 -0
  23. package/src/x402-poortwachters.mjs +241 -0
  24. package/tools/bundel-bouw.mjs +126 -0
  25. package/vectors/inam/BRON-README.md +38 -0
  26. package/vectors/inam/canonical-vectors.json +122 -0
  27. package/vectors/inam/vectors.json +96 -0
  28. package/vectors/kanon/amount-insufficient-001.json +52 -0
  29. package/vectors/kanon/cross-chain-replay-001.json +54 -0
  30. package/vectors/kanon/cross-contract-replay-001.json +54 -0
  31. package/vectors/kanon/domain-name-mismatch-001.json +53 -0
  32. package/vectors/kanon/expired-001.json +51 -0
  33. package/vectors/kanon/network-mismatch-001.json +52 -0
  34. package/vectors/kanon/nonce-replay-001.json +56 -0
  35. package/vectors/kanon/not-yet-valid-001.json +51 -0
  36. package/vectors/kanon/sig-malleable-high-s-001.json +54 -0
  37. package/vectors/kanon/valid-baseline-001.json +52 -0
@@ -0,0 +1,145 @@
1
+ // Het profiel inlezen en toetsen, zodat de ladder één waarheid heeft.
2
+ //
3
+ // WAAROM DIT BESTAAT. Onze ladder stond als data in een JavaScript-bestand. Dat is
4
+ // prima voor ons en onbruikbaar voor iedereen anders: een tweede implementatie kan
5
+ // een .mjs niet inlezen, en dus bleef onze meetlat opgesloten in onze eigen code.
6
+ // Precies die opsluiting is wat x402#3396 als probleem benoemt: gereedschappen
7
+ // coderen de regels in code, dus ze lopen uiteen en zijn het oneens over wat een
8
+ // gebrek is. Vanaf nu staat de ladder in profiles/x402-deployed-ladder-v1.json en
9
+ // leest deze code dat bestand. Verandert de ladder, dan verandert het bestand, en
10
+ // dan ziet een vreemde dat aan de versie en de datum.
11
+ //
12
+ // WAAROM EIGEN TOETSING EN GEEN JSON-SCHEMA-BIBLIOTHEEK. Dit project heeft geen
13
+ // verplichte afhankelijkheden en dat is een eigenschap, niet een gemis: een
14
+ // verifieerder die zelf een halve boom aan pakketten binnentrekt, vraagt vertrouwen
15
+ // in plaats van het te geven. Het schema in profiles/profile.schema.json is er voor
16
+ // een buitenstaander die wél een validator heeft. De controles hieronder dekken
17
+ // bovendien iets wat geen JSON-schema kan uitdrukken: de verbanden TUSSEN velden.
18
+
19
+ import fs from 'node:fs'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ const pad = (rel) => fileURLToPath(new URL(rel, import.meta.url))
23
+
24
+ export const PROFIEL_PAD = pad('../profiles/x402-deployed-ladder-v1.json')
25
+
26
+ /**
27
+ * De verbanden die een JSON-schema niet kan uitdrukken, en die allemaal een keer
28
+ * een echte fout zouden zijn geweest als niemand ze had gecontroleerd.
29
+ *
30
+ * Geeft een lijst klachten terug. Leeg is goed. Gooit niet: de aanroeper beslist of
31
+ * een kapot profiel een harde fout is (bij ons: ja, zie onderaan dit bestand).
32
+ */
33
+ export function toetsProfiel(p) {
34
+ const klachten = []
35
+ const zeg = (m) => klachten.push(m)
36
+
37
+ if (!p || typeof p !== 'object') return ['het profiel is geen object']
38
+ for (const veld of ['profile', 'version', 'published', 'intent', 'outcomes', 'specs', 'checks']) {
39
+ if (p[veld] === undefined) zeg(`verplicht veld ontbreekt: ${veld}`)
40
+ }
41
+ if (!Array.isArray(p.checks) || p.checks.length === 0) return klachten.concat('checks is leeg of geen lijst')
42
+
43
+ // Vier uitkomsten, niet drie. Drie is de fout die wij zelf maakten: dan valt
44
+ // "wij hebben het niet geprobeerd" samen met "wij konden niet beslissen", en een
45
+ // lezer kan een gat in de meter niet onderscheiden van een gebrek in het eindpunt.
46
+ const verwachteUitkomsten = ['pass', 'fail', 'skip', 'inconclusive']
47
+ const gegeven = Object.keys(p.outcomes ?? {}).sort()
48
+ if (gegeven.join(',') !== verwachteUitkomsten.slice().sort().join(',')) {
49
+ zeg(`outcomes moet exact ${verwachteUitkomsten.join(', ')} zijn, gevonden: ${gegeven.join(', ') || 'niets'}`)
50
+ }
51
+
52
+ // Elke gepinde spec moet compleet zijn, anders is een clausuleverwijzing een
53
+ // verwijzing naar wat er vandaag staat.
54
+ for (const [naam, s] of Object.entries(p.specs ?? {})) {
55
+ for (const veld of ['repo', 'path', 'commit', 'state']) {
56
+ if (!s?.[veld]) zeg(`spec ${naam} mist ${veld}`)
57
+ }
58
+ if (s?.state && !['merged on main', 'proposed, not merged'].includes(s.state)) {
59
+ zeg(`spec ${naam} heeft een onbekende state: ${s.state}`)
60
+ }
61
+ // Een voorstel zonder PR-nummer is niet terug te vinden door een vreemde.
62
+ if (s?.state === 'proposed, not merged' && typeof s.pullRequest !== 'number') {
63
+ zeg(`spec ${naam} is een voorstel maar noemt geen pullRequest`)
64
+ }
65
+ }
66
+
67
+ const ids = new Set(), keys = new Set(), niveaus = []
68
+ for (const c of p.checks) {
69
+ if (ids.has(c.id)) zeg(`dubbele check-id: ${c.id}`)
70
+ ids.add(c.id)
71
+ if (keys.has(c.key)) zeg(`dubbele check-key: ${c.key}`)
72
+ keys.add(c.key)
73
+ niveaus.push(c.level)
74
+
75
+ if (!Array.isArray(c.clause) || c.clause.length === 0) zeg(`${c.id} heeft geen clausuleverwijzing`)
76
+ for (const cl of c.clause ?? []) {
77
+ if (!p.specs?.[cl.spec]) zeg(`${c.id} verwijst naar een onbekende spec: ${cl.spec}`)
78
+ if (!cl.section || String(cl.section).length < 3) zeg(`${c.id} heeft een clausule zonder sectie`)
79
+ }
80
+ for (const rv of c.relatedVectors ?? []) {
81
+ if (!p.relatedWork?.[rv.set]) zeg(`${c.id} verwijst naar een onbekende vectorset: ${rv.set}`)
82
+ }
83
+ if (!['skip', 'inconclusive'].includes(c.whenNotObservable)) {
84
+ zeg(`${c.id} heeft een onbekende whenNotObservable: ${c.whenNotObservable}`)
85
+ }
86
+ if (!c.assertion?.evaluator || !c.assertion?.describes) zeg(`${c.id} mist evaluator of describes`)
87
+ if (!['MUST', 'SHOULD'].includes(c.normative)) zeg(`${c.id} heeft een onbekende normative: ${c.normative}`)
88
+ if (!c.input || typeof c.input.pays !== 'boolean') zeg(`${c.id} zegt niet of hij betaalt`)
89
+
90
+ // DE INVARIANT DIE HET MEEST UITMAAKT. Een passief profiel mag een check die
91
+ // een echte afrekening nodig heeft nooit als fail rapporteren: dat zou een
92
+ // eindpunt afstraffen voor iets dat wij bewust niet hebben geprobeerd. Precies
93
+ // deze regel staat als zin in onze CONFORMANCE.md en was tot nu toe nergens
94
+ // mechanisch afgedwongen.
95
+ if (c.input?.pays === true && c.whenNotObservable !== 'skip') {
96
+ zeg(`${c.id} heeft een afrekening nodig maar valt niet terug op skip, dus een passieve run zou hem kunnen afkeuren`)
97
+ }
98
+ if (c.input?.pays === false && c.whenNotObservable === 'skip') {
99
+ zeg(`${c.id} heeft geen afrekening nodig, dus skip is hier de verkeerde terugval: dit hoort inconclusive te zijn`)
100
+ }
101
+ }
102
+
103
+ // Niveaus moeten 0 tot n zijn zonder gat: de hoogste-trede-regel loopt van onder
104
+ // naar boven en stopt bij de eerste die niet haalt. Een gat zou die lus stil laten
105
+ // stoppen op een trede die niemand heeft gemeten.
106
+ const gesorteerd = niveaus.slice().sort((a, b) => a - b)
107
+ for (let i = 0; i < gesorteerd.length; i++) {
108
+ if (gesorteerd[i] !== i) { zeg(`niveaus lopen niet van 0 op zonder gat: ${gesorteerd.join(', ')}`); break }
109
+ }
110
+ for (let i = 1; i < niveaus.length; i++) {
111
+ if (niveaus[i] <= niveaus[i - 1]) { zeg('checks staan niet op oplopend niveau in het bestand'); break }
112
+ }
113
+
114
+ return klachten
115
+ }
116
+
117
+ /** Laadt en toetst het profiel. Gooit bij een klacht, want een kapot profiel levert stil verkeerde metingen. */
118
+ export function laadProfiel(bestand = PROFIEL_PAD) {
119
+ const p = JSON.parse(fs.readFileSync(bestand, 'utf8'))
120
+ const klachten = toetsProfiel(p)
121
+ if (klachten.length) throw new Error('profiel niet in orde:\n ' + klachten.join('\n '))
122
+ return p
123
+ }
124
+
125
+ /**
126
+ * De treden zoals de rest van deze codebase ze al jaren verwacht, nu AFGELEID uit
127
+ * het profiel in plaats van ernaast geschreven. Bewust dezelfde veldnamen, zodat
128
+ * geen enkele aanroeper hoeft te veranderen en de omzetting dus aantoonbaar niets
129
+ * aan het gedrag verandert.
130
+ */
131
+ export function tredenUitProfiel(p) {
132
+ return p.checks.map((c) => ({
133
+ sleutel: c.key,
134
+ nummer: c.level,
135
+ naam: c.name,
136
+ eis: c.requirement,
137
+ meetbaarZonderTeBetalen: c.input.pays === false,
138
+ // Nieuw en alleen toegevoegd: waar deze trede vandaan komt, en of dat een
139
+ // voorstel is. Daarmee kan een uitslag eerlijk zeggen tegen wat hij meet.
140
+ profielId: c.id,
141
+ clausule: c.clause.map((cl) => `${p.specs[cl.spec].path} §${cl.section}`),
142
+ citeertVoorstel: c.clause.some((cl) => p.specs[cl.spec].state === 'proposed, not merged'),
143
+ terugvalAlsNietTeZien: c.whenNotObservable,
144
+ }))
145
+ }
@@ -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
+ }