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.
- package/CONFORMANCE.md +110 -0
- package/LICENSE +201 -0
- package/NOTICE +39 -0
- package/README.md +256 -2
- package/bin/cli.mjs +89 -0
- package/examples/receipt-live-base-sepolia-2.json +21 -0
- package/examples/receipt-live-base-sepolia-3.json +21 -0
- package/examples/receipt-live-base-sepolia.json +21 -0
- package/package.json +59 -4
- package/profiles/profile.schema.json +115 -0
- package/profiles/x402-deployed-ladder-v1.json +178 -0
- package/src/eip3009-autorisatie.mjs +195 -0
- package/src/inam-countersigned.mjs +162 -0
- package/src/mcp-server.mjs +262 -0
- package/src/profiel.mjs +145 -0
- package/src/x402-conformance-check.mjs +194 -0
- package/src/x402-conformance-treden.mjs +246 -0
- package/src/x402-discovery-census.mjs +247 -0
- package/src/x402-index-liveness.mjs +201 -0
- package/src/x402-link-onchain.mjs +280 -0
- package/src/x402-metingen-bundel.mjs +96 -0
- package/src/x402-offer-receipt.mjs +84 -0
- package/src/x402-poortwachters.mjs +241 -0
- package/tools/bundel-bouw.mjs +126 -0
- package/vectors/inam/BRON-README.md +38 -0
- package/vectors/inam/canonical-vectors.json +122 -0
- package/vectors/inam/vectors.json +96 -0
- package/vectors/kanon/amount-insufficient-001.json +52 -0
- package/vectors/kanon/cross-chain-replay-001.json +54 -0
- package/vectors/kanon/cross-contract-replay-001.json +54 -0
- package/vectors/kanon/domain-name-mismatch-001.json +53 -0
- package/vectors/kanon/expired-001.json +51 -0
- package/vectors/kanon/network-mismatch-001.json +52 -0
- package/vectors/kanon/nonce-replay-001.json +56 -0
- package/vectors/kanon/not-yet-valid-001.json +51 -0
- package/vectors/kanon/sig-malleable-high-s-001.json +54 -0
- package/vectors/kanon/valid-baseline-001.json +52 -0
package/src/profiel.mjs
ADDED
|
@@ -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
|
+
}
|