Changeset 691311f in Klonkt


Ignore:
Timestamp:
08/06/2026 10:40:40 AM (5 weeks ago)
Author:
Claude (agent) <aiclaude@…>
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)
Message:

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@…>

Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r57aee3b r691311f  
    12841284}
    12851285
    1286 export async function fetchActor(url) {
     1286export 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  }
    12871303  try {
    12881304    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
     
    13681384}
    13691385
     1386/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
     1387 *  (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
     1388let _signSlug;
     1389function 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
    13701396// Best-effort verification of an incoming signed request. Returns the sender's
    13711397// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
     
    13731399// federating servers with drifting clocks; an operator can widen it via env.
    13741400const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
    1375 export async function verifyRequest(req) {
     1401export async function verifyRequest(req, asSlug = null) {
    13761402  const sigH = req.headers['signature'];
    13771403  if (!sigH) return null;
    13781404  const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
    13791405  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() });
    13811411  const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
    13821412  if (!pem) return null;
     
    16781708  // keeps everything below identical, including the actor-versus-signer check,
    16791709  // 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);
    16811711
    16821712  // 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
    212import { test } from 'node:test';
    313import assert from 'node:assert/strict';
    414
    515process.env.DATABASE_PATH = ':memory:';
    6 process.env.PUBLIC_BASE_URL = 'https://test.example';
     16process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
    717
    818const dbMod = await import('../src/config/database.js');
    919const db = dbMod.default;
    1020dbMod.initializeDatabase();
    11 const AP = (await import('../src/services/ActivityPubService.js')).default;
     21const AP = await import('../src/services/ActivityPubService.js');
    1222
    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);
     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');
    1826
    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.
     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');
    2354});
     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 TracChangeset for help on using the changeset viewer.