source: Klonkt/test/authorized-fetch.test.js@ 691311f

main
Last change on this file since 691311f was 691311f, checked in by Claude (agent) <aiclaude@…>, 5 weeks ago

Sleutel-ophaal ondertekenen: volgers achter authorized fetch kunnen weer volgen

Op boiert.eu bleven vier accounts eindeloos in het log hangen met een geweigerde
Follow, terwijl tientallen andere gewoon lukten. Oorzaak: hun instances draaien
Mastodons secure mode en serveren hun actor-document -- en dus hun publieke
sleutel -- alleen aan een ONDERTEKEND verzoek. fetchActor deed een onbetekende
GET, kreeg 401, vond geen sleutel, en dus kon verifyRequest een correct
ondertekende Follow niet verifiëren. Mastodon probeert het daarna dagenlang
opnieuw, en daarom domineerden juist die vier het log.

De correlatie is volledig: infosec.exchange, masto.hackers.town, toot.cat en
mastodon.africa geven 401 op een onbetekende actor-GET; mstdn.social en
mastodon.xyz geven 200. Precies de eerste vier werden geweigerd.

fetchActor accepteert nu een ondertekenaar en gebruikt signedGetJson, dat al
bestond. verifyRequest geeft de ontvangende site mee; voor de gedeelde inbox een
willekeurige lokale site, want elke Klonkt-actor is een geldige ondertekenaar --
het gaat de andere kant erom DAT er ondertekend is.

Terugval op de onbetekende GET blijft: niet elke 401 komt van secure mode, en een
instance die geen handtekening verwacht moet blijven werken. Een test dwingt dat
af door de ondertekende poging te laten falen.

Geen kip-ei-probleem: om onze handtekening te controleren haalt de andere kant
ons actor-document op, en dat serveert Klonkt publiek.

Het gevolg reikt verder dan Follow. Elke ophaalactie bij zo'n instance faalde --
profielen, notes, reacties -- dus een deel van de geweigerde Creates uit
shaer-s8k kan dezelfde oorzaak hebben gehad.

Suite 453/453.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 4.2 KB
Line 
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
12import { test } from 'node:test';
13import assert from 'node:assert/strict';
14
15process.env.DATABASE_PATH = ':memory:';
16process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
17
18const dbMod = await import('../src/config/database.js');
19const db = dbMod.default;
20dbMod.initializeDatabase();
21const AP = await import('../src/services/ActivityPubService.js');
22
23db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
24 .run('u1', 'u1', 'u1@test', 'x', 'god');
25db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
26
27// IP-literals: safeFetch slaat de DNS-lookup over, dus de test blijft offline.
28const OPEN_ACTOR = 'https://203.0.113.30/users/open'; // gewone instance
29const SECURE_ACTOR = 'https://203.0.113.40/users/gesloten'; // authorized fetch
30
31let verzoeken = [];
32const echteFetch = globalThis.fetch;
33globalThis.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
48test('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');
54});
55
56test('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
63test('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
68test('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
85test('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
89test.after(() => { globalThis.fetch = echteFetch; });
Note: See TracBrowser for help on using the repository browser.