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
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Een onafhankelijke verifieerder voor EIP-3009 betaalautorisaties, geschreven tegen
|
|
2
|
+
// de vectoren van iamonuwa/kanon en niet tegen zijn code.
|
|
3
|
+
//
|
|
4
|
+
// WAAROM DIT HIER STAAT, want het is de ANDERE kant van wat deze repo tot nu toe deed.
|
|
5
|
+
// Alles hier ging over de VERKOPER: zijn ondertekende aanbod, zijn bonnetje, en de
|
|
6
|
+
// vraag of dat bonnetje op precies een afrekening op de keten opent. Een EIP-3009
|
|
7
|
+
// autorisatie is de KOPER-kant: de handtekening waarmee een agent zijn geld vrijgeeft,
|
|
8
|
+
// voor er iets afgerekend is. Samen zijn dat de twee uiteinden van dezelfde transactie,
|
|
9
|
+
// en ze overlappen nergens. Dat is precies waarom een tweede implementatie hier iets
|
|
10
|
+
// toevoegt in plaats van iets te verdubbelen.
|
|
11
|
+
//
|
|
12
|
+
// DE REGEL DIE DEZE HELE REPO DRAAGT, ook hier: geschreven tegen de GEPUBLICEERDE
|
|
13
|
+
// vectoren en de specificaties (EIP-3009, EIP-712), nooit tegen de broncode van de
|
|
14
|
+
// referentie-implementatie. Twee implementaties die elkaars code niet gezien hebben en
|
|
15
|
+
// toch op dezelfde uitslagen komen, dat is interop. Een implementatie die met zichzelf
|
|
16
|
+
// instemt bewijst niets.
|
|
17
|
+
//
|
|
18
|
+
// DE VOLGORDE VAN DE CONTROLES IS ZELF EEN UITSPRAAK, en zij volgt uit zijn eigen
|
|
19
|
+
// beschrijvingen bij de vectoren:
|
|
20
|
+
// 1. netwerk (platte tekst, voor de handtekening; zijn tekst: "the plaintext mismatch")
|
|
21
|
+
// 2. kneedbaarheid van de handtekening (voor het herstellen, anders herstel je een
|
|
22
|
+
// tweede geldige vorm van dezelfde handtekening)
|
|
23
|
+
// 3. geldigheidsvenster
|
|
24
|
+
// 4. herstellen en de ondertekenaar vergelijken
|
|
25
|
+
// 5. bedrag (zijn tekst: "caught only after the signature verifies")
|
|
26
|
+
// 6. nonce al verbruikt
|
|
27
|
+
// Een andere volgorde geeft bij een vector met twee gebreken een andere redencode. Waar
|
|
28
|
+
// wij van hem afwijken hoort dat GEMELD te worden en niet weggepoetst tot het groen is.
|
|
29
|
+
//
|
|
30
|
+
// ethers is optioneel in dit pakket. Zonder dat pakket kan stap 4 niet en komt de
|
|
31
|
+
// uitslag terug als ONGEMETEN, nooit als afgekeurd: een gat in onze meter is geen
|
|
32
|
+
// gebrek van de betaling.
|
|
33
|
+
|
|
34
|
+
import { laadEthers } from './x402-conformance-treden.mjs'
|
|
35
|
+
|
|
36
|
+
/** De helft van de secp256k1-groepsorde. Daarboven is een handtekening kneedbaar. */
|
|
37
|
+
const HALVE_ORDE = 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0n
|
|
38
|
+
|
|
39
|
+
export const REDENEN = [
|
|
40
|
+
'VALID',
|
|
41
|
+
'NETWORK_MISMATCH',
|
|
42
|
+
'SIG_MALLEABLE',
|
|
43
|
+
'SIGNER_MISMATCH',
|
|
44
|
+
'NOT_YET_VALID',
|
|
45
|
+
'EXPIRED',
|
|
46
|
+
'NONCE_REPLAY',
|
|
47
|
+
'AMOUNT_INSUFFICIENT',
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
/** De EIP-712-types van EIP-3009, normatief en niet over de lijn verzonden. */
|
|
51
|
+
export const TRANSFER_TYPES = {
|
|
52
|
+
TransferWithAuthorization: [
|
|
53
|
+
{ name: 'from', type: 'address' },
|
|
54
|
+
{ name: 'to', type: 'address' },
|
|
55
|
+
{ name: 'value', type: 'uint256' },
|
|
56
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
57
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
58
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `eip155:84532` naar 84532. Geeft null bij een vorm die wij niet kennen. */
|
|
63
|
+
export function chainIdUitNetwerk(netwerk) {
|
|
64
|
+
const m = /^eip155:(\d+)$/.exec(String(netwerk ?? ''))
|
|
65
|
+
return m ? Number(m[1]) : null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Het domein waaronder deze autorisatie hoort te zijn ondertekend.
|
|
70
|
+
*
|
|
71
|
+
* Alle drie de replay-vectoren draaien hierom: wie onder een ander domein tekent
|
|
72
|
+
* (andere keten, ander contract, andere naam) levert een handtekening die op een ANDER
|
|
73
|
+
* adres herstelt. Daarom is er geen aparte controle voor cross-chain of cross-contract
|
|
74
|
+
* nodig: het domein doet dat werk, en de uitslag heet dan terecht SIGNER_MISMATCH.
|
|
75
|
+
*/
|
|
76
|
+
export function domeinUit(accepted) {
|
|
77
|
+
return {
|
|
78
|
+
name: accepted?.extra?.name,
|
|
79
|
+
version: accepted?.extra?.version,
|
|
80
|
+
chainId: chainIdUitNetwerk(accepted?.network),
|
|
81
|
+
verifyingContract: accepted?.asset,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Is de s-waarde van deze handtekening boven de halve groepsorde. */
|
|
86
|
+
export function isKneedbaar(signature) {
|
|
87
|
+
const h = String(signature ?? '').replace(/^0x/i, '')
|
|
88
|
+
if (h.length < 130) return false
|
|
89
|
+
const s = BigInt('0x' + h.slice(64, 128))
|
|
90
|
+
return s > HALVE_ORDE
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Beoordeelt een enkele autorisatie.
|
|
95
|
+
*
|
|
96
|
+
* Geeft { valid, reason_code } terug in exact de vorm die kanon se vectoren in hun
|
|
97
|
+
* `expected` gebruiken, plus `ongemeten` met een reden als de controle niet KON
|
|
98
|
+
* draaien. Die derde mogelijkheid staat niet in zijn schema, en dat is precies het
|
|
99
|
+
* punt dat wij in x402#3396 open hebben gelegd: een gat in de verifieerder is iets
|
|
100
|
+
* anders dan een afgekeurde betaling.
|
|
101
|
+
*/
|
|
102
|
+
export async function beoordeelAutorisatie(input, context = {}) {
|
|
103
|
+
const doelNetwerk = context.doelNetwerk ?? input?.accepted?.network
|
|
104
|
+
const accepted = input?.accepted ?? {}
|
|
105
|
+
const auth = input?.payload?.authorization ?? {}
|
|
106
|
+
const signature = input?.payload?.signature
|
|
107
|
+
const nu = Number(context.verification_time ?? Math.floor(Date.now() / 1000))
|
|
108
|
+
|
|
109
|
+
// 1. Netwerk, in platte tekst, voor er iets met de handtekening gebeurt.
|
|
110
|
+
if (String(doelNetwerk) !== String(accepted.network)) {
|
|
111
|
+
return { valid: false, reason_code: 'NETWORK_MISMATCH' }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. Kneedbaarheid, voor het herstellen. Een hoge s is een tweede geldige vorm van
|
|
115
|
+
// dezelfde handtekening, en wie eerst herstelt accepteert die vorm stilletjes.
|
|
116
|
+
if (isKneedbaar(signature)) {
|
|
117
|
+
return { valid: false, reason_code: 'SIG_MALLEABLE' }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. Het geldigheidsvenster.
|
|
121
|
+
if (nu < Number(auth.validAfter)) return { valid: false, reason_code: 'NOT_YET_VALID' }
|
|
122
|
+
if (nu > Number(auth.validBefore)) return { valid: false, reason_code: 'EXPIRED' }
|
|
123
|
+
|
|
124
|
+
// 4. Herstellen en de ondertekenaar vergelijken. Zonder ethers kan dit niet, en dan
|
|
125
|
+
// is de eerlijke uitslag ONGEMETEN.
|
|
126
|
+
const ethers = await laadEthers()
|
|
127
|
+
if (!ethers) {
|
|
128
|
+
return { valid: false, reason_code: null, ongemeten: true, reden: 'ethers ontbreekt, dus de handtekening is niet te herstellen' }
|
|
129
|
+
}
|
|
130
|
+
let hersteld
|
|
131
|
+
try {
|
|
132
|
+
hersteld = ethers.verifyTypedData(domeinUit(accepted), TRANSFER_TYPES, {
|
|
133
|
+
from: auth.from, to: auth.to, value: auth.value,
|
|
134
|
+
validAfter: auth.validAfter, validBefore: auth.validBefore, nonce: auth.nonce,
|
|
135
|
+
}, signature)
|
|
136
|
+
} catch (e) {
|
|
137
|
+
// Een handtekening die niet eens te herstellen is, is geen geldige ondertekenaar.
|
|
138
|
+
return { valid: false, reason_code: 'SIGNER_MISMATCH', detail: String(e?.message ?? e).slice(0, 120) }
|
|
139
|
+
}
|
|
140
|
+
if (String(hersteld).toLowerCase() !== String(auth.from).toLowerCase()) {
|
|
141
|
+
return { valid: false, reason_code: 'SIGNER_MISMATCH', hersteld }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 5. Het bedrag, pas NA de handtekening: een authentiek ondertekende onderbetaling.
|
|
145
|
+
if (BigInt(auth.value ?? 0) < BigInt(accepted.amount ?? 0)) {
|
|
146
|
+
return { valid: false, reason_code: 'AMOUNT_INSUFFICIENT' }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 6. Is deze nonce al verbruikt. Dit is toestand van buiten, geen eigenschap van de
|
|
150
|
+
// autorisatie, en komt daarom uit de context in plaats van uit de betaling.
|
|
151
|
+
const gezien = (context.seen_nonces ?? []).map((n) => String(n).toLowerCase())
|
|
152
|
+
if (gezien.includes(String(auth.nonce).toLowerCase())) {
|
|
153
|
+
return { valid: false, reason_code: 'NONCE_REPLAY' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { valid: true, reason_code: 'VALID', ondertekenaar: hersteld }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Een hele vector beoordelen, met zijn eigen context en doelnetwerk. */
|
|
160
|
+
export async function beoordeelVector(vector) {
|
|
161
|
+
return beoordeelAutorisatie(vector.input, { ...(vector.context ?? {}), doelNetwerk: vector.network })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Een commandoregel, zodat een vreemde de kruiscontrole in EEN regel kan narekenen in
|
|
165
|
+
// plaats van ons op ons woord te geloven. Dat is het hele punt van deze repo.
|
|
166
|
+
if (process.argv[1]?.endsWith('eip3009-autorisatie.mjs')) {
|
|
167
|
+
const fs = await import('node:fs')
|
|
168
|
+
const path = await import('node:path')
|
|
169
|
+
const { fileURLToPath } = await import('node:url')
|
|
170
|
+
const map = path.join(fileURLToPath(new URL('..', import.meta.url)), 'vectors', 'kanon')
|
|
171
|
+
if (!fs.existsSync(map)) {
|
|
172
|
+
console.log('geen vectoren gevonden in ' + map)
|
|
173
|
+
process.exitCode = 2
|
|
174
|
+
} else {
|
|
175
|
+
const bestanden = fs.readdirSync(map).filter((f) => f.endsWith('.json')).sort()
|
|
176
|
+
let eens = 0, oneens = 0, ongemeten = 0
|
|
177
|
+
console.log('Cross-check against iamonuwa/kanon, EIP-3009 payment authorizations')
|
|
178
|
+
console.log('Vectors pinned at commit 0d5a7461e996, Apache-2.0, redistributed unmodified.')
|
|
179
|
+
console.log('This implementation was written against those vectors and against EIP-3009')
|
|
180
|
+
console.log('and EIP-712, never against that project\'s source.')
|
|
181
|
+
console.log('')
|
|
182
|
+
for (const f of bestanden) {
|
|
183
|
+
const v = JSON.parse(fs.readFileSync(path.join(map, f), 'utf8'))
|
|
184
|
+
const u = await beoordeelVector(v)
|
|
185
|
+
if (u.ongemeten) { ongemeten++; console.log(' not measured ' + v.id + ' ' + u.reden); continue }
|
|
186
|
+
const zelfde = u.valid === v.expected.valid && u.reason_code === v.expected.reason_code
|
|
187
|
+
if (zelfde) { eens++; console.log(' agree ' + v.id.padEnd(42) + ' ' + u.reason_code) }
|
|
188
|
+
else { oneens++; console.log(' DISAGREE ' + v.id.padEnd(42) + ' ours ' + u.reason_code + ', theirs ' + v.expected.reason_code) }
|
|
189
|
+
}
|
|
190
|
+
console.log('')
|
|
191
|
+
console.log('agree on ' + eens + ' of ' + bestanden.length + ', disagree ' + oneens + ', not measured ' + ongemeten)
|
|
192
|
+
if (ongemeten) console.log('Install the optional ethers dependency to run the signature recovery.')
|
|
193
|
+
process.exitCode = oneens === 0 ? 0 : 1
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Onafhankelijke verificatie van INAM-tegengetekende bonnetjes.
|
|
2
|
+
//
|
|
3
|
+
// Geschreven tegen hun SPEC.md (§4.2 content-addressing, §4.3 lifecycle, §15 over
|
|
4
|
+
// HTTP) en hun README, NIET tegen hun verify.mjs. Dat is het hele punt: als twee
|
|
5
|
+
// implementaties die elkaars code niet hebben gezien dezelfde uitspraak doen over
|
|
6
|
+
// dezelfde vectoren, is dat interop-bewijs. Eén implementatie die zichzelf
|
|
7
|
+
// bevestigt, bewijst niets.
|
|
8
|
+
//
|
|
9
|
+
// Read-only, geen netwerk, geen sleutels van onszelf.
|
|
10
|
+
|
|
11
|
+
import { createHash, createPublicKey, verify as edVerify } from 'node:crypto'
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
const DIRECT = process.argv[1]?.endsWith('inam-countersigned.mjs')
|
|
15
|
+
const VECTOREN = process.argv[2]
|
|
16
|
+
if (DIRECT && !VECTOREN) { console.error('usage: node src/inam-countersigned.mjs <vectors.json>'); process.exit(2) }
|
|
17
|
+
|
|
18
|
+
// ── bouwstenen ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
// §4.2: "recursive key-sorted, whitespace-free JSON serializer"
|
|
21
|
+
export function canoniek(x) {
|
|
22
|
+
if (x === null || typeof x !== 'object') return JSON.stringify(x)
|
|
23
|
+
if (Array.isArray(x)) return '[' + x.map(canoniek).join(',') + ']'
|
|
24
|
+
return '{' + Object.keys(x).sort().map(k => JSON.stringify(k) + ':' + canoniek(x[k])).join(',') + '}'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sha256hex = s => createHash('sha256').update(s, 'utf8').digest('hex')
|
|
28
|
+
|
|
29
|
+
// base58btc, zelf geschreven zodat we geen code van hen of van een pakket delen
|
|
30
|
+
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
|
|
31
|
+
export function base58decode(s) {
|
|
32
|
+
let getal = 0n
|
|
33
|
+
for (const teken of s) {
|
|
34
|
+
const i = B58.indexOf(teken)
|
|
35
|
+
if (i < 0) throw new Error('geen base58: ' + teken)
|
|
36
|
+
getal = getal * 58n + BigInt(i)
|
|
37
|
+
}
|
|
38
|
+
const bytes = []
|
|
39
|
+
while (getal > 0n) { bytes.unshift(Number(getal & 255n)); getal >>= 8n }
|
|
40
|
+
for (const teken of s) { if (teken === '1') bytes.unshift(0); else break }
|
|
41
|
+
return Buffer.from(bytes)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// did:key met multicodec 0xed01 = Ed25519 publieke sleutel
|
|
45
|
+
export function didNaarSleutel(did) {
|
|
46
|
+
const deel = did.replace(/^did:key:/, '').split('#')[0]
|
|
47
|
+
if (!deel.startsWith('z')) throw new Error('verwacht base58btc (z...)')
|
|
48
|
+
const ruw = base58decode(deel.slice(1))
|
|
49
|
+
if (ruw[0] !== 0xed || ruw[1] !== 0x01) throw new Error('geen ed25519-multicodec')
|
|
50
|
+
const sleutel = ruw.subarray(2)
|
|
51
|
+
if (sleutel.length !== 32) throw new Error('verkeerde sleutellengte ' + sleutel.length)
|
|
52
|
+
// minimale SPKI-DER-omhulling rond een rauwe ed25519-sleutel
|
|
53
|
+
const der = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), sleutel])
|
|
54
|
+
return createPublicKey({ key: der, format: 'der', type: 'spki' })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function ed25519Klopt(did, inhoud, handtekeningB64) {
|
|
58
|
+
try {
|
|
59
|
+
const sig = Buffer.from(handtekeningB64, handtekeningB64.includes('-') || handtekeningB64.includes('_') ? 'base64url' : 'base64')
|
|
60
|
+
return edVerify(null, Buffer.from(inhoud, 'utf8'), didNaarSleutel(did), sig)
|
|
61
|
+
} catch { return false }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let keccak256
|
|
65
|
+
try {
|
|
66
|
+
// Een BARE import, en bewust geen pad op iemands schijf. Dit stond hier als
|
|
67
|
+
// file:///C:/Code/... en dat werkte alleen op de machine van de schrijver: voor
|
|
68
|
+
// iedere vreemde viel controle 1 stil weg en kreeg hij zonder het te weten een
|
|
69
|
+
// zwakkere uitslag dan wij. Dat is het tegenovergestelde van een onafhankelijke
|
|
70
|
+
// verifieerder. ethers staat nu als optionele afhankelijkheid in package.json.
|
|
71
|
+
const ethers = await import('ethers')
|
|
72
|
+
keccak256 = b => ethers.keccak256(b)
|
|
73
|
+
} catch {
|
|
74
|
+
keccak256 = null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── de acht controles, zoals hun README ze beschrijft ────────────────────────
|
|
78
|
+
|
|
79
|
+
export function toetsGeval(geval, vectoren) {
|
|
80
|
+
const mislukt = []
|
|
81
|
+
const bestand = geval.feedbackFile
|
|
82
|
+
const f = JSON.parse(bestand)
|
|
83
|
+
const r = f?.inam?.receipt ?? {}
|
|
84
|
+
const ev = geval.onchainEvent ?? {}
|
|
85
|
+
const obs = vectoren.observed ?? {}
|
|
86
|
+
|
|
87
|
+
// 1. keccak256 van de bestandsbytes is de feedbackHash uit het event
|
|
88
|
+
if (keccak256) {
|
|
89
|
+
const berekend = keccak256(Buffer.from(bestand, 'utf8'))
|
|
90
|
+
if (String(berekend).toLowerCase() !== String(ev.feedbackHash).toLowerCase()) mislukt.push('feedbackHashMatchesFile')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 2. §4.2: receiptId = sha256 over canoniek van zeven velden
|
|
94
|
+
const zeven = {}
|
|
95
|
+
for (const k of ['jobId', 'agentA', 'agentB', 'task', 'result', 'settlement', 'verification']) {
|
|
96
|
+
if (r[k] !== undefined) zeven[k] = r[k]
|
|
97
|
+
}
|
|
98
|
+
if ('sha256:' + sha256hex(canoniek(zeven)) !== r.receiptId) mislukt.push('receiptIdRecomputes')
|
|
99
|
+
|
|
100
|
+
// 3+4. §4.3: beide partijen tekenen DEZELFDE canonieke inhoud, alles behalve
|
|
101
|
+
// signatures, status en dispute
|
|
102
|
+
const teTekenen = { ...r }
|
|
103
|
+
delete teTekenen.signatures; delete teTekenen.status; delete teTekenen.dispute
|
|
104
|
+
const inhoud = canoniek(teTekenen)
|
|
105
|
+
const sigs = r.signatures ?? {}
|
|
106
|
+
const didB = r.agentB?.did ?? r.agentB?.id ?? vectoren.parties?.provider?.did
|
|
107
|
+
const didA = r.agentA?.did ?? r.agentA?.id ?? vectoren.parties?.requester?.did
|
|
108
|
+
const sigB = sigs.agentB?.signature ?? sigs.agentB ?? sigs.worker
|
|
109
|
+
const sigA = sigs.agentA?.signature ?? sigs.agentA ?? sigs.requester
|
|
110
|
+
if (!sigB || !ed25519Klopt(didB, inhoud, sigB)) mislukt.push('workerSignature')
|
|
111
|
+
if (!sigA || !ed25519Klopt(didA, inhoud, sigA)) mislukt.push('requesterSignature')
|
|
112
|
+
|
|
113
|
+
// 5. waarde hoort bij de uitkomst: 100 / 50 / 0
|
|
114
|
+
const uitkomst = r.result?.outcome ?? r.result?.status
|
|
115
|
+
const hoort = { success: 100, partial: 50, failed: 0 }[uitkomst]
|
|
116
|
+
const waarde = ev.value ?? f.value
|
|
117
|
+
if (hoort !== undefined && Number(waarde) !== hoort) mislukt.push('valueMatchesOutcome')
|
|
118
|
+
|
|
119
|
+
// 6. het bestand noemt het adres dat het event verstuurde
|
|
120
|
+
const inBestand = String(f.clientAddress ?? '').split(':').pop().toLowerCase()
|
|
121
|
+
if (inBestand !== String(ev.clientAddress ?? '').toLowerCase()) mislukt.push('fileClientAddressMatchesSender')
|
|
122
|
+
|
|
123
|
+
// 7. dat adres is de wallet waarvan de requester controle bewees
|
|
124
|
+
const bewezen = String(vectoren.parties?.requester?.erc8004Address ?? '').toLowerCase()
|
|
125
|
+
if (String(ev.clientAddress ?? '').toLowerCase() !== bewezen) mislukt.push('senderIsRequesterWallet')
|
|
126
|
+
|
|
127
|
+
// 8+9. §15: hashes over de bytes die daadwerkelijk over de lijn gingen
|
|
128
|
+
const u = new URL(obs.request?.url ?? 'https://x/')
|
|
129
|
+
const verzoekBytes = `${obs.request?.method} ${u.pathname}${u.search}\n${obs.request?.body ?? ''}`
|
|
130
|
+
if ('sha256:' + sha256hex(verzoekBytes) !== r.task?.specHash) mislukt.push('specHashMatchesObservedBytes')
|
|
131
|
+
if ('sha256:' + sha256hex(obs.response?.body ?? '') !== r.result?.outputHash) mislukt.push('outputHashMatchesObservedBytes')
|
|
132
|
+
|
|
133
|
+
return { accepted: mislukt.length === 0, failedChecks: mislukt }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── draaien en vergelijken met wat de vectoren zelf verwachten ───────────────
|
|
137
|
+
//
|
|
138
|
+
// Alleen bij directe aanroep. Importeren levert de functies, niets anders.
|
|
139
|
+
if (DIRECT) {
|
|
140
|
+
|
|
141
|
+
const v = JSON.parse(readFileSync(VECTOREN, 'utf8'))
|
|
142
|
+
console.log(`suite: ${v.suite}`)
|
|
143
|
+
console.log(`keccak256 beschikbaar: ${keccak256 ? 'ja' : 'NEE (controle 1 overgeslagen)'}\n`)
|
|
144
|
+
|
|
145
|
+
let gelijk = 0, afwijkend = 0
|
|
146
|
+
for (const geval of v.cases) {
|
|
147
|
+
const onze = toetsGeval(geval, v)
|
|
148
|
+
const hunne = geval.expected
|
|
149
|
+
const zelfdeOordeel = onze.accepted === hunne.accepted
|
|
150
|
+
const onzeSet = [...onze.failedChecks].sort().join(',')
|
|
151
|
+
const hunSet = [...(hunne.failedChecks ?? [])].sort().join(',')
|
|
152
|
+
const zelfdeChecks = onzeSet === hunSet
|
|
153
|
+
const merk = zelfdeOordeel && zelfdeChecks ? 'GELIJK ' : (zelfdeOordeel ? 'deels ' : 'AFWIJKEND')
|
|
154
|
+
if (zelfdeOordeel && zelfdeChecks) gelijk++; else afwijkend++
|
|
155
|
+
console.log(`${merk} ${geval.id}`)
|
|
156
|
+
console.log(` zij : accepted=${hunne.accepted} [${hunSet || '-'}]`)
|
|
157
|
+
console.log(` wij : accepted=${onze.accepted} [${onzeSet || '-'}]`)
|
|
158
|
+
}
|
|
159
|
+
console.log(`\n${gelijk} van ${v.cases.length} volledig gelijk, ${afwijkend} afwijkend`)
|
|
160
|
+
process.exit(afwijkend === 0 ? 0 : 1)
|
|
161
|
+
|
|
162
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Een MCP-server, zodat een agent deze controle AANROEPT in plaats van hem te moeten
|
|
2
|
+
// aanbevelen.
|
|
3
|
+
//
|
|
4
|
+
// WAAROM DIT BESTAAT, en het is gemeten en niet bedacht. Op 2026-10-17 classificeerde
|
|
5
|
+
// de gratis laag 32 marktsignalen over agent-betalingen: 14 benoemen een concreet
|
|
6
|
+
// probleem en alle 14 staan aan de KOPERSKANT. Niet een enkel verkopersprobleem. De
|
|
7
|
+
// titels zeggen het zelf: "5 Ways Your Paid Endpoint Gets Drained", "your agent still
|
|
8
|
+
// needs a bill", en "Your AI Agent Paid a Supplier Twice. Can You Prove What It Did?"
|
|
9
|
+
// In dezelfde ronde bleken er in de MCP-directories al zeven partijen DATA te
|
|
10
|
+
// verkopen over x402 en geen enkele die de betaallaag zelf controleert.
|
|
11
|
+
//
|
|
12
|
+
// Dat is de hele reden voor deze twee gereedschappen: het voor en het na van die ene
|
|
13
|
+
// pijn. Voor je betaalt, klopt dit eindpunt. Na je betaling, hoort dit bonnetje bij
|
|
14
|
+
// precies een echte betaling.
|
|
15
|
+
//
|
|
16
|
+
// GEEN AFHANKELIJKHEDEN, EN DAT IS EEN KEUZE EN GEEN LUIHEID. Dit protocol is
|
|
17
|
+
// newline-gescheiden JSON-RPC over stdio, dat is een paar regels. De officiele
|
|
18
|
+
// bibliotheek erbij halen zou betekenen dat een verifieerder wiens hele waarde
|
|
19
|
+
// onafhankelijkheid is, een boom afhankelijkheden meeneemt om te zeggen of een
|
|
20
|
+
// handtekening klopt. De vormen hieronder zijn overgenomen uit de officiele sdk die
|
|
21
|
+
// in ons andere project staat, niet uit het hoofd: protocolversie, de tools-vorm en
|
|
22
|
+
// de content-vorm komen daar letterlijk uit.
|
|
23
|
+
//
|
|
24
|
+
// DE VALKUIL DIE EEN MCP-SERVER STIL KAPOT MAAKT: alles wat naar stdout gaat en geen
|
|
25
|
+
// JSON-RPC-frame is, breekt de verbinding. Daarom gaat ELKE melding naar stderr en
|
|
26
|
+
// schrijft alleen `stuur()` naar stdout.
|
|
27
|
+
//
|
|
28
|
+
// GEBRUIK (in een MCP-client):
|
|
29
|
+
// npx github:holistis/agent-receipt-verify mcp
|
|
30
|
+
//
|
|
31
|
+
// WAT DIT NIET DOET: het oordeelt over de VORM van een eindpunt en een bonnetje, niet
|
|
32
|
+
// over de eerlijkheid van de partij erachter. Een eindpunt dat alles goed doet kan je
|
|
33
|
+
// alsnog rommel verkopen. Zie CONFORMANCE.md voor wat met opzet niet gemeten wordt.
|
|
34
|
+
|
|
35
|
+
import { meetEindpunt, hoogsteTrede } from './x402-conformance-check.mjs'
|
|
36
|
+
import { TREDEN, PROFIEL } from './x402-conformance-treden.mjs'
|
|
37
|
+
import { maakRpc, linkBonnetje } from './x402-link-onchain.mjs'
|
|
38
|
+
|
|
39
|
+
const PROTOCOL = '2025-11-25'
|
|
40
|
+
const ONDERSTEUND = [PROTOCOL, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']
|
|
41
|
+
|
|
42
|
+
const NAAM = 'agent-receipt-verify'
|
|
43
|
+
const VERSIE = '0.2.0'
|
|
44
|
+
|
|
45
|
+
// Een publieke rpc per netwerk, zodat de koper niet eerst een sleutel hoeft te regelen
|
|
46
|
+
// om zijn eigen bonnetje te kunnen natrekken. Een netwerk dat hier niet staat wordt
|
|
47
|
+
// EERLIJK als niet-ondersteund gemeld en nooit stil op een ander netwerk gezocht.
|
|
48
|
+
const RPC_PER_NETWERK = {
|
|
49
|
+
'eip155:84532': 'https://sepolia.base.org',
|
|
50
|
+
'eip155:8453': 'https://mainnet.base.org',
|
|
51
|
+
'base-sepolia': 'https://sepolia.base.org',
|
|
52
|
+
base: 'https://mainnet.base.org',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const log = (...a) => process.stderr.write('[' + NAAM + '] ' + a.join(' ') + '\n')
|
|
56
|
+
|
|
57
|
+
const GEREEDSCHAP = [
|
|
58
|
+
{
|
|
59
|
+
name: 'check_x402_endpoint',
|
|
60
|
+
description:
|
|
61
|
+
'Check a paid x402 endpoint BEFORE paying it. Sends exactly one request, never pays, and reports which levels of ' +
|
|
62
|
+
'the published conformance ladder it reaches: does it ask for payment the way x402 says, does it declare the ' +
|
|
63
|
+
'offer-receipt extension, and does the signature on its offer actually verify. Levels that need a real settlement ' +
|
|
64
|
+
'are reported as not measured, never as failed.',
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
url: { type: 'string', description: 'The paid endpoint to check, for example https://api.example.com/paid/thing' },
|
|
69
|
+
method: { type: 'string', description: 'HTTP method the endpoint expects. Defaults to POST. Use GET for a GET-only route.' },
|
|
70
|
+
},
|
|
71
|
+
required: ['url'],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'link_receipt_to_settlement',
|
|
76
|
+
description:
|
|
77
|
+
'Check AFTER paying that a version 2 offer-receipt belongs to exactly one real on-chain settlement. Takes the ' +
|
|
78
|
+
'signed receipt payload plus the authorization salt the seller returned outside it, and resolves the salted ' +
|
|
79
|
+
'commitment against live chain state. Answers linked, not linked, or could-not-check, and never guesses between them.',
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
payload: { type: 'object', description: 'The signed receipt payload, the object with version, network, payer and authorizationCommitment' },
|
|
84
|
+
salt: { type: 'string', description: 'The authorizationSalt from extensions["offer-receipt"].info, which is deliberately not inside the signed payload' },
|
|
85
|
+
asset: { type: 'string', description: 'The token contract from the accepts entry that was paid, for example the USDC address' },
|
|
86
|
+
rpcUrl: { type: 'string', description: 'Optional. An RPC endpoint to use instead of the public default for that network.' },
|
|
87
|
+
},
|
|
88
|
+
required: ['payload', 'salt', 'asset'],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
function tekst(s, fout = false) {
|
|
94
|
+
return { content: [{ type: 'text', text: s }], ...(fout ? { isError: true } : {}) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function doeCheckEndpoint(args) {
|
|
98
|
+
const url = typeof args?.url === 'string' ? args.url.trim() : ''
|
|
99
|
+
if (!/^https?:\/\//.test(url)) return tekst('url must be an absolute http or https address', true)
|
|
100
|
+
const methode = typeof args?.method === 'string' && /^(GET|POST|PUT|PATCH|DELETE)$/i.test(args.method)
|
|
101
|
+
? args.method.toUpperCase()
|
|
102
|
+
: 'POST'
|
|
103
|
+
|
|
104
|
+
const m = await meetEindpunt(url, { methode })
|
|
105
|
+
const hoogste = hoogsteTrede(m)
|
|
106
|
+
const regels = [
|
|
107
|
+
(hoogste >= 0 ? 'Reaches level ' + hoogste : 'Reaches no level') + ' of the conformance ladder.',
|
|
108
|
+
'Checked with a single ' + methode + ' request. Nothing was paid.',
|
|
109
|
+
'',
|
|
110
|
+
]
|
|
111
|
+
for (const t of TREDEN) {
|
|
112
|
+
const staat = m.treden[t.sleutel]
|
|
113
|
+
if (!staat) continue
|
|
114
|
+
const merk = staat === 'HAALT' ? 'reached ' : staat === 'HAALT_NIET' ? 'NOT reached' : 'not measured'
|
|
115
|
+
regels.push('L' + t.nummer + ' ' + merk + ' ' + t.naam)
|
|
116
|
+
if (m.bewijs[t.sleutel]) regels.push(' ' + m.bewijs[t.sleutel])
|
|
117
|
+
}
|
|
118
|
+
if (m.bewijs.t2afwijking) regels.push('', 'note on the key: ' + m.bewijs.t2afwijking)
|
|
119
|
+
// De uitslag draagt nu ook WAAR de ladder vandaan komt. Een cijfer zonder
|
|
120
|
+
// herkomst is een mening, en een trede die tegen een nog niet samengevoegd
|
|
121
|
+
// voorstel meet, hoort dat zelf te zeggen in plaats van het aan de lezer te laten.
|
|
122
|
+
const voorstelTreden = TREDEN.filter((t) => t.citeertVoorstel).map((t) => 'L' + t.nummer)
|
|
123
|
+
regels.push(
|
|
124
|
+
'',
|
|
125
|
+
'Ladder: profile ' + PROFIEL.profile + ' v' + PROFIEL.version + ' of ' + PROFIEL.published +
|
|
126
|
+
', which names the clause behind every level: profiles/x402-deployed-ladder-v1.json',
|
|
127
|
+
)
|
|
128
|
+
if (voorstelTreden.length) {
|
|
129
|
+
regels.push(
|
|
130
|
+
voorstelTreden.join(' and ') + ' are measured against sections 5.6 and later of the offer-receipt ' +
|
|
131
|
+
'extension, which exist only in pull request 3827 and are not merged. Their numbering can still move.',
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
regels.push(
|
|
135
|
+
'Levels 3 to 5 cannot be observed without paying, so they are reported as not measured rather than absent.',
|
|
136
|
+
'The ladder and what it deliberately does not measure: https://github.com/holistis/agent-receipt-verify/blob/main/CONFORMANCE.md',
|
|
137
|
+
'This judges the form of an endpoint, not the honesty of the party behind it.',
|
|
138
|
+
)
|
|
139
|
+
return tekst(regels.join('\n'))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function doeLinkReceipt(args) {
|
|
143
|
+
const payload = args?.payload
|
|
144
|
+
const salt = typeof args?.salt === 'string' ? args.salt.trim() : ''
|
|
145
|
+
const asset = typeof args?.asset === 'string' ? args.asset.trim() : ''
|
|
146
|
+
if (!payload || typeof payload !== 'object') return tekst('payload must be the signed receipt object', true)
|
|
147
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(salt)) return tekst('salt must be a 32 byte hex string starting with 0x', true)
|
|
148
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(asset)) return tekst('asset must be a 20 byte hex address starting with 0x', true)
|
|
149
|
+
|
|
150
|
+
const netwerk = String(payload.network ?? '')
|
|
151
|
+
const rpcUrl = typeof args?.rpcUrl === 'string' && args.rpcUrl ? args.rpcUrl : RPC_PER_NETWERK[netwerk]
|
|
152
|
+
if (!rpcUrl) {
|
|
153
|
+
return tekst(
|
|
154
|
+
'could not check: no public RPC is configured for network "' + netwerk + '".\n' +
|
|
155
|
+
'Pass rpcUrl explicitly. This is reported as could-not-check, not as not-linked, because those are different answers.',
|
|
156
|
+
true,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const uit = await linkBonnetje(maakRpc(rpcUrl), { payload, salt, asset })
|
|
162
|
+
const regels = []
|
|
163
|
+
if (uit.linked) {
|
|
164
|
+
regels.push('LINKED. This receipt opens to exactly one on-chain authorization.')
|
|
165
|
+
regels.push('nonce ' + uit.identifier)
|
|
166
|
+
if (uit.afrekening?.tx) regels.push('transaction ' + uit.afrekening.tx)
|
|
167
|
+
if (uit.afrekening?.blok) regels.push('block ' + uit.afrekening.blok)
|
|
168
|
+
} else {
|
|
169
|
+
// Niet-gelinkt heeft bij deze controle altijd een REDEN mee, en die reden IS
|
|
170
|
+
// het antwoord. Nul kandidaten en twee kandidaten zijn allebei niet-gelinkt en
|
|
171
|
+
// bewust niet waarschijnlijk-gelinkt: fail-closed, zie 5.6.4 stap 3.
|
|
172
|
+
regels.push('NOT LINKED. ' + (uit.reden ?? 'the commitment did not open to exactly one authorization'))
|
|
173
|
+
regels.push('Two candidates opening the same commitment reads as not linked on purpose, not as probably linked.')
|
|
174
|
+
regels.push('A receipt signed before its settlement was mined can also read this way; retry later or widen the window.')
|
|
175
|
+
}
|
|
176
|
+
if (uit.venster) {
|
|
177
|
+
regels.push(
|
|
178
|
+
'',
|
|
179
|
+
'searched blocks ' + uit.venster.vanBlok + ' to ' + uit.venster.totBlok +
|
|
180
|
+
' (' + uit.venster.kandidaten + ' candidate authorizations, ' + uit.venster.aanroepen + ' rpc calls)',
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
regels.push('checked against ' + rpcUrl + ' for network ' + netwerk + '.')
|
|
184
|
+
return tekst(regels.join('\n'))
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return tekst('could not check: ' + String(e.message).slice(0, 180) + '\nThis is a failure of the check, not a verdict on the receipt.', true)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function behandel(bericht) {
|
|
191
|
+
const { id, method, params } = bericht
|
|
192
|
+
|
|
193
|
+
if (method === 'initialize') {
|
|
194
|
+
const gevraagd = params?.protocolVersion
|
|
195
|
+
return {
|
|
196
|
+
protocolVersion: ONDERSTEUND.includes(gevraagd) ? gevraagd : PROTOCOL,
|
|
197
|
+
capabilities: { tools: {} },
|
|
198
|
+
serverInfo: { name: NAAM, version: VERSIE },
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (method === 'ping') return {}
|
|
202
|
+
if (method === 'tools/list') return { tools: GEREEDSCHAP }
|
|
203
|
+
if (method === 'tools/call') {
|
|
204
|
+
const naam = params?.name
|
|
205
|
+
const args = params?.arguments ?? {}
|
|
206
|
+
if (naam === 'check_x402_endpoint') return await doeCheckEndpoint(args)
|
|
207
|
+
if (naam === 'link_receipt_to_settlement') return await doeLinkReceipt(args)
|
|
208
|
+
// Een onbekend gereedschap is een fout van de aanroeper en hoort als JSON-RPC-fout
|
|
209
|
+
// terug, niet als een tekst die op een uitslag lijkt.
|
|
210
|
+
const fout = new Error('unknown tool: ' + String(naam))
|
|
211
|
+
fout.code = -32602
|
|
212
|
+
throw fout
|
|
213
|
+
}
|
|
214
|
+
const fout = new Error('method not found: ' + String(method))
|
|
215
|
+
fout.code = -32601
|
|
216
|
+
throw fout
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function stuur(obj) {
|
|
220
|
+
process.stdout.write(JSON.stringify(obj) + '\n')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function maakVerwerker() {
|
|
224
|
+
let buffer = ''
|
|
225
|
+
return async function verwerk(brok) {
|
|
226
|
+
buffer += brok
|
|
227
|
+
let grens
|
|
228
|
+
while ((grens = buffer.indexOf('\n')) >= 0) {
|
|
229
|
+
const regel = buffer.slice(0, grens).trim()
|
|
230
|
+
buffer = buffer.slice(grens + 1)
|
|
231
|
+
if (!regel) continue
|
|
232
|
+
let bericht
|
|
233
|
+
try {
|
|
234
|
+
bericht = JSON.parse(regel)
|
|
235
|
+
} catch {
|
|
236
|
+
stuur({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } })
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
// Een notificatie heeft geen id en krijgt per specificatie GEEN antwoord. Wie
|
|
240
|
+
// daar toch op antwoordt, laat sommige clients vastlopen.
|
|
241
|
+
if (bericht.id === undefined || bericht.id === null) {
|
|
242
|
+
if (bericht.method === 'notifications/initialized') log('client is ready')
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
stuur({ jsonrpc: '2.0', id: bericht.id, result: await behandel(bericht) })
|
|
247
|
+
} catch (e) {
|
|
248
|
+
stuur({ jsonrpc: '2.0', id: bericht.id, error: { code: e.code ?? -32603, message: String(e.message).slice(0, 200) } })
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (process.argv[1]?.endsWith('mcp-server.mjs')) {
|
|
255
|
+
log('listening on stdio, protocol ' + PROTOCOL + ', ' + GEREEDSCHAP.length + ' tools')
|
|
256
|
+
const verwerk = maakVerwerker()
|
|
257
|
+
process.stdin.setEncoding('utf8')
|
|
258
|
+
process.stdin.on('data', (b) => {
|
|
259
|
+
verwerk(b).catch((e) => log('handler error: ' + e.message))
|
|
260
|
+
})
|
|
261
|
+
process.stdin.on('end', () => process.exit(0))
|
|
262
|
+
}
|