agent-receipt-verify 0.2.6 → 0.2.8
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/bench/methodefout.mjs +204 -0
- package/bin/cli.mjs +6 -0
- package/package.json +5 -3
- package/src/attempt-identity.mjs +278 -0
- package/src/x402-conformance-check.mjs +9 -1
- package/vectors/attempt-identity/all-paths-agree.json +126 -0
- package/vectors/attempt-identity/changed-amount-announced.json +86 -0
- package/vectors/attempt-identity/changed-amount-no-event.json +82 -0
- package/vectors/attempt-identity/changed-amount-silent-collapse.json +81 -0
- package/vectors/attempt-identity/client-supplied-key-unverified.json +29 -0
- package/vectors/attempt-identity/countersignature-matches.json +28 -0
- package/vectors/attempt-identity/countersigned-despite-mismatch.json +29 -0
- package/vectors/attempt-identity/derive-once-honoured.json +122 -0
- package/vectors/attempt-identity/idempotency-key-bound.json +28 -0
- package/vectors/attempt-identity/key-order-changes-identity.json +63 -0
- package/vectors/attempt-identity/key-order-stable-identity.json +62 -0
- package/vectors/attempt-identity/nothing-recorded-not-measured.json +21 -0
- package/vectors/attempt-identity/outcome-folded-into-identity.json +35 -0
- package/vectors/attempt-identity/outcome-kept-out-of-identity.json +34 -0
- package/vectors/attempt-identity/paths-disagree.json +81 -0
- package/vectors/attempt-identity/reference-canonicalized-first.json +28 -0
- package/vectors/attempt-identity/reference-over-transmitted.json +26 -0
- package/vectors/attempt-identity/settle-rederives-downstream.json +103 -0
- package/vectors/attempt-identity/settled-despite-mismatch.json +29 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* METHODEFOUT-BENCHMARK -- meet de foutmarge van DEZE meter tegen de echte markt.
|
|
4
|
+
*
|
|
5
|
+
* WAAROM DIT BESTAAT, en het is een eigen fout die het nodig maakte. Deze verifieerder
|
|
6
|
+
* sondeert standaard met POST. Een betaalde route die alleen GET bedient antwoordt
|
|
7
|
+
* daarop 405, of 404 bij veel webraamwerken, en dat las de meter als "dit eindpunt vraagt
|
|
8
|
+
* niet om betaling zoals x402 voorschrijft". Gemeten op 18 oktober 2026, op zestig unieke
|
|
9
|
+
* hosts uit de CDP-index: van de 57 die ECHT om betaling vragen keurde de meter er 36 af.
|
|
10
|
+
* 63,2 procent. Dat bleef onopgemerkt omdat er alleen tegen eigen testvectoren getoetst
|
|
11
|
+
* werd, en testvectoren bevatten precies de vorm die de schrijver al in gedachten had.
|
|
12
|
+
*
|
|
13
|
+
* Een meter die zijn eigen foutmarge tegen de werkelijkheid niet meet, meet niets.
|
|
14
|
+
*
|
|
15
|
+
* DIT GEBRUIKT DE ECHTE METER, niet een nagebouwde sonde. Een eerste versie van dit
|
|
16
|
+
* bestand bouwde het verzoek zelf na, en dat is precies de fout die het wil opsporen: dan
|
|
17
|
+
* benchmark je een replica die met de echte code uit elkaar loopt. Daarom importeert dit
|
|
18
|
+
* `meetEindpunt` uit src.
|
|
19
|
+
*
|
|
20
|
+
* GEDRAGSREGELS, hard, want dit raakt eindpunten van anderen:
|
|
21
|
+
* - de meter doet zelf een POST en hoogstens een herhaling; dit voegt er alleen bij een
|
|
22
|
+
* AFKEURING nog een GET aan toe om te zien of de afkeuring terecht was
|
|
23
|
+
* - een host maar een keer, anders weegt een operator met honderd paden honderd keer
|
|
24
|
+
* - nooit betalen, nooit fuzzen, nooit herhalen om iets af te dwingen
|
|
25
|
+
* - rust tussen de hosts
|
|
26
|
+
*
|
|
27
|
+
* GEBRUIK:
|
|
28
|
+
* node bench/methodefout.mjs --urls <bestand.json> een array van resource-urls
|
|
29
|
+
* node bench/methodefout.mjs --urls - < bronnen.json hetzelfde, van stdin
|
|
30
|
+
* node bench/methodefout.mjs --urls x.json --uit uitslag.jsonl
|
|
31
|
+
*
|
|
32
|
+
* De lijst met bronnen komt van buiten dit bestand, want die vergt indextoegang en een
|
|
33
|
+
* sleutel, en een benchmark hoort geen geheimen te hoeven lezen.
|
|
34
|
+
*
|
|
35
|
+
* EXITCODES: 0 gemeten, 1 er is een RESTFOUT (de meter keurt nog hosts af die wel om
|
|
36
|
+
* betaling vragen), 2 de meting kon niet draaien.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import fs from 'node:fs'
|
|
40
|
+
import { meetEindpunt, hoogsteTrede } from '../src/x402-conformance-check.mjs'
|
|
41
|
+
|
|
42
|
+
const UA = 'x402-conformance-check/1 (read-only, github.com/holistis/agent-receipt-verify)'
|
|
43
|
+
const rust = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
44
|
+
|
|
45
|
+
function vlag(naam, standaard = null) {
|
|
46
|
+
const v = process.argv.find((a) => a === naam || a.startsWith(naam + '='))
|
|
47
|
+
if (!v) return standaard
|
|
48
|
+
if (v.includes('=')) return v.slice(naam.length + 1)
|
|
49
|
+
return process.argv[process.argv.indexOf(v) + 1] ?? standaard
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Een host maar EEN keer, anders weegt een operator met honderd paden honderd keer. */
|
|
53
|
+
export function eenPerHost(urls) {
|
|
54
|
+
const perHost = new Map()
|
|
55
|
+
for (const u of urls) {
|
|
56
|
+
try {
|
|
57
|
+
const h = new URL(u).host
|
|
58
|
+
if (!perHost.has(h)) perHost.set(h, u)
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
return [...perHost.values()]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Antwoordt dit eindpunt op GET WEL met 402? Dit wordt alleen gevraagd als de meter het
|
|
66
|
+
* eindpunt AFKEURDE, om te zien of die afkeuring terecht was. Een afkeuring die wij niet
|
|
67
|
+
* natrekken, telt stil als een eigenschap van de markt in plaats van als onze restfout.
|
|
68
|
+
*/
|
|
69
|
+
async function tweedeKansOpGet(url, fetchImpl) {
|
|
70
|
+
try {
|
|
71
|
+
const r = await fetchImpl(url, {
|
|
72
|
+
method: 'GET',
|
|
73
|
+
headers: { 'User-Agent': UA, Accept: 'application/json' },
|
|
74
|
+
signal: AbortSignal.timeout(12000),
|
|
75
|
+
})
|
|
76
|
+
return r.status === 402
|
|
77
|
+
} catch {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function benchmark(urls, { fetchImpl = fetch, pauzeMs = 500 } = {}) {
|
|
83
|
+
const t = {
|
|
84
|
+
gemeten: 0,
|
|
85
|
+
haaltL0: 0,
|
|
86
|
+
// Hoeveel hosts de meter alleen haalt DOORDAT hij een herhaling doet. Dit is wat de
|
|
87
|
+
// reparatie oplevert, en het is tegelijk de omvang van de oude fout.
|
|
88
|
+
gereddenDoorHerhaling: 0,
|
|
89
|
+
// De restfout: de meter keurt af, maar het eindpunt antwoordt wel 402 op GET. Dit
|
|
90
|
+
// hoort nul te zijn. Staat hij boven nul, dan is er nog een vorm die wij missen.
|
|
91
|
+
restfout: 0,
|
|
92
|
+
terechtAfgekeurd: 0,
|
|
93
|
+
ongemeten: 0,
|
|
94
|
+
}
|
|
95
|
+
const gered = []
|
|
96
|
+
const rest = []
|
|
97
|
+
for (const u of urls) {
|
|
98
|
+
t.gemeten++
|
|
99
|
+
const m = await meetEindpunt(u, { fetchImpl })
|
|
100
|
+
const trede = hoogsteTrede(m)
|
|
101
|
+
if (m.treden.t0 === 'ONGEMETEN') {
|
|
102
|
+
t.ongemeten++
|
|
103
|
+
} else if (trede >= 0) {
|
|
104
|
+
t.haaltL0++
|
|
105
|
+
if (m.bewijs.methode_opnieuw) {
|
|
106
|
+
t.gereddenDoorHerhaling++
|
|
107
|
+
gered.push({ url: u, eerste_status: m.bewijs.methode_opnieuw.eerste_status, tweede: m.bewijs.methode_opnieuw.tweede })
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
await rust(Math.min(300, pauzeMs))
|
|
111
|
+
if (await tweedeKansOpGet(u, fetchImpl)) {
|
|
112
|
+
t.restfout++
|
|
113
|
+
rest.push({ url: u, bewijs: m.bewijs.t0 })
|
|
114
|
+
} else {
|
|
115
|
+
t.terechtAfgekeurd++
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (pauzeMs) await rust(pauzeMs)
|
|
119
|
+
}
|
|
120
|
+
const vraagtEchtOmBetaling = t.haaltL0 + t.restfout
|
|
121
|
+
return {
|
|
122
|
+
...t,
|
|
123
|
+
vraagtEchtOmBetaling,
|
|
124
|
+
restfoutPct: vraagtEchtOmBetaling > 0 ? Number(((t.restfout / vraagtEchtOmBetaling) * 100).toFixed(1)) : null,
|
|
125
|
+
zonderHerhalingFoutPct:
|
|
126
|
+
vraagtEchtOmBetaling > 0
|
|
127
|
+
? Number((((t.gereddenDoorHerhaling + t.restfout) / vraagtEchtOmBetaling) * 100).toFixed(1))
|
|
128
|
+
: null,
|
|
129
|
+
gered,
|
|
130
|
+
rest,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (process.argv[1]?.endsWith('methodefout.mjs')) {
|
|
135
|
+
const bron = vlag('--urls')
|
|
136
|
+
if (!bron) {
|
|
137
|
+
console.error('geef een bestand met resource-urls: --urls bronnen.json (of - voor stdin)')
|
|
138
|
+
process.exit(2)
|
|
139
|
+
}
|
|
140
|
+
let ruw
|
|
141
|
+
try {
|
|
142
|
+
ruw = bron === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(bron, 'utf8')
|
|
143
|
+
} catch (e) {
|
|
144
|
+
console.error('kan de bronlijst niet lezen: ' + e.message)
|
|
145
|
+
process.exit(2)
|
|
146
|
+
}
|
|
147
|
+
let lijst
|
|
148
|
+
try {
|
|
149
|
+
const j = JSON.parse(ruw)
|
|
150
|
+
lijst = Array.isArray(j) ? j : (j.urls ?? j.resources ?? [])
|
|
151
|
+
} catch {
|
|
152
|
+
lijst = ruw.split(/\r?\n/).map((s) => s.trim()).filter((s) => /^https?:\/\//.test(s))
|
|
153
|
+
}
|
|
154
|
+
const hosts = eenPerHost(lijst.filter((u) => typeof u === 'string'))
|
|
155
|
+
const aantal = Number(vlag('--aantal', String(hosts.length)))
|
|
156
|
+
const teMeten = hosts.slice(0, aantal)
|
|
157
|
+
if (teMeten.length === 0) {
|
|
158
|
+
console.error('geen bruikbare urls, dus NIETS gemeten. Dit is geen uitslag van nul fouten.')
|
|
159
|
+
process.exit(2)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log('urls in de lijst ' + lijst.length + ', unieke hosts ' + hosts.length + ', gemeten ' + teMeten.length)
|
|
163
|
+
const u = await benchmark(teMeten)
|
|
164
|
+
console.log('')
|
|
165
|
+
console.log(' haalt trede 0 : ' + u.haaltL0)
|
|
166
|
+
console.log(' waarvan alleen door de methode-herhaling : ' + u.gereddenDoorHerhaling)
|
|
167
|
+
console.log(' RESTFOUT, afgekeurd maar antwoordt 402 : ' + u.restfout)
|
|
168
|
+
console.log(' terecht afgekeurd : ' + u.terechtAfgekeurd)
|
|
169
|
+
console.log(' niet bereikbaar, dus ongemeten : ' + u.ongemeten)
|
|
170
|
+
console.log('')
|
|
171
|
+
console.log(' van de ' + u.vraagtEchtOmBetaling + ' hosts die echt om betaling vragen:')
|
|
172
|
+
console.log(' foutmarge NU : ' + u.restfoutPct + ' procent')
|
|
173
|
+
console.log(' foutmarge ZONDER de herhaling: ' + u.zonderHerhalingFoutPct + ' procent')
|
|
174
|
+
if (u.rest.length) {
|
|
175
|
+
console.log('')
|
|
176
|
+
console.log(' nog steeds fout, en dit is het werk dat overblijft:')
|
|
177
|
+
for (const r of u.rest.slice(0, 12)) console.log(' ' + r.url + ' ' + r.bewijs)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const uit = vlag('--uit')
|
|
181
|
+
if (uit) {
|
|
182
|
+
const regel = {
|
|
183
|
+
gemeten_op: new Date().toISOString(),
|
|
184
|
+
gemeten: u.gemeten,
|
|
185
|
+
haalt_l0: u.haaltL0,
|
|
186
|
+
gered_door_herhaling: u.gereddenDoorHerhaling,
|
|
187
|
+
restfout: u.restfout,
|
|
188
|
+
terecht_afgekeurd: u.terechtAfgekeurd,
|
|
189
|
+
ongemeten: u.ongemeten,
|
|
190
|
+
vraagt_echt_om_betaling: u.vraagtEchtOmBetaling,
|
|
191
|
+
restfout_pct: u.restfoutPct,
|
|
192
|
+
zonder_herhaling_fout_pct: u.zonderHerhalingFoutPct,
|
|
193
|
+
rest: u.rest.slice(0, 10).map((r) => r.url),
|
|
194
|
+
}
|
|
195
|
+
fs.appendFileSync(uit, JSON.stringify(regel) + '\n')
|
|
196
|
+
console.log('')
|
|
197
|
+
console.log('regel bijgeschreven in ' + uit)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Een restfout boven nul is een echte, open fout van deze meter. Die hoort een
|
|
201
|
+
// exitcode te geven, anders leest een groene run als "alles klopt" terwijl er hosts
|
|
202
|
+
// afgekeurd worden die wel om betaling vragen.
|
|
203
|
+
if (u.restfout > 0) process.exitCode = 1
|
|
204
|
+
}
|
package/bin/cli.mjs
CHANGED
|
@@ -75,6 +75,11 @@ function gebruik() {
|
|
|
75
75
|
console.log(' that one settlement? Eleven run offline, the chain-reading one is')
|
|
76
76
|
console.log(' marked and skipped rather than failed.')
|
|
77
77
|
console.log('')
|
|
78
|
+
console.log(' npx -y agent-receipt-verify attempt-identity')
|
|
79
|
+
console.log(' nineteen vectors for the question the other layers assume: are these')
|
|
80
|
+
console.log(' records about the SAME attempt? One acceptance and at least one refusal')
|
|
81
|
+
console.log(' per normative clause of x402 spec section 5.5. Offline.')
|
|
82
|
+
console.log('')
|
|
78
83
|
console.log('What this does NOT do: it judges the SHAPE of a receipt, not the honesty of')
|
|
79
84
|
console.log('the party that issued it. See CONFORMANCE.md for what is deliberately left')
|
|
80
85
|
console.log('unmeasured.')
|
|
@@ -92,6 +97,7 @@ const onderdelen = {
|
|
|
92
97
|
kanon: '../src/eip3009-autorisatie.mjs',
|
|
93
98
|
beleid: '../bench/eip3009-beleid.mjs',
|
|
94
99
|
'link-vectors': '../src/settlement-link-vectors.mjs',
|
|
100
|
+
'attempt-identity': '../src/attempt-identity.mjs',
|
|
95
101
|
'verify-delivery': '../src/verify-delivery.mjs',
|
|
96
102
|
}
|
|
97
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-receipt-verify",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Checks x402 endpoint conformance before an agent pays, and links the receipt to its settlement.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"node": ">=20"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node test/interop.mjs && node test/canonical.mjs && node test/link-onchain.mjs && node test/profiel.mjs && node test/eip3009.mjs && node test/conformance.mjs && node test/jws-offer.mjs && node test/census.mjs && node test/poortwachters.mjs && node test/metingen-bundel.mjs && node test/mcp-server.mjs && node test/cli.mjs && node test/bundel.mjs && node test/settlement-link-vectors.mjs && node test/naar-kanon-vorm.mjs && node test/verify-delivery.mjs && node test/acceptance-binding.mjs",
|
|
11
|
+
"test": "node test/interop.mjs && node test/canonical.mjs && node test/link-onchain.mjs && node test/profiel.mjs && node test/eip3009.mjs && node test/conformance.mjs && node test/jws-offer.mjs && node test/census.mjs && node test/poortwachters.mjs && node test/metingen-bundel.mjs && node test/mcp-server.mjs && node test/cli.mjs && node test/bundel.mjs && node test/settlement-link-vectors.mjs && node test/naar-kanon-vorm.mjs && node test/verify-delivery.mjs && node test/acceptance-binding.mjs && node test/methodefout-bench.mjs && node test/attempt-identity.mjs",
|
|
12
12
|
"link-example": "node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia.json && node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia-2.json && node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia-3.json",
|
|
13
13
|
"conformance": "node src/x402-conformance-check.mjs",
|
|
14
14
|
"bench": "node bench/robustness.mjs",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"bench-eip3009": "node bench/eip3009-beleid.mjs",
|
|
23
23
|
"link-vectors": "node src/settlement-link-vectors.mjs",
|
|
24
24
|
"verify-delivery": "node src/verify-delivery.mjs",
|
|
25
|
-
"kanon-vorm": "node tools/naar-kanon-vorm.mjs"
|
|
25
|
+
"kanon-vorm": "node tools/naar-kanon-vorm.mjs",
|
|
26
|
+
"bench-methodefout": "node bench/methodefout.mjs",
|
|
27
|
+
"attempt-identity": "node src/attempt-identity.mjs"
|
|
26
28
|
},
|
|
27
29
|
"optionalDependencies": {
|
|
28
30
|
"ethers": "^6.17.0"
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Toetst de poging-identiteit: hoort dit alles bij DEZELFDE poging.
|
|
2
|
+
//
|
|
3
|
+
// WAAROM DIT BESTAAT. In erc-8004#99 kwamen op 18 oktober 2026 vier bewijslagen op tafel
|
|
4
|
+
// (vooraf-veiligheid, vooraf-oordeel, betaalbewijs na afrekening, en onafhankelijke
|
|
5
|
+
// werk-acceptatie), en de schrijver van de spec stelde vast dat geen van die vier de
|
|
6
|
+
// vraag eronder beantwoordt: welke van deze bewijsstukken gaan eigenlijk over dezelfde
|
|
7
|
+
// POGING. Hij schrijft die regel in x402#3776 paragraaf 5.5, en die paragraaf sluit af
|
|
8
|
+
// met een verwijzing naar "the normative conformance tests ... for the required negative
|
|
9
|
+
// cases". Die testen bestonden nog niet. Dit is dat.
|
|
10
|
+
//
|
|
11
|
+
// WAT HIER GEMETEN WORDT, en het is bewust alleen de INVARIANT. Niet of een facilitator
|
|
12
|
+
// goed rekent, niet of een handtekening klopt, maar of alle paden van een verzoek
|
|
13
|
+
// dezelfde identiteit opleveren en of een veranderd verzoek een andere identiteit krijgt.
|
|
14
|
+
// Dat is precies wat 5.5 eist en niets meer.
|
|
15
|
+
//
|
|
16
|
+
// WAT HIER MET OPZET NIET GEMETEN WORDT. Wij halen geen enkel pad zelf op. Een vector is
|
|
17
|
+
// een vastgelegde reeks WAARNEMINGEN per pad, en deze module toetst de invariant over die
|
|
18
|
+
// reeks. Een facilitator laten sonderen zou betekenen dat wij zijn betaalpaden aanroepen,
|
|
19
|
+
// en dat is niet aan ons.
|
|
20
|
+
//
|
|
21
|
+
// DRIE UITKOMSTEN, NOOIT TWEE: PASS, FAIL, NOT_MEASURED. Een clausule waarover de vector
|
|
22
|
+
// geen gegevens draagt is niet geslaagd en niet gefaald, hij is niet gemeten. Dat
|
|
23
|
+
// onderscheid is dezelfde correctie die de Kanon-auteur op 17 oktober 2026 overnam voor
|
|
24
|
+
// zijn eigen controleur, en de spec-auteur een dag later voor de zijne.
|
|
25
|
+
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* De normatieve clausules van paragraaf 5.5, met het letterlijke citaat erbij.
|
|
30
|
+
*
|
|
31
|
+
* Het citaat staat hier zodat een lezer kan nazien dat wij toetsen wat er STAAT en niet
|
|
32
|
+
* wat wij ervan maakten. Verandert de tekst, dan hoort dit mee te veranderen en is het
|
|
33
|
+
* verschil zichtbaar in de diff in plaats van verstopt in een oordeel.
|
|
34
|
+
*/
|
|
35
|
+
export const CLAUSULES = [
|
|
36
|
+
{
|
|
37
|
+
id: 'derive-once-at-ingress',
|
|
38
|
+
citaat:
|
|
39
|
+
'MUST be derived once, at ingress, from the complete request snapshot as received by the facilitator, ' +
|
|
40
|
+
'and MUST NOT be re-derived or partially re-derived downstream',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'all-paths-same-artifact',
|
|
44
|
+
citaat:
|
|
45
|
+
'/settle, refusal handling, replay-lookup, and challenge-quote responses all resolve the same artifact value',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'changed-request-different-artifact',
|
|
49
|
+
citaat:
|
|
50
|
+
'A request whose amount, query, or body changes between paths MUST produce a different artifact, and that ' +
|
|
51
|
+
'change MUST surface as a settlement or announcement event, never as a silent divergence',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'countersignature-recompute-or-refuse',
|
|
55
|
+
citaat:
|
|
56
|
+
'that counterparty MUST independently recompute the fingerprint from the bytes it observed, and MUST refuse ' +
|
|
57
|
+
'to sign if the recomputed value differs ... on mismatch the interaction MUST NOT settle',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'own-identity-from-decoded-fields',
|
|
61
|
+
citaat:
|
|
62
|
+
"an attempt's own identity ... MUST be derived from named decoded fields and MUST NOT be derived from wire " +
|
|
63
|
+
'bytes, an attempt is the same attempt whether it arrived with a different key order or different whitespace',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'reference-over-transmitted-bytes',
|
|
67
|
+
citaat:
|
|
68
|
+
'that reference is taken over the artifact as transmitted, because the signature covers those bytes, ' +
|
|
69
|
+
'canonicalizing them first would verify a different object than the one that was signed',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'outcome-not-folded-into-identity',
|
|
73
|
+
citaat:
|
|
74
|
+
'Outcome payloads produced later in the attempt ... are not folded into the identity artifact, an ' +
|
|
75
|
+
'implementation MUST NOT extend identity derivation with values that did not exist at ingress',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'name-independent-but-bound',
|
|
79
|
+
citaat:
|
|
80
|
+
'An implementation that already uses Idempotency-Key satisfies it by binding that key to the ingress snapshot',
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
export const UITKOMSTEN = ['PASS', 'FAIL', 'NOT_MEASURED']
|
|
85
|
+
|
|
86
|
+
// Een minimale canonicalisatie: sleutels gesorteerd, geen witruimte. Genoeg om "dezelfde
|
|
87
|
+
// velden, andere sleutelvolgorde" te herkennen, en met opzet NIET uitgegeven als een
|
|
88
|
+
// volledige RFC8785-implementatie: die heeft eigen regels voor getallen, en een halve
|
|
89
|
+
// implementatie onder een bekende naam is erger dan een eigen kleine functie onder een
|
|
90
|
+
// eigen naam.
|
|
91
|
+
export function sorteerStabiel(w) {
|
|
92
|
+
if (Array.isArray(w)) return w.map(sorteerStabiel)
|
|
93
|
+
if (w && typeof w === 'object') {
|
|
94
|
+
const uit = {}
|
|
95
|
+
for (const k of Object.keys(w).sort()) uit[k] = sorteerStabiel(w[k])
|
|
96
|
+
return uit
|
|
97
|
+
}
|
|
98
|
+
return w
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const velddigest = (o) => createHash('sha256').update(JSON.stringify(sorteerStabiel(o))).digest('hex')
|
|
102
|
+
export const bytedigest = (s) => createHash('sha256').update(String(s), 'utf8').digest('hex')
|
|
103
|
+
|
|
104
|
+
const kaal = (w) => (typeof w === 'string' ? w.trim().toLowerCase().replace(/^0x/, '') : null)
|
|
105
|
+
const zelfde = (a, b) => kaal(a) !== null && kaal(a) === kaal(b)
|
|
106
|
+
|
|
107
|
+
const PAD_NAMEN = ['challenge-quote', 'settle', 'refusal', 'replay-lookup']
|
|
108
|
+
|
|
109
|
+
/** Eén clausule, één oordeel. Geen gegevens betekent NOT_MEASURED en nooit FAIL. */
|
|
110
|
+
function toetsClausule(id, inv) {
|
|
111
|
+
const niet = (reden) => ({ clausule: id, uitkomst: 'NOT_MEASURED', reden })
|
|
112
|
+
const faal = (code, detail) => ({ clausule: id, uitkomst: 'FAIL', reason_code: code, detail })
|
|
113
|
+
const goed = (detail) => ({ clausule: id, uitkomst: 'PASS', detail })
|
|
114
|
+
|
|
115
|
+
if (id === 'derive-once-at-ingress') {
|
|
116
|
+
const ingress = inv.ingress
|
|
117
|
+
const paden = Array.isArray(inv.paths) ? inv.paths : []
|
|
118
|
+
if (!ingress?.identity || paden.length === 0) return niet('geen ingress-identiteit of geen paden vastgelegd')
|
|
119
|
+
// Een pad dat de identiteit opnieuw afleidt uit iets dat NIET in de ingress-momentopname
|
|
120
|
+
// zat, is de overtreding. De vector legt dat vast als het veld `derived_from`.
|
|
121
|
+
for (const p of paden) {
|
|
122
|
+
if (p.derived_from && p.derived_from !== 'ingress-snapshot') {
|
|
123
|
+
return faal('IDENTITY_REDERIVED_DOWNSTREAM', `pad ${p.path} leidde opnieuw af uit ${p.derived_from}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return goed(`${paden.length} paden, allemaal uit de ingress-momentopname`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (id === 'all-paths-same-artifact') {
|
|
130
|
+
const paden = Array.isArray(inv.paths) ? inv.paths : []
|
|
131
|
+
const metSnapshot = paden.filter((p) => p.identity)
|
|
132
|
+
if (metSnapshot.length < 2) return niet('minder dan twee paden met een identiteit')
|
|
133
|
+
// Alleen paden met EEN GELIJK verzoek horen dezelfde identiteit te hebben. Een pad
|
|
134
|
+
// met een gewijzigd verzoek valt onder de volgende clausule, niet onder deze.
|
|
135
|
+
const basis = inv.ingress?.snapshot ? velddigest(inv.ingress.snapshot) : null
|
|
136
|
+
const gelijkVerzoek = metSnapshot.filter((p) => !p.snapshot || !basis || velddigest(p.snapshot) === basis)
|
|
137
|
+
if (gelijkVerzoek.length < 2) return niet('minder dan twee paden met een ongewijzigd verzoek')
|
|
138
|
+
const eerste = gelijkVerzoek[0]
|
|
139
|
+
for (const p of gelijkVerzoek.slice(1)) {
|
|
140
|
+
if (!zelfde(p.identity, eerste.identity)) {
|
|
141
|
+
return faal('PATHS_DISAGREE_ON_IDENTITY', `${eerste.path} en ${p.path} geven een andere identiteit`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const gedekt = gelijkVerzoek.map((p) => p.path).filter((p) => PAD_NAMEN.includes(p))
|
|
145
|
+
return goed(`${gelijkVerzoek.length} paden eens, waarvan benoemd in de spec: ${gedekt.join(', ') || 'geen'}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (id === 'changed-request-different-artifact') {
|
|
149
|
+
const paden = Array.isArray(inv.paths) ? inv.paths : []
|
|
150
|
+
const basis = inv.ingress?.snapshot
|
|
151
|
+
if (!basis) return niet('geen ingress-momentopname om tegen af te zetten')
|
|
152
|
+
const gewijzigd = paden.filter((p) => p.snapshot && velddigest(p.snapshot) !== velddigest(basis))
|
|
153
|
+
if (gewijzigd.length === 0) return niet('geen pad met een gewijzigd verzoek vastgelegd')
|
|
154
|
+
for (const p of gewijzigd) {
|
|
155
|
+
if (zelfde(p.identity, inv.ingress.identity)) {
|
|
156
|
+
return faal('CHANGED_REQUEST_SAME_IDENTITY', `pad ${p.path} wijzigde het verzoek en hield dezelfde identiteit`)
|
|
157
|
+
}
|
|
158
|
+
const events = Array.isArray(inv.events) ? inv.events : []
|
|
159
|
+
const erkend = events.some((e) => zelfde(e.identity, p.identity))
|
|
160
|
+
if (!erkend) {
|
|
161
|
+
return faal('CHANGED_REQUEST_NO_EVENT', `pad ${p.path} kreeg een andere identiteit zonder dat er een gebeurtenis bij hoort`)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return goed(`${gewijzigd.length} gewijzigd(e) verzoek(en), elk met eigen identiteit en een gebeurtenis`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (id === 'countersignature-recompute-or-refuse') {
|
|
168
|
+
const c = inv.countersignature
|
|
169
|
+
if (!c) return niet('geen medeondertekening vastgelegd')
|
|
170
|
+
if (c.recomputed === undefined || c.ingress_value === undefined) return niet('geen herberekende of ingress-waarde')
|
|
171
|
+
const past = zelfde(c.recomputed, c.ingress_value)
|
|
172
|
+
if (!past && c.signed === true) return faal('COUNTERSIGNED_DESPITE_MISMATCH', 'de herberekende waarde week af en er is toch ondertekend')
|
|
173
|
+
if (!past && c.settled === true) return faal('SETTLED_DESPITE_MISMATCH', 'de herberekende waarde week af en er is toch afgerekend')
|
|
174
|
+
if (past && c.signed === false) return goed('de waarden kloppen en er is niet ondertekend, dat is toegestaan maar ongebruikelijk')
|
|
175
|
+
return goed(past ? 'herberekend en gelijk, dus ondertekenen mag' : 'herberekend en afwijkend, dus geweigerd en niet afgerekend')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (id === 'own-identity-from-decoded-fields') {
|
|
179
|
+
const v = inv.wire_variants
|
|
180
|
+
if (!Array.isArray(v) || v.length < 2) return niet('minder dan twee draadvarianten vastgelegd')
|
|
181
|
+
// Dezelfde velden, andere sleutelvolgorde of witruimte. De identiteit MOET gelijk
|
|
182
|
+
// blijven; verschilt hij, dan is hij uit de bytes afgeleid.
|
|
183
|
+
const digests = new Set(v.map((x) => velddigest(x.decoded)))
|
|
184
|
+
if (digests.size !== 1) return niet('de varianten hebben niet dezelfde gedecodeerde velden')
|
|
185
|
+
const ids = new Set(v.map((x) => kaal(x.identity)))
|
|
186
|
+
if (ids.size !== 1) {
|
|
187
|
+
return faal('IDENTITY_DERIVED_FROM_WIRE_BYTES', 'dezelfde velden in een andere sleutelvolgorde gaven een andere identiteit')
|
|
188
|
+
}
|
|
189
|
+
return goed(`${v.length} draadvarianten, dezelfde identiteit`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (id === 'reference-over-transmitted-bytes') {
|
|
193
|
+
const r = inv.signed_artifact_reference
|
|
194
|
+
if (!r || r.transmitted === undefined || r.reference_hash === undefined) return niet('geen verwijzing naar een ondertekend stuk')
|
|
195
|
+
// Wij rekenen beide kanten ZELF uit in plaats van een label te geloven.
|
|
196
|
+
const overBytes = bytedigest(r.transmitted)
|
|
197
|
+
let overVelden = null
|
|
198
|
+
try {
|
|
199
|
+
overVelden = velddigest(JSON.parse(r.transmitted))
|
|
200
|
+
} catch {
|
|
201
|
+
overVelden = null
|
|
202
|
+
}
|
|
203
|
+
if (zelfde(r.reference_hash, overBytes)) return goed('de verwijzing is over de verzonden bytes genomen')
|
|
204
|
+
if (overVelden && zelfde(r.reference_hash, overVelden)) {
|
|
205
|
+
return faal('REFERENCE_CANONICALIZED_BEFORE_HASHING', 'de verwijzing is over een gecanonicaliseerde vorm genomen, dus over een ander object dan er ondertekend is')
|
|
206
|
+
}
|
|
207
|
+
return faal('REFERENCE_HASH_UNEXPLAINED', 'de verwijzing komt noch met de verzonden bytes noch met een gecanonicaliseerde vorm overeen')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (id === 'outcome-not-folded-into-identity') {
|
|
211
|
+
const o = inv.outcome_variants
|
|
212
|
+
if (!Array.isArray(o) || o.length < 2) return niet('minder dan twee uitkomst-varianten vastgelegd')
|
|
213
|
+
const ids = new Set(o.map((x) => kaal(x.identity)))
|
|
214
|
+
if (ids.size !== 1) {
|
|
215
|
+
return faal('OUTCOME_FOLDED_INTO_IDENTITY', 'de identiteit verschilt tussen volledige en gedeeltelijke levering, dus uitkomstdata zit erin')
|
|
216
|
+
}
|
|
217
|
+
return goed(`${o.length} leveringsvarianten, dezelfde identiteit`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (id === 'name-independent-but-bound') {
|
|
221
|
+
const k = inv.identity_field
|
|
222
|
+
if (!k || k.bound_to === undefined) return niet('geen identiteitsveld met een binding vastgelegd')
|
|
223
|
+
if (k.bound_to !== 'ingress-snapshot') {
|
|
224
|
+
return faal('IDENTITY_NOT_BOUND_TO_SNAPSHOT', `het veld ${k.name ?? 'onbekend'} is gebonden aan ${k.bound_to} in plaats van aan de ingress-momentopname`)
|
|
225
|
+
}
|
|
226
|
+
if (k.client_supplied === true && k.verified_against_snapshot !== true) {
|
|
227
|
+
return faal('CLIENT_SUPPLIED_IDENTITY_NOT_VERIFIED', 'de client levert de identiteit aan en die wordt niet tegen de momentopname getoetst')
|
|
228
|
+
}
|
|
229
|
+
return goed(`${k.name ?? 'het identiteitsveld'} is aan de ingress-momentopname gebonden`)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return niet('onbekende clausule')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Toetst alle clausules waarover de vector gegevens draagt.
|
|
237
|
+
*
|
|
238
|
+
* Het eindoordeel is FAIL zodra EEN clausule faalt, want 5.5 is een invariant en niet een
|
|
239
|
+
* cijferlijst. Zonder enige meetbare clausule is het NOT_MEASURED, nooit PASS: een vector
|
|
240
|
+
* zonder gegevens mag nooit als bewijs van naleving tellen.
|
|
241
|
+
*/
|
|
242
|
+
export function toetsPogingIdentiteit(invoer) {
|
|
243
|
+
const inv = invoer && typeof invoer === 'object' ? invoer : {}
|
|
244
|
+
const perClausule = CLAUSULES.map((c) => toetsClausule(c.id, inv))
|
|
245
|
+
const gefaald = perClausule.filter((r) => r.uitkomst === 'FAIL')
|
|
246
|
+
const geslaagd = perClausule.filter((r) => r.uitkomst === 'PASS')
|
|
247
|
+
const uitkomst = gefaald.length > 0 ? 'FAIL' : geslaagd.length > 0 ? 'PASS' : 'NOT_MEASURED'
|
|
248
|
+
return {
|
|
249
|
+
outcome: uitkomst,
|
|
250
|
+
reason_code: gefaald[0]?.reason_code ?? null,
|
|
251
|
+
detail: gefaald[0]?.detail ?? null,
|
|
252
|
+
gemeten: geslaagd.length + gefaald.length,
|
|
253
|
+
niet_gemeten: perClausule.length - geslaagd.length - gefaald.length,
|
|
254
|
+
per_clausule: perClausule,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Een ingang, zodat dit geen bestand is dat iemand moet vinden maar een regel die hij kan
|
|
259
|
+
// draaien. Zonder sleutel, zonder account, zonder netwerk.
|
|
260
|
+
if (process.argv[1]?.endsWith('attempt-identity.mjs')) {
|
|
261
|
+
const fs = await import('node:fs')
|
|
262
|
+
const path = await import('node:path')
|
|
263
|
+
const { fileURLToPath } = await import('node:url')
|
|
264
|
+
const map = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'vectors', 'attempt-identity')
|
|
265
|
+
const bestanden = fs.readdirSync(map).filter((b) => b.endsWith('.json')).sort()
|
|
266
|
+
let past = 0
|
|
267
|
+
for (const b of bestanden) {
|
|
268
|
+
const v = JSON.parse(fs.readFileSync(path.join(map, b), 'utf8'))
|
|
269
|
+
const u = toetsPogingIdentiteit(v.input)
|
|
270
|
+
const ok = u.outcome === v.expected.outcome && (v.expected.reason_code === undefined || u.reason_code === v.expected.reason_code)
|
|
271
|
+
if (ok) past++
|
|
272
|
+
console.log((ok ? ' ok ' : ' FAIL ') + b.replace('.json', '').padEnd(40) + u.outcome + (u.reason_code ? ' / ' + u.reason_code : ''))
|
|
273
|
+
}
|
|
274
|
+
console.log('')
|
|
275
|
+
console.log(past + ' of ' + bestanden.length + ' vectors behave as recorded, across ' + CLAUSULES.length + ' normative clauses of x402 spec section 5.5.')
|
|
276
|
+
console.log('Every clause carries one acceptance and at least one refusal. No account, no key, no network.')
|
|
277
|
+
if (past !== bestanden.length) process.exitCode = 1
|
|
278
|
+
}
|
|
@@ -90,7 +90,15 @@ export async function meetEindpunt(url, { fetchImpl = fetch, methode = 'POST', l
|
|
|
90
90
|
// De herhaling blijft bewust smal: alleen 404 en 405, alleen als wij NIET met GET
|
|
91
91
|
// sondeerden, en precies een keer. Elke 404 blind herhalen zou het verkeer naar elk
|
|
92
92
|
// dood adres verdubbelen, en dat is niet aan ons om te doen.
|
|
93
|
-
|
|
93
|
+
// GEMETEN, EN DAARNA VERBREED. Na de 404 en 405 bleef er een restfout van 3,4 procent
|
|
94
|
+
// staan op zestig echte hosts, en die bestond uit twee andere codes die hetzelfde
|
|
95
|
+
// zeggen: cheaptokens.ai/api/buy gaf 400 op onze POST met een leeg lichaam en 402 op
|
|
96
|
+
// GET, en www.getbags.app/api/v1 gaf 406 op onze Accept-kop en 402 op GET. Alle vier
|
|
97
|
+
// de codes melden iets over ONS VERZOEK en niets over de bron, dus alle vier verdienen
|
|
98
|
+
// precies een tweede poging. De verzameling staat hier met naam zodat een vijfde code
|
|
99
|
+
// een gemeten beslissing vergt en niet stil meegroeit.
|
|
100
|
+
const VRAAG_OPNIEUW_BIJ = new Set([400, 404, 405, 406])
|
|
101
|
+
if (VRAAG_OPNIEUW_BIJ.has(resp.status)) {
|
|
94
102
|
const allow = resp.headers.get('allow') || resp.headers.get('Allow') || ''
|
|
95
103
|
const genoemd = allow
|
|
96
104
|
.split(',')
|