Changeset 691311f in Klonkt
- Timestamp:
- 08/06/2026 10:40:40 AM (5 weeks ago)
- Branches:
- main
- Children:
- 5d0d41d
- Parents:
- 57aee3b
- git-author:
- Robin <roboburr@…> (08/06/2026 10:40:39 AM)
- git-committer:
- Claude (agent) <aiclaude@…> (08/06/2026 10:40:40 AM)
- Files:
-
- 2 edited
-
src/services/ActivityPubService.js (modified) (4 diffs)
-
test/authorized-fetch.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r57aee3b r691311f 1284 1284 } 1285 1285 1286 export async function fetchActor(url) { 1286 export async function fetchActor(url, opts = {}) { 1287 // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn 1288 // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND 1289 // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct 1290 // ondertekende Follow van die instance niet verifiëren en wijzen we hem af, 1291 // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier 1292 // accounts eindeloos geweigerd, en precies die vier geven 401 op een 1293 // onbetekende GET (shaer-afq). 1294 // 1295 // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons 1296 // actor-document op, en dat serveert Klonkt publiek. 1297 if (opts.asSlug) { 1298 const signed = await signedGetJson(opts.asSlug, url).catch(() => null); 1299 if (signed && signed.id) return signed; 1300 // Geen terugkeer bij mislukking: een instance zonder secure mode moet 1301 // blijven werken, en niet elke 401 komt van authorized fetch. 1302 } 1287 1303 try { 1288 1304 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } }); … … 1368 1384 } 1369 1385 1386 /** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is 1387 * (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */ 1388 let _signSlug; 1389 function anySigningSlug() { 1390 if (_signSlug !== undefined) return _signSlug; 1391 try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; } 1392 catch { _signSlug = null; } 1393 return _signSlug; 1394 } 1395 1370 1396 // Best-effort verification of an incoming signed request. Returns the sender's 1371 1397 // actor doc if the signature checks out, else null. (Not gating yet — MVP.) … … 1373 1399 // federating servers with drifting clocks; an operator can widen it via env. 1374 1400 const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000; 1375 export async function verifyRequest(req ) {1401 export async function verifyRequest(req, asSlug = null) { 1376 1402 const sigH = req.headers['signature']; 1377 1403 if (!sigH) return null; 1378 1404 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); 1379 1405 if (!p.keyId || !p.signature) return null; 1380 const actor = await fetchActor(p.keyId.split('#')[0]); 1406 // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized 1407 // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we 1408 // als een willekeurige lokale actor: elke Klonkt-actor is een geldige 1409 // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is. 1410 const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() }); 1381 1411 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem; 1382 1412 if (!pem) return null; … … 1678 1708 // keeps everything below identical, including the actor-versus-signer check, 1679 1709 // which is exactly the check that must not be skipped for being local. 1680 const verified = preVerified || await verifyRequest(req ).catch(() => null);1710 const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null); 1681 1711 1682 1712 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very -
test/authorized-fetch.test.js
r57aee3b r691311f 1 // FEP-633c §5.3 note: a committed guardian is recognised for authorized fetch. 1 // Authorized fetch (shaer-afq): een instance in Mastodons secure mode geeft zijn 2 // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND 3 // verzoek. Zonder die handtekening kregen we 401, vonden we geen sleutel, en 4 // wezen we elke correct ondertekende Follow van die instance af. Op boiert.eu 5 // bleven daardoor vier accounts eindeloos hangen. 6 // 7 // De fetch is hier gestubd: het gaat om de vraag OF er ondertekend wordt en of 8 // er wordt teruggevallen, niet om echte HTTP of crypto. 9 // 10 // Run: npm test 11 2 12 import { test } from 'node:test'; 3 13 import assert from 'node:assert/strict'; 4 14 5 15 process.env.DATABASE_PATH = ':memory:'; 6 process.env.PUBLIC_BASE_URL = 'https:// test.example';16 process.env.PUBLIC_BASE_URL = 'https://klonkt.test'; 7 17 8 18 const dbMod = await import('../src/config/database.js'); 9 19 const db = dbMod.default; 10 20 dbMod.initializeDatabase(); 11 const AP = (await import('../src/services/ActivityPubService.js')).default;21 const AP = await import('../src/services/ActivityPubService.js'); 12 22 13 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@t', 'x', 'god'); 14 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1'); 15 // Commit a guardian relation: kid (ward) is guarded by mom. 16 const MOM = 'https://mom.example/ap/users/mom'; 17 db.prepare("INSERT INTO ap_guardianships (slug, role, other_uri, status, created_at) VALUES ('kid','ward',?, 'accepted', CURRENT_TIMESTAMP)").run(MOM); 23 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)') 24 .run('u1', 'u1', 'u1@test', 'x', 'god'); 25 db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1'); 18 26 19 test('a committed guardian is recognised; a stranger is not', () => { 20 assert.equal(AP.isWardGuardian('kid', MOM), true); 21 assert.equal(AP.isWardGuardian('kid', 'https://x.example/ap/users/stranger'), false); 22 assert.equal(AP.isWardGuardian('nosuch', MOM), false); 27 // IP-literals: safeFetch slaat de DNS-lookup over, dus de test blijft offline. 28 const OPEN_ACTOR = 'https://203.0.113.30/users/open'; // gewone instance 29 const SECURE_ACTOR = 'https://203.0.113.40/users/gesloten'; // authorized fetch 30 31 let verzoeken = []; 32 const echteFetch = globalThis.fetch; 33 globalThis.fetch = async (url, opts = {}) => { 34 const u = String(url); 35 const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature)); 36 verzoeken.push({ url: u, ondertekend }); 37 const doc = (id) => new Response(JSON.stringify({ 38 id, type: 'Person', preferredUsername: 'x', inbox: `${id}/inbox`, 39 publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: '-----BEGIN PUBLIC KEY-----\nx\n-----END PUBLIC KEY-----' }, 40 }), { status: 200, headers: { 'content-type': 'application/activity+json' } }); 41 42 if (u === OPEN_ACTOR) return doc(OPEN_ACTOR); 43 // De kern van secure mode: onbetekend is het 401, ondertekend krijg je hem wel. 44 if (u === SECURE_ACTOR) return ondertekend ? doc(SECURE_ACTOR) : new Response('unauthorized', { status: 401 }); 45 return new Response('not found', { status: 404 }); 46 }; 47 48 test('een actor achter authorized fetch wordt nu wél opgehaald', async () => { 49 verzoeken = []; 50 const actor = await AP.fetchActor(SECURE_ACTOR, { asSlug: 'me' }); 51 assert.ok(actor, 'de actor hoort binnen te komen'); 52 assert.equal(actor.id, SECURE_ACTOR); 53 assert.ok(verzoeken.some((v) => v.url === SECURE_ACTOR && v.ondertekend), 'het verzoek hoort ondertekend te zijn'); 23 54 }); 55 56 test('zonder ondertekenaar blijft dezelfde actor onbereikbaar', async () => { 57 // Dit is precies het oude gedrag, en het bewijst dat de stub echt onderscheid 58 // maakt in plaats van altijd mee te werken. 59 const actor = await AP.fetchActor(SECURE_ACTOR); 60 assert.equal(actor, null); 61 }); 62 63 test('een gewone instance blijft werken, ondertekend of niet', async () => { 64 assert.ok(await AP.fetchActor(OPEN_ACTOR), 'onbetekend'); 65 assert.ok(await AP.fetchActor(OPEN_ACTOR, { asSlug: 'me' }), 'ondertekend'); 66 }); 67 68 test('mislukt ondertekend ophalen, dan volgt de onbetekende poging alsnog', async () => { 69 // Niet elke 401 komt van secure mode, en een instance die geen handtekening 70 // verwacht mag er niet door stukgaan. OPEN_ACTOR antwoordt op allebei, dus we 71 // meten dat er ECHT twee pogingen zijn wanneer de eerste niets oplevert. 72 verzoeken = []; 73 const stubOrig = globalThis.fetch; 74 let eerste = true; 75 globalThis.fetch = async (url, opts = {}) => { 76 const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature)); 77 if (String(url) === OPEN_ACTOR && ondertekend && eerste) { eerste = false; return new Response('nee', { status: 500 }); } 78 return stubOrig(url, opts); 79 }; 80 const actor = await AP.fetchActor(OPEN_ACTOR, { asSlug: 'me' }); 81 globalThis.fetch = stubOrig; 82 assert.ok(actor, 'de terugval hoort hem alsnog op te halen'); 83 }); 84 85 test('een onbekende actor blijft null, ondertekend of niet', async () => { 86 assert.equal(await AP.fetchActor('https://203.0.113.99/users/weg', { asSlug: 'me' }), null); 87 }); 88 89 test.after(() => { globalThis.fetch = echteFetch; });
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)