agent-receipt-verify 0.2.3 → 0.2.4

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.
@@ -0,0 +1,507 @@
1
+ #!/usr/bin/env node
2
+ // Controleer of een BETAALD antwoord WAAR is, bij een bron die de verkoper niet bezit.
3
+ //
4
+ // WAT DIT VASTSTELT, en de formulering is met opzet smal. Een handtekening bewijst dat
5
+ // een bericht ECHT is, niet dat het WAAR is. Zodra alleen de verkoper de uitvoering
6
+ // ziet, is elk bewijsstuk dat hij maakt een functie van zijn eigen toestand, en een
7
+ // handtekening eronder verandert daar niets aan. Deze controle pakt de andere helft:
8
+ // hij leest zelf, bij de bron, of de bewering van de verkoper klopt.
9
+ //
10
+ // WAT DIT UITDRUKKELIJK NIET VASTSTELT, en dit stond er in de eerste versie te breed in.
11
+ // - Het bewijst NIET dat de verkoper werk heeft verricht. Het bewijstoken staat in
12
+ // publieke DNS of in een publiek bestand, dus een luie verkoper kan het daar zelf
13
+ // lezen en teruggeven. De uitslag zegt dat het ANTWOORD WAAR is, niet dat het
14
+ // VERDIEND is. Dat verschil hoort hardop benoemd te worden en niet verstopt.
15
+ // - Het bewijst NIET dat het token het JUISTE token voor deze klant is. Dat token is
16
+ // een hmac van de uitgever; zonder diens sleutel is het door een vreemde niet na te
17
+ // rekenen. Wie dat wel beweert, overvraagt zijn eigen gereedschap.
18
+ // - Het bindt dit antwoord NIET aan een specifieke betaling. Die binding vraagt een
19
+ // ondertekend leveringsbewijs met een inhoudshash, en dat is een losse stap.
20
+ //
21
+ // WAT HET WEL AFSLUIT, en dat is de hele reden van deze herziening. De plek komt NIET
22
+ // uit het bonnetje. Hij wordt hier AFGELEID uit het eindpunt waarvoor de koper betaalde,
23
+ // met de vaste afspraak van de dienst zelf (zie DNS_LABEL en WELL_KNOWN_PATH). Zonder
24
+ // die binding koos de verkoper zowel het token als de plek, en dan is elke uitslag te
25
+ // regisseren: een token dat in een bekend publiek record staat, met dat record als plek,
26
+ // en de controle zegt groen. Dat is in onze eigen keuring werkelijk gedraaid met een
27
+ // verzonnen bonnetje dat naar een vreemd domein wees, en het kwam groen uit met
28
+ // exitcode 0. Daarom moet de koper nu het eindpunt noemen.
29
+ //
30
+ // UITKOMSTEN, drie met opzet, en over de zachte moet eerlijk gezegd worden wat hij wel
31
+ // en niet uitsluit. Een eerdere versie van deze koptekst beweerde dat de verkoper de
32
+ // zachte uitkomst nooit kan kiezen. Dat was onwaar, en onze eigen tweede keuring heeft
33
+ // acht routes gemeten waarlangs het toch lukte. Zeven daarvan zijn nu dicht: een
34
+ // onbruikbaar token, een 404 op de plek, en een dns-naam die niet bestaat zijn alle drie
35
+ // een AFKEURING geworden, want het zijn antwoorden en geen storingen.
36
+ //
37
+ // EEN ROUTE BLIJFT OPEN, EN DIE STAAT HIER OMDAT HIJ NIET TE DICHTEN IS. De verkoper
38
+ // noemt de METHODE, dus hij kiest welke van de twee bronnen gelezen wordt. Noemt hij een
39
+ // methode die wij niet kennen, dan kunnen wij niets afleiden en is de uitslag NIET
40
+ // GEMETEN. Dat is eerlijk, want wij hebben werkelijk niets gemeten, en het is tegelijk
41
+ // een route die een verkoper kan nemen. NIET GEMETEN betekent dus: wij konden niet
42
+ // kijken. Het betekent NIET: de verkoper is eerlijk.
43
+ // SUPPORTED als het token werkelijk op de AFGELEIDE plek staat. NOT_SUPPORTED als de
44
+ // bron bereikbaar was en het token er niet staat, en ook als het antwoord over een
45
+ // ander domein gaat dan waarvoor betaald is. NOT_MEASURED alleen als de controle
46
+ // werkelijk niet kon lopen. Wie die laatste twee door elkaar haalt, beschuldigt een
47
+ // eerlijke verkoper bij een dns-storing.
48
+ //
49
+ // GEBRUIK:
50
+ // node src/verify-delivery.mjs --answer=<bonnetje.json> --endpoint=<url van de klant>
51
+ // npx agent-receipt-verify verify-delivery --answer=a.json --endpoint=https://klant.example/pad
52
+ // Exitcodes: 0 gesteund, 1 niet gesteund, 2 niet te meten.
53
+
54
+ import fs from 'node:fs'
55
+ import path from 'node:path'
56
+ import dns from 'node:dns/promises'
57
+ import { fileURLToPath } from 'node:url'
58
+
59
+ /** De twee plekken waar dit soort bewijs hoort te staan, uit de dienst zelf. */
60
+ export const DNS_LABEL = '_endpoint-attest'
61
+ export const WELL_KNOWN_PATH = '/.well-known/endpoint-attest.txt'
62
+
63
+ /**
64
+ * Een dak op het document en een klok op het verzoek.
65
+ *
66
+ * Zonder het dak OP DE STROOM leest dit alles in voordat het knipt: een plek die
67
+ * eindeloos blijft sturen trok in de keuring 2,4 GiB geheugen in veertig seconden.
68
+ * Zonder de klok hangt het commando voor altijd aan een bestemming die wel verbindt en
69
+ * nooit antwoordt.
70
+ */
71
+ export const MAX_DOCUMENT = 65536
72
+ export const HAAL_TIJDSLIMIET_MS = 10000
73
+
74
+ /**
75
+ * Een hostnaam met een punt op het eind is dezelfde naam.
76
+ *
77
+ * Zonder dit zet een punt achter de naam elke naamregel hieronder buiten werking, want
78
+ * "localhost." eindigt niet op "localhost". Dat is in de keuring gemeten en er werd
79
+ * echt verbonden met 127.0.0.1.
80
+ */
81
+ export function normaliseerHost(h) {
82
+ let s = String(h ?? '').toLowerCase().trim()
83
+ while (s.endsWith('.')) s = s.slice(0, -1)
84
+ return s
85
+ }
86
+
87
+ /**
88
+ * Is dit opgeloste adres een adres waar een vreemde ons niet naartoe mag sturen?
89
+ *
90
+ * WAAROM DIT OP HET ADRES ZIT EN NIET OP DE NAAM. Een naamfilter kan dit per definitie
91
+ * niet dekken, en dat is gemeten in plaats van beredeneerd: 127-0-0-1.nip.io,
92
+ * localtest.me, 169-254-169-254.nip.io en 10-0-0-1.nip.io zijn volkomen normale
93
+ * publieke namen zonder enig verdacht label, en ze wijzen alle vier naar binnen. De
94
+ * naam zegt dus niets. Alleen het opgeloste adres zegt iets.
95
+ *
96
+ * Geeft een reden terug als het adres geweigerd hoort te worden, en null als het mag.
97
+ */
98
+ export function privaatAdres(ip) {
99
+ const s = String(ip ?? '').toLowerCase().trim()
100
+ if (/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(s)) {
101
+ const d = s.split('.').map(Number)
102
+ if (d.some((x) => !Number.isInteger(x) || x < 0 || x > 255)) return 'not a usable address: ' + s
103
+ if (d[0] === 0) return 'this-network address: ' + s
104
+ if (d[0] === 10) return 'private address: ' + s
105
+ if (d[0] === 127) return 'loopback address: ' + s
106
+ if (d[0] === 192 && d[1] === 168) return 'private address: ' + s
107
+ if (d[0] === 172 && d[1] >= 16 && d[1] <= 31) return 'private address: ' + s
108
+ if (d[0] === 169 && d[1] === 254) return 'link-local address: ' + s
109
+ if (d[0] === 100 && d[1] >= 64 && d[1] <= 127) return 'carrier-grade nat address: ' + s
110
+ if (d[0] === 192 && d[1] === 0 && d[2] === 0) return 'reserved address: ' + s
111
+ if (d[0] >= 224) return 'multicast or reserved address: ' + s
112
+ return null
113
+ }
114
+ if (s.includes(':')) {
115
+ const kaal = s.replace(/^\[/, '').replace(/\]$/, '').split('%')[0]
116
+ if (kaal === '::1' || kaal === '::') return 'loopback or unspecified address: ' + kaal
117
+ if (/^f[cd][0-9a-f]{2}:/.test(kaal)) return 'unique local address: ' + kaal
118
+ if (/^fe[89ab][0-9a-f]:/.test(kaal)) return 'link-local address: ' + kaal
119
+ if (/^ff[0-9a-f]{2}:/.test(kaal)) return 'multicast address: ' + kaal
120
+ // Een ipv4-adres dat in een ipv6-vorm verstopt zit, hoort langs dezelfde regels.
121
+ const ingebed = kaal.match(/([0-9]{1,3}(?:\.[0-9]{1,3}){3})$/)
122
+ if (ingebed) {
123
+ const r = privaatAdres(ingebed[1])
124
+ if (r) return 'ipv6-mapped ' + r
125
+ }
126
+ return null
127
+ }
128
+ return 'not a usable address: ' + s
129
+ }
130
+
131
+ /**
132
+ * Weiger een plek die niet publiek is, en los de naam daarvoor echt op.
133
+ *
134
+ * `lookup` is inspuitbaar zodat de test geen dns nodig heeft. Faalt het oplossen, dan is
135
+ * dat GEEN groen licht: zonder opgelost adres weten we niet waar we heen gaan, en dan
136
+ * gaan we niet.
137
+ */
138
+ export async function plekIsPubliek(waarde, { lookup = dns.lookup } = {}) {
139
+ // EEN ANDER SCHEMA WORDT GEWEIGERD EN NIET OMGEBOUWD. Zonder deze regel vuurde de
140
+ // schema-controle hieronder NOOIT: alles wat niet met http of https begon kreeg er
141
+ // https voor, dus een ftp-adres werd een https-adres met hostnaam "ftp". Gevonden
142
+ // door een NIEUWE test van onze eigen suite, die dus iets echts aanwees in plaats van
143
+ // alleen een oude verwachting te bevestigen.
144
+ const heeftSchema = /^[a-z][a-z0-9+.-]*:/i.test(String(waarde))
145
+ if (heeftSchema && !/^https?:/i.test(String(waarde))) {
146
+ const schema = String(waarde).split(':')[0]
147
+ return { ok: false, reden: 'only http or https is allowed, got ' + schema + ':' }
148
+ }
149
+ let u
150
+ try {
151
+ u = new URL(/^https?:\/\//i.test(waarde) ? waarde : 'https://' + waarde)
152
+ } catch {
153
+ return { ok: false, reden: 'not a usable location' }
154
+ }
155
+ // HTTP WORDT NIET MEER GEWEIGERD, EN DAT IS EEN CORRECTIE. Hier stond "alleen https",
156
+ // maar de uitgever van dit bewijs staat http uitdrukkelijk toe (zie parseEndpoint in
157
+ // packages/endpoint-attest/src/ownership.ts) en publiceert dan ook op http. Een eigen,
158
+ // strengere eis klinkt veiliger en levert hier het tegenovergestelde op: een eerlijke
159
+ // verkoper met een http-eindpunt werd afgekeurd op een regel die de dienst zelf niet
160
+ // heeft. De echte grens blijft staan en die zit op het OPGELOSTE ADRES, niet op het
161
+ // schema. Wie over platte http leest hoort dat wel in de uitslag te zien, want dan kan
162
+ // iemand op het pad ertussen het antwoord verzinnen.
163
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') {
164
+ return { ok: false, reden: 'only http or https is allowed, got ' + u.protocol }
165
+ }
166
+ if (u.username || u.password) return { ok: false, reden: 'credentials in the location are refused' }
167
+ const h = normaliseerHost(u.hostname)
168
+ if (!h) return { ok: false, reden: 'no hostname in the location' }
169
+ if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) {
170
+ return { ok: false, reden: 'non-public hostname: ' + h }
171
+ }
172
+ if (h.startsWith('[') || h.includes(':')) return { ok: false, reden: 'literal ipv6 is refused' }
173
+ if (/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(h)) {
174
+ const r = privaatAdres(h)
175
+ if (r) return { ok: false, reden: r }
176
+ return { ok: true, url: u, adressen: [h], plattekst: u.protocol === 'http:' }
177
+ }
178
+ let adressen
179
+ try {
180
+ const uit = await lookup(h, { all: true })
181
+ adressen = (Array.isArray(uit) ? uit : [uit]).map((a) => (a && a.address ? a.address : String(a)))
182
+ } catch (e) {
183
+ return { ok: false, reden: 'could not resolve ' + h + ': ' + String(e?.message ?? e).slice(0, 80) }
184
+ }
185
+ if (!adressen.length) return { ok: false, reden: 'no address for ' + h }
186
+ for (const a of adressen) {
187
+ const r = privaatAdres(a)
188
+ if (r) return { ok: false, reden: h + ' resolves to a ' + r }
189
+ }
190
+ return { ok: true, url: u, adressen, plattekst: u.protocol === 'http:' }
191
+ }
192
+
193
+ /**
194
+ * Welke plek HOORT hier gelezen te worden, gegeven het eindpunt waarvoor betaald is.
195
+ *
196
+ * Dit is de kern van de reparatie. De plek is geen invoer van de verkoper maar een
197
+ * gevolg van de aanvraag van de koper, met de vaste afspraak van de dienst erbij.
198
+ */
199
+ export function verwachtePlek(endpoint, methode) {
200
+ if (typeof endpoint !== 'string' || !endpoint.trim()) {
201
+ return { klacht: 'no endpoint was named by the caller, so there is nothing to bind the location to' }
202
+ }
203
+ let u
204
+ try {
205
+ // Een kale hostnaam krijgt https ervoor, want dan is er geen schema om te volgen.
206
+ // Staat er WEL een schema, dan blijft het staan: http is bij de uitgever geldig en
207
+ // stilzwijgend naar https tillen levert een andere plek dan hij publiceert.
208
+ u = new URL(/^https?:\/\//i.test(endpoint) ? endpoint : 'https://' + endpoint)
209
+ } catch {
210
+ return { klacht: 'the endpoint is not a usable url: ' + endpoint.slice(0, 60) }
211
+ }
212
+ if (u.username || u.password) {
213
+ return { klacht: 'the endpoint must not carry credentials' }
214
+ }
215
+ const host = normaliseerHost(u.hostname)
216
+ if (!host) return { klacht: 'the endpoint has no hostname' }
217
+ // DE PLEK WORDT PRECIES ZO GEBOUWD ALS DE UITGEVER HEM BOUWT, en dat was hij niet.
218
+ // Gemeten tegen hun eigen proofLocation in packages/endpoint-attest/src/ownership.ts:
219
+ // bij dns-txt gebruikt die `hostname`, dus zonder poort, want een dns-naam kan geen
220
+ // poort dragen. Bij well-known-file gebruikt die `${url.protocol}//${url.host}`, dus
221
+ // MET poort en met het ECHTE schema, en hun parseEndpoint staat http uitdrukkelijk
222
+ // toe. Wij bouwden hier 'https://' plus hostname, dus wij lieten de poort vallen en
223
+ // dwongen https af. Gevolg, en dat is de valse beschuldiging waar deze hele controle
224
+ // tegen bedoeld is: een eerlijke verkoper met een http-eindpunt of een poort die niet
225
+ // 443 is, kreeg NIET TE METEN, of zelfs een harde NIET GESTEUND als er op 443
226
+ // toevallig een ander bestand stond.
227
+ if (methode === 'dns-txt') return { host, plek: DNS_LABEL + '.' + host }
228
+ if (methode === 'well-known-file') {
229
+ return { host, plek: u.protocol + '//' + u.host + WELL_KNOWN_PATH, schema: u.protocol }
230
+ }
231
+ return { klacht: 'unknown ownership method: ' + String(methode) }
232
+ }
233
+
234
+ /**
235
+ * Wat zegt de genoemde plek, vergeleken met de plek die wij zelf afleiden?
236
+ *
237
+ * WAAROM DIT NOOIT DE UITKOMST BEPAALT, behalve bij een ander domein. De zachte
238
+ * uitkomst mag niet door de verkoper te kiezen zijn. Een eerdere versie van deze
239
+ * reparatie gaf NOT_MEASURED zodra de genoemde plek afweek van de afgesproken plek, en
240
+ * dat liet een verkoper die niets leverde de zachte uitslag kiezen door simpelweg een
241
+ * ander label op hetzelfde domein te noemen. Nu wordt ALTIJD de afgeleide plek gelezen
242
+ * en is de genoemde plek alleen nog een mededeling. Een ander DOMEIN blijft hard, want
243
+ * dan gaat het antwoord aantoonbaar over iets anders dan waarvoor betaald is.
244
+ */
245
+ export function plekVergelijking(genoemdePlek, verwacht) {
246
+ if (!genoemdePlek) return { gelijk: true }
247
+ let genoemdeHost = normaliseerHost(genoemdePlek)
248
+ if (/^https?:\/\//i.test(genoemdePlek)) {
249
+ try {
250
+ genoemdeHost = normaliseerHost(new URL(genoemdePlek).hostname)
251
+ } catch { /* vorm al fout; de vergelijking hieronder wijst hem af */ }
252
+ }
253
+ const onderPaidHost = genoemdeHost === verwacht.host || genoemdeHost.endsWith('.' + verwacht.host)
254
+ if (!onderPaidHost) {
255
+ return {
256
+ hard: 'the answer points at ' + genoemdeHost + ', which is not ' + verwacht.host +
257
+ ', the host the caller paid about',
258
+ }
259
+ }
260
+ const zelfdePlek = genoemdePlek === verwacht.plek ||
261
+ normaliseerHost(genoemdePlek) === normaliseerHost(verwacht.plek)
262
+ if (zelfdePlek) return { gelijk: true }
263
+ return {
264
+ waarschuwing: 'the answer names ' + genoemdePlek + ', this check read ' + verwacht.plek +
265
+ ' instead, which is where the endpoint-attest convention puts it',
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Staat het token er, exact?
271
+ *
272
+ * WAAROM EXACT EN NIET ALS DEELSTRING. De uitgever vergelijkt zelf exact. Een
273
+ * deelvergelijking laat een token van acht tekens slagen zodra die reeks ergens in een
274
+ * willekeurig record voorkomt, en dat was in de keuring de hele barriere: de drempel
275
+ * bleek lengte in plaats van herkomst. Een TXT-record kan in stukken aankomen, dus die
276
+ * horen eerst aan elkaar voor er vergeleken wordt.
277
+ */
278
+ export function tokenStaatEr(waarden, token) {
279
+ const t = String(token).trim()
280
+ if (!t) return false
281
+ return (waarden ?? []).some((w) => String(w).trim() === t)
282
+ }
283
+
284
+ /**
285
+ * Lees de bron zelf, met een dak en een klok.
286
+ */
287
+ async function haalDocument(url, fetchImpl) {
288
+ const r = await fetchImpl(url, {
289
+ redirect: 'error',
290
+ headers: { accept: 'text/plain' },
291
+ signal: AbortSignal.timeout(HAAL_TIJDSLIMIET_MS),
292
+ })
293
+ if (!r.ok) {
294
+ // EEN HTTP-FOUTSTATUS IS EEN ANTWOORD VAN DE BRON, GEEN STORING VAN ONS. Een 404 op
295
+ // het well-known pad betekent dat de klant daar niets heeft gepubliceerd, en dat is
296
+ // bewijs van afwezigheid. Dit ging eerder naar NIET GEMETEN, waardoor een verkoper de
297
+ // zachte uitslag kon kiezen door simpelweg de andere methode te noemen dan die de
298
+ // klant gebruikte. Een 5xx is wel een storing, en blijft dus niet-gemeten.
299
+ if (r.status >= 400 && r.status < 500) {
300
+ return { afwezig: 'the location answered http ' + r.status + ', so nothing is published there' }
301
+ }
302
+ return { klacht: 'the location answered http ' + r.status }
303
+ }
304
+ // Het dak hoort OP de stroom. Pas na het inlezen knippen is geen grens.
305
+ if (r.body && typeof r.body.getReader === 'function') {
306
+ const lezer = r.body.getReader()
307
+ const brokken = []
308
+ let totaal = 0
309
+ while (totaal < MAX_DOCUMENT) {
310
+ const { done, value } = await lezer.read()
311
+ if (done) break
312
+ if (!value) continue
313
+ totaal += value.byteLength ?? value.length ?? 0
314
+ brokken.push(value)
315
+ }
316
+ try { await lezer.cancel() } catch { /* de bron is al weg, dat is hier geen fout */ }
317
+ const samen = new Uint8Array(Math.min(totaal, MAX_DOCUMENT))
318
+ let i = 0
319
+ for (const b of brokken) {
320
+ for (let k = 0; k < b.length && i < samen.length; k++) samen[i++] = b[k]
321
+ }
322
+ return { tekst: new TextDecoder().decode(samen) }
323
+ }
324
+ return { tekst: String(await r.text()).slice(0, MAX_DOCUMENT) }
325
+ }
326
+
327
+ /**
328
+ * Zoek het token zelf op, bij de bron die de verkoper niet bezit.
329
+ *
330
+ * `endpoint` is VERPLICHT: het is de enige invoer die niet van de verkoper komt, en dus
331
+ * het enige waaraan de plek gebonden kan worden. Zonder dat argument kan deze functie
332
+ * met opzet niets groen noemen.
333
+ */
334
+ export async function zoekToken(antwoord, opties = {}) {
335
+ const { endpoint, resolveTxt = dns.resolveTxt, fetchImpl = fetch, lookup = dns.lookup } = opties
336
+ const methode = antwoord?.method
337
+ const token = antwoord?.token
338
+ const genoemdePlek = typeof antwoord?.location === 'string' ? antwoord.location.trim() : ''
339
+
340
+ if (typeof token !== 'string' || token.trim().length < 8) {
341
+ // EEN ONBRUIKBAAR TOKEN IS EEN AFKEURING, GEEN ONVOLLEDIGE METING. Dit gaf eerder
342
+ // NIET GEMETEN, en dat was een van de acht routes waarlangs een verkoper die niets
343
+ // leverde toch de zachte uitslag kreeg: stuur een token van zeven tekens, of een
344
+ // getal, en de uitslag was exitcode 2 in plaats van 1. Het token komt van de
345
+ // verkoper. Onbruikbare waar sturen is zijn keuze, niet onze storing.
346
+ return { outcome: 'NOT_SUPPORTED', reden: 'the answer carries no usable token' }
347
+ }
348
+ const verwacht = verwachtePlek(endpoint, methode)
349
+ if (verwacht.klacht) return { outcome: 'NOT_MEASURED', reden: verwacht.klacht }
350
+
351
+ // Gaat het antwoord over een ANDER domein dan waarvoor betaald is, dan is dat geen
352
+ // storing maar een antwoord over iets anders, en dat is een afkeuring. Alle andere
353
+ // afwijkingen zijn alleen een mededeling: we lezen hieronder toch de afgeleide plek.
354
+ const vergelijking = plekVergelijking(genoemdePlek, verwacht)
355
+ if (vergelijking.hard) return { outcome: 'NOT_SUPPORTED', reden: vergelijking.hard }
356
+ const waarschuwing = vergelijking.waarschuwing
357
+
358
+ if (methode === 'dns-txt') {
359
+ if (!/^[a-z0-9._-]+$/i.test(verwacht.plek) || verwacht.plek.length > 253) {
360
+ return { outcome: 'NOT_MEASURED', reden: 'not a usable dns name: ' + verwacht.plek }
361
+ }
362
+ let records
363
+ try {
364
+ records = await resolveTxt(verwacht.plek)
365
+ } catch (e) {
366
+ // AFWEZIGHEID IS EEN ANTWOORD, EEN STORING IS DAT NIET. Hier ging elke dns-fout
367
+ // naar NIET GEMETEN, dus ook het geval dat de naam gewoon NIET BESTAAT. Dat is
368
+ // precies het bewijs dat er niets gepubliceerd staat, net zoals "het token staat in
369
+ // geen van de N records" dat is. Een resolver die uitvalt of weigert is wel een
370
+ // echte storing aan onze kant. De codes komen uit node se eigen dns-module.
371
+ const code = String(e?.code ?? '')
372
+ const bestaatNiet = code === 'ENOTFOUND' || code === 'ENODATA' || code === 'NXDOMAIN'
373
+ if (bestaatNiet) {
374
+ return {
375
+ outcome: 'NOT_SUPPORTED',
376
+ reden: 'there is no TXT record at ' + verwacht.plek + ', so nothing is published there',
377
+ waarschuwing,
378
+ }
379
+ }
380
+ return { outcome: 'NOT_MEASURED', reden: 'dns lookup failed: ' + String(e?.message ?? e).slice(0, 90) }
381
+ }
382
+ const geplakt = (records ?? []).map((r) => (Array.isArray(r) ? r.join('') : String(r)))
383
+ return tokenStaatEr(geplakt, token)
384
+ ? { outcome: 'SUPPORTED', bron: 'dns TXT ' + verwacht.plek, records: geplakt.length, waarschuwing }
385
+ : {
386
+ outcome: 'NOT_SUPPORTED',
387
+ reden: 'the token is not any of the ' + geplakt.length + ' TXT record(s) at ' + verwacht.plek,
388
+ waarschuwing,
389
+ }
390
+ }
391
+
392
+ if (methode === 'well-known-file') {
393
+ const poort = await plekIsPubliek(verwacht.plek, { lookup })
394
+ if (!poort.ok) return { outcome: 'NOT_MEASURED', reden: poort.reden }
395
+ let uit
396
+ try {
397
+ uit = await haalDocument(poort.url.toString(), fetchImpl)
398
+ } catch (e) {
399
+ return { outcome: 'NOT_MEASURED', reden: 'fetch failed: ' + String(e?.message ?? e).slice(0, 90) }
400
+ }
401
+ if (uit.afwezig) return { outcome: 'NOT_SUPPORTED', reden: uit.afwezig, waarschuwing }
402
+ if (uit.klacht) return { outcome: 'NOT_MEASURED', reden: uit.klacht }
403
+ // HET HELE GETRIMDE LICHAAM, NIET EEN REGEL ERVAN, en dat is een correctie tegen mijn
404
+ // eigen onderbouwing in. Dit bestand beargumenteert hierboven dat de vergelijking
405
+ // exact is omdat de uitgever exact vergelijkt, en deed daarna iets ruimers: het token
406
+ // werd geaccepteerd als ELKE regel van het document. Gemeten tegen de uitgever zelf
407
+ // (packages/endpoint-attest/src/ownership.ts): die eist dat het getrimde lichaam
408
+ // GELIJK is aan het token, en zegt er in zijn eigen commentaar bij waarom, namelijk
409
+ // dat een token dat ergens in een verder onverwante pagina staat niet aantoont dat de
410
+ // beheerder het met opzet heeft gepubliceerd. Een bestand met het token op een eigen
411
+ // regel tussen html kwam bij ons uit op GESTEUND terwijl de echte poort het afwees.
412
+ // Dat is de enige richting die echt telt: ons gereedschap zei waar over iets dat
413
+ // geweigerd wordt.
414
+ const lichaam = String(uit.tekst).trim()
415
+ const platteNoot = poort.plattekst
416
+ ? 'read over plain http, so anyone on the path could have written this answer'
417
+ : null
418
+ const samen = [waarschuwing, platteNoot].filter(Boolean).join('; ') || undefined
419
+ return lichaam === String(token).trim()
420
+ ? { outcome: 'SUPPORTED', bron: poort.url.toString(), records: 1, waarschuwing: samen }
421
+ : {
422
+ outcome: 'NOT_SUPPORTED',
423
+ reden: 'the document at ' + poort.url.toString() + ' is not exactly the token',
424
+ waarschuwing: samen,
425
+ }
426
+ }
427
+
428
+ return { outcome: 'NOT_MEASURED', reden: 'unknown ownership method: ' + String(methode) }
429
+ }
430
+
431
+ /**
432
+ * Toon een veld van een vreemde zonder dat hij de uitslag kan naschrijven.
433
+ *
434
+ * Zonder dit zet een verkoper stuurcodes of nieuwe regels in een veld en schrijft zo
435
+ * zijn eigen SUPPORTED-regel in onze uitvoer, op de plek waar de mens kijkt.
436
+ */
437
+ export function veiligVeld(waarde, max = 120) {
438
+ const s = String(waarde ?? '')
439
+ let uit = ''
440
+ for (const teken of s) {
441
+ const c = teken.codePointAt(0)
442
+ uit += c < 0x20 || c === 0x7f || (c >= 0x80 && c <= 0x9f) ? '.' : teken
443
+ if (uit.length >= max) return uit + '...'
444
+ }
445
+ return uit || '(none)'
446
+ }
447
+
448
+ const ditBestand = fileURLToPath(import.meta.url)
449
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(ditBestand)) {
450
+ const vlag = (naam) => process.argv.slice(2).find((a) => a.startsWith('--' + naam + '='))
451
+ const antwoordVlag = vlag('answer')
452
+ const endpointVlag = vlag('endpoint')
453
+ if (!antwoordVlag || !endpointVlag) {
454
+ console.log('usage: node src/verify-delivery.mjs --answer=<file> --endpoint=<url of the customer>')
455
+ console.log('')
456
+ console.log('--answer the json a paid call returned: method, token, location, verifiedAt.')
457
+ console.log('--endpoint the url you paid to have checked. REQUIRED, and it is the point:')
458
+ console.log(' the location is derived from this, never taken from the answer.')
459
+ console.log('')
460
+ console.log('This then reads that location itself, on the customer domain, and reports')
461
+ console.log('whether the answer is TRUE. It does not claim the seller did any work, and it')
462
+ console.log('cannot recompute the token, which is an hmac held by the issuer.')
463
+ process.exitCode = 2
464
+ } else {
465
+ const pad = antwoordVlag.slice('--answer='.length)
466
+ const endpoint = endpointVlag.slice('--endpoint='.length)
467
+ let antwoord
468
+ try {
469
+ antwoord = JSON.parse(fs.readFileSync(pad, 'utf8'))
470
+ } catch (e) {
471
+ console.error('could not read the answer: ' + e.message)
472
+ process.exit(2)
473
+ }
474
+ const verwacht = verwachtePlek(endpoint, antwoord?.method)
475
+ console.log('what the seller answered:')
476
+ console.log(' method : ' + veiligVeld(antwoord?.method, 40))
477
+ console.log(' token : ' + veiligVeld(String(antwoord?.token ?? '').slice(0, 24)) + '...')
478
+ console.log(' location : ' + veiligVeld(antwoord?.location))
479
+ console.log(' verifiedAt : ' + veiligVeld(antwoord?.verifiedAt, 40) + ' (shown, never checked)')
480
+ console.log('')
481
+ console.log('what this check reads instead, derived from --endpoint:')
482
+ console.log(' ' + (verwacht.plek ? veiligVeld(verwacht.plek, 200) : verwacht.klacht))
483
+ console.log('')
484
+ const u = await zoekToken(antwoord, { endpoint })
485
+ console.log('read independently, at a source the seller does not own:')
486
+ if (u.waarschuwing) console.log(' note : ' + veiligVeld(u.waarschuwing, 300))
487
+ if (u.outcome === 'SUPPORTED') {
488
+ console.log(' SUPPORTED : the token really is published at ' + veiligVeld(u.bron, 200))
489
+ console.log(' meaning : the answer is TRUE. The public world agrees with it, and the')
490
+ console.log(' location was derived here rather than taken from the answer.')
491
+ console.log(' not shown : that the seller did any work (the record is public, so he could')
492
+ console.log(' have read it too), nor that this token is the right one for this')
493
+ console.log(' customer (that needs the issuer key), nor that this answer')
494
+ console.log(' belongs to one specific payment.')
495
+ process.exit(0)
496
+ }
497
+ if (u.outcome === 'NOT_SUPPORTED') {
498
+ console.log(' NOT SUPPORTED: ' + veiligVeld(u.reden, 300))
499
+ console.log(' meaning : the source was reachable and does not back this answer.')
500
+ process.exit(1)
501
+ }
502
+ console.log(' NOT MEASURED : ' + veiligVeld(u.reden, 300))
503
+ console.log(' meaning : the check could not run. This is NOT a failed delivery, and')
504
+ console.log(' treating it as one would blame an honest seller for a dns fault.')
505
+ process.exit(2)
506
+ }
507
+ }
@@ -172,8 +172,31 @@ export function censusPerHost(ruw) {
172
172
  return { operators, zonderHost, dubbeleResources, uniekeResources: gezieneResources.size }
173
173
  }
174
174
 
175
+ /**
176
+ * Het tweede argument is een uitvoerpad, maar een vlag is geen pad.
177
+ *
178
+ * WAAROM DIT EEN APARTE FUNCTIE IS. Zonder deze controle schrijft dit commando naar
179
+ * letterlijk wat je het geeft, dus `census --help` maakt een bestand met de naam
180
+ * `--help` in de map waar de gebruiker staat. Dat is precies de klasse fout die dit
181
+ * pakket eerder al kostte: een commando dat wij publiceren moet bij een vreemde doen
182
+ * wat hij verwacht, niet rommel achterlaten. Onze eigen testsuite liet er zelf een van
183
+ * achter, en dat was het signaal.
184
+ */
185
+ export function uitvoerPadUitArgument(arg) {
186
+ if (arg === undefined) return { pad: null }
187
+ if (typeof arg !== 'string' || arg.startsWith('-')) {
188
+ return { pad: null, klacht: `"${arg}" looks like a flag, not an output path. This command takes an optional file path and nothing else.` }
189
+ }
190
+ return { pad: arg }
191
+ }
192
+
175
193
  if (process.argv[1]?.endsWith('x402-discovery-census.mjs')) {
176
- const uitPad = process.argv[2]
194
+ const keuze = uitvoerPadUitArgument(process.argv[2])
195
+ if (keuze.klacht) {
196
+ console.error(keuze.klacht)
197
+ process.exit(2)
198
+ }
199
+ const uitPad = keuze.pad
177
200
  const ruw = await haalIndex()
178
201
  const c = censusPerHost(ruw.items)
179
202
 
@@ -16,21 +16,51 @@ import { createHash } from 'node:crypto'
16
16
  const TAG = Buffer.from('x402-receipt-authorization/0', 'ascii')
17
17
  const NUL = Buffer.from([0])
18
18
 
19
+ // De lengte is VERPLICHT, niet optioneel. Eerder was `verwacht === undefined` stil
20
+ // toegestaan, en dan deed deze functie niets anders dan hex naar bytes omzetten. Dat
21
+ // was precies het gat dat hieronder beschreven staat: zodra `lengtes` per ongeluk geen
22
+ // object was, viel elke lengtecontrole weg zonder dat er iets te zien was.
19
23
  const bytes = (h, naam, verwacht) => {
24
+ if (!Number.isInteger(verwacht) || verwacht <= 0) {
25
+ throw new Error(`geen lengte-eis bekend voor ${naam}, dus dit veld wordt niet geaccepteerd`)
26
+ }
20
27
  const b = Buffer.from(String(h ?? '').replace(/^0x/, ''), 'hex')
21
- if (verwacht !== undefined && b.length !== verwacht) {
28
+ if (b.length !== verwacht) {
22
29
  throw new Error(`${naam} moet ${verwacht} bytes zijn, kreeg ${b.length}`)
23
30
  }
24
31
  return b
25
32
  }
26
33
 
34
+ // De bekende methoden, met de lengte-eis per veld.
35
+ //
36
+ // WAAROM DIT EEN APARTE TABEL IS EN MET hasOwn WORDT OPGEZOCHT. Dit stond als
37
+ // `{ eip3009: {...} }[method]` in de functie zelf, en dat is een opzoeking die ook de
38
+ // PROTOTYPEKETEN raakt. Zelf gemeten op de gepubliceerde versie: `constructor`,
39
+ // `toString`, `valueOf`, `hasOwnProperty` en `__proto__` kwamen alle vijf door de
40
+ // `!lengtes`-poort, want ze geven een functie terug en een functie is waar. Daarna was
41
+ // `lengtes.asset` undefined en viel elke lengtecontrole weg, en dan zijn de velden
42
+ // zonder scheiding aan elkaar geplakt. De botsing is echt en narekenbaar:
43
+ //
44
+ // method constructor, asset 0xaabb, authorizer 0xcc
45
+ // method constructor, asset 0xaa, authorizer 0xbbcc
46
+ // geven BEIDE 0xd7f3867bb7a62bc13ba3e5e332c2109f3dc397d94c2f0d8c1238b8d87faa3836
47
+ //
48
+ // Twee verschillende afrekeningen die hetzelfde commitment opleveren is exact wat een
49
+ // commitment moet uitsluiten, dus dit is geen schoonheidsfoutje.
50
+ const LENGTES = { eip3009: { asset: 20, authorizer: 20 } }
51
+
52
+ /** Is dit een methode die wij echt kennen, en niet iets uit de prototypeketen? */
53
+ export function kentMethode(method) {
54
+ return typeof method === 'string' && Object.hasOwn(LENGTES, method)
55
+ }
56
+
27
57
  // §5.6.2. Elk veld na `method` heeft een vaste lengte, dus het voorbeeld is
28
58
  // ondubbelzinnig. Lengtes worden hier afgedwongen: een veld van de verkeerde lengte
29
59
  // zou stilzwijgend een ander commitment opleveren dat nergens op opent.
30
60
  export function berekenCommitment({ method, asset, authorizer, identifier, salt }) {
31
61
  if (!method) throw new Error('method ontbreekt')
32
- const lengtes = { eip3009: { asset: 20, authorizer: 20 } }[method]
33
- if (!lengtes) throw new Error(`onbekende authorizationMethod: ${method}`)
62
+ if (!kentMethode(method)) throw new Error(`onbekende authorizationMethod: ${method}`)
63
+ const lengtes = LENGTES[method]
34
64
 
35
65
  const pre = Buffer.concat([
36
66
  TAG, NUL,
@@ -52,9 +82,25 @@ export function opentCommitment({ receipt, salt, asset, kandidaatIdentifiers })
52
82
  const commitment = payload?.authorizationCommitment
53
83
  if (!method || !commitment) return { linked: false, reden: 'receipt carries no commitment' }
54
84
 
85
+ // De vaste velden EERST, buiten de lus. Anders kan een onbruikbaar zout of een
86
+ // onbekende methode nooit zichtbaar worden: elke ronde gooit dan dezelfde fout, de
87
+ // catch slikt hem op, en de uitslag wordt "geen kandidaat opent dit" terwijl de
88
+ // waarheid is dat er niets te berekenen viel. Dat is het verschil tussen een
89
+ // afkeuring en een onvolledige meting, en dat verschil mag nooit wegvallen.
90
+ if (!kentMethode(method)) {
91
+ return { linked: false, meetbaar: false, reden: `unknown authorizationMethod: ${method}` }
92
+ }
93
+ try {
94
+ berekenCommitment({ method, asset, authorizer: payload.payer, identifier: '0x' + '00'.repeat(32), salt })
95
+ } catch (e) {
96
+ return { linked: false, meetbaar: false, reden: 'cannot compute a commitment here: ' + e.message }
97
+ }
98
+
55
99
  const treffers = []
56
100
  for (const identifier of kandidaatIdentifiers ?? []) {
57
101
  let berekend
102
+ // Hier kan nog maar een ding misgaan: deze ene identifier heeft de verkeerde
103
+ // lengte. Dat is dan geen treffer, en dat is de juiste uitkomst.
58
104
  try { berekend = berekenCommitment({ method, asset, authorizer: payload.payer, identifier, salt }) } catch { continue }
59
105
  if (berekend.toLowerCase() === String(commitment).toLowerCase()) treffers.push(identifier)
60
106
  }