Changeset 691311f in Klonkt for src


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

File:
1 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
Note: See TracChangeset for help on using the changeset viewer.