source: Klonkt/src/services/ap-transport.js@ bfa3ba3

main
Last change on this file since bfa3ba3 was bfa3ba3, checked in by Robin <roboburr@…>, 25 hours ago

Ook de directe verzenders langs de volgorde per inbox

Rest van shaer-xzab.3. Drie plekken verstuurden zelf met deliver() en zetten
pas bij een mislukking in de rij: een antwoord (deliverReply), het wissen
daarvan (deliverOutboxDelete) en een direct bericht (deliverDirectNote, ook de
hulpvraag en de zwaai). Zo kon de Delete van een antwoord aankomen vóór het
antwoord zelf als dat nog in de rij wachtte, en stond het antwoord daarna weer
op die server.

Nu één weg: deliverNowOrQueue in ap-transport, met dezelfde regel als
deliverWithRetry (wacht er voor die inbox al iets, of zit de server in zijn
backoff, dan achteraan), en hij zegt of het 'sent' of 'queued' werd, want
deliverReply en deliverDirectNote tellen wat er meteen aankwam.
deliverWithRetry is er nu een dunne laag over. guardianship/delivery.js krijgt
deliverNowOrQueue via wireDelivery in plaats van deliver en enqueueDelivery.

test/direct-sends-queue.test.js: vijf toetsen tegen een echte HTTP-server,
waaronder het hele scenario: antwoord lukt niet en wacht, de server is terug,
het antwoord wordt gewist -- de Delete wacht achter de Create en ze komen in
die volgorde aan. Vier mutaties apart gedraaid (elke plek terug naar zelf
versturen, en 'sent' dat niet telt), elk gevangen. Volledige suite 1376 groen.

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

  • Property mode set to 100644
File size: 26.3 KB
Line 
1/**
2 * ap-transport.js — het transport onder de federatie (stap 3 van shaer-drc).
3 *
4 * Alles wat hier woont raakt het netwerk of de sleutels, en niets erin weet
5 * iets van Notes, feeds of guardianship:
6 * - de SSRF-poort (safeFetch en zijn wachters) voor ELKE uitgaande fetch
7 * - de RSA-sleutels per actor
8 * - HTTP Signatures: tekenen (deliver, signedGetHeaders) en controleren
9 * (verifyRequest)
10 * - de bezorging met wachtrij en backoff (deliverWithRetry en de worker)
11 * - de ondertekende GET (signedGetJson) en zijn onbetekende broer (apGetJson)
12 *
13 * Verhuisd uit ActivityPubService.js, dat alles her-exporteert: bestaande
14 * importeurs merken niets. De afhankelijkheden wijzen alleen omlaag (db,
15 * ap-core, Node zelf) -- er mag hier nooit iets uit de dienstlaag bij.
16 */
17import crypto from 'crypto';
18import fs from 'fs';
19import dns from 'dns';
20import net from 'net';
21import db from '../config/database.js';
22import { actorId } from './ap-core.js';
23
24// ── SSRF guard for outbound fetches ───────────────────────────────
25// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
26// every outbound fetch must refuse hosts that resolve to private/loopback ranges
27// (cloud metadata, internal services) — on the initial host AND each redirect hop.
28function isBlockedIp(ip) {
29 if (!ip) return true;
30 const v = net.isIP(ip);
31 if (v === 4) {
32 const o = ip.split('.').map(Number);
33 return o[0] === 127 || o[0] === 10 || o[0] === 0
34 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
35 || (o[0] === 192 && o[1] === 168)
36 || (o[0] === 169 && o[1] === 254)
37 || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
38 }
39 if (v === 6) {
40 const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
41 return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
42 || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
43 || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
44 }
45 return true; // not an IP literal we recognise → refuse
46}
47/**
48 * Uitzonderingen op de SSRF-poort, voor een testkudde op de eigen machine
49 * (shaer-6wt: honderd wards met een guardian, Barts opdracht 8-8).
50 *
51 * WAAROM DIT MAG BESTAAN. De bescherming hierboven is er omdat een actor-URI van
52 * een VREEMDE komt: een aanvaller die "http://169.254.169.254/" doorgeeft laat
53 * ons zijn werk doen. Deze lijst gaat niet over vreemden -- hij staat in de
54 * omgeving van deze server, wordt door de beheerder gezet, en is leeg tenzij
55 * iemand hem expliciet vult.
56 *
57 * WAAROM HIJ ZO SMAL IS. Geen vlag die "loopback is oke" zegt, maar een lijst
58 * van precieze host:poort-paren. `[::1]:3060` opent niet 127.0.0.1, niet poort
59 * 3061, en niets in het interne netwerk. Een brede vlag zou de bescherming in
60 * een dev-omgeving uitzetten, en dev-omgevingen worden productie.
61 *
62 * AP_ALLOW_HOSTS="[::1]:3060,[::1]:3061"
63 */
64const AP_ALLOW_HOSTS = new Set(
65 String(process.env.AP_ALLOW_HOSTS || '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean),
66);
67function isAllowedTestHost(u) {
68 if (!AP_ALLOW_HOSTS.size) return false;
69 return AP_ALLOW_HOSTS.has(u.host.toLowerCase());
70}
71async function assertPublicHost(hostname) {
72 // URL.hostname geeft een IPv6-literal MET blokhaken ("[::1]"), en net.isIP
73 // herkent die vorm niet. Zonder strippen viel elk IPv6-adres door naar de
74 // DNS-tak, waar het strandde op ENOTFOUND: geweigerd, maar per ongeluk en met
75 // de verkeerde reden. isBlockedIp strippde ze al -- die verwachtte dus input
76 // die hier nooit aankwam.
77 const naakt = String(hostname || '').replace(/^\[|\]$/g, '');
78 if (net.isIP(naakt)) { if (isBlockedIp(naakt)) throw new Error('ssrf-blocked-ip'); return; }
79 const addrs = await dns.promises.lookup(naakt, { all: true });
80 if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
81}
82// One honest name on ALL outbound federation traffic (Robins vraag, 31-7):
83// safeFetch went out with the bare Node default before, and polite fediverse
84// citizens say who they are (some instances even refuse anonymous UAs). A
85// caller-provided User-Agent (the EmbedResolver) still wins.
86let _uaVer = '1.0';
87try { _uaVer = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url))).version || _uaVer; } catch { /* keep default */ }
88const KLONKT_UA = `Klonkt/${_uaVer} (+https://klonkt.com)`;
89
90export async function safeFetch(url, opts = {}, maxRedirects = 3) {
91 let target = url;
92 for (let hop = 0; ; hop++) {
93 const u = new URL(target); // throws on malformed → caller's catch
94 if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
95 // Alleen op de precieze host:poort uit AP_ALLOW_HOSTS, en per hop opnieuw:
96 // een omleiding naar een ANDER intern adres blijft geweigerd.
97 if (!isAllowedTestHost(u)) await assertPublicHost(u.hostname);
98 const r = await fetch(target, {
99 ...opts,
100 headers: { 'User-Agent': KLONKT_UA, ...(opts.headers || {}) },
101 redirect: 'manual',
102 signal: AbortSignal.timeout(8000),
103 });
104 const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
105 if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
106 return r;
107 }
108}
109
110// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
111// Prepared lazily (NOT at module load) — the ap_keys table is created in
112// initializeDatabase(), which runs after this module is imported.
113let _sel, _ins;
114function keyStmts() {
115 if (!_sel) {
116 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
117 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
118 }
119 return { sel: _sel, ins: _ins };
120}
121
122export function getOrCreateKeys(slug) {
123 const { sel, ins } = keyStmts();
124 const row = sel.get(slug);
125 if (row) return row;
126 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
127 modulusLength: 2048,
128 publicKeyEncoding: { type: 'spki', format: 'pem' },
129 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
130 });
131 ins.run(slug, publicKey, privateKey);
132 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
133}
134
135// ── HTTP Signatures + delivery ────────────────────────────────────
136// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
137export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
138 const body = JSON.stringify(bodyObj);
139 const u = new URL(inboxUrl);
140 const date = new Date().toUTCString();
141 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
142 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
143 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
144 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
145 const r = await safeFetch(inboxUrl, {
146 method: 'POST',
147 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
148 body,
149 });
150 return r.status;
151}
152
153export async function fetchActor(url, opts = {}) {
154 // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn
155 // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
156 // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct
157 // ondertekende Follow van die instance niet verifiëren en wijzen we hem af,
158 // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier
159 // accounts eindeloos geweigerd, en precies die vier geven 401 op een
160 // onbetekende GET (shaer-afq).
161 //
162 // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons
163 // actor-document op, en dat serveert Klonkt publiek.
164 //
165 // ONBETEKEND EERST, en dat is een veiligheidskeuze en geen optimalisatie.
166 // verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die
167 // URL komt uit een header die iedereen mag sturen. Tekenden we dat verzoek
168 // standaard, dan kan een volslagen onbekende ons een ONDERTEKEND verzoek laten
169 // sturen naar een adres van zijn keuze -- met onze identiteit eronder. Dat is
170 // precies hoe een instance op een blocklist belandt. Ondertekenen doen we dus
171 // pas als het onbetekend niet lukt, en dan alleen voor deze ene URL.
172 let doc = null;
173 try {
174 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
175 if (r.ok) {
176 const len = Number(r.headers.get('content-length') || 0);
177 if (len > 2_000_000) return null; // refuse oversized actor docs
178 doc = await r.json();
179 }
180 } catch { /* val door naar de ondertekende poging */ }
181 // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document
182 // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die
183 // telt als mislukt.
184 if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc;
185 if (!opts.asSlug) return doc;
186 const signed = await signedGetJson(opts.asSlug, url).catch(() => null);
187 return (signed && signed.id) ? signed : doc;
188}
189
190// ── Delivery queue with retries ───────────────────────────────────
191// Outbound deliveries are tried immediately; on failure (down server, timeout,
192// non-2xx) they're queued and retried with backoff so a briefly-offline follower
193// doesn't silently miss the post. The signing key is NOT stored — the worker
194// re-derives it from the actor slug at send time.
195//
196// KLONKT DIE NIET ALTIJD AAN STAAT (shaer-xzab). Een Klonkt op een Mac thuis
197// slaapt, gaat de trein in, staat een weekend uit. Daarom:
198//
199// - Een bezorging VERLOOPT OP LEEFTIJD (5 dagen sinds created_at), niet na een
200// aantal pogingen (xzab.1). Zes pogingen waren ~4,5 uur, en de laatste trede
201// van 360 minuten werd nooit gebruikt: de telling was er eerder.
202// - OFFLINE ZIJN KOST GEEN POGINGEN (xzab.2). Mislukt een hele beurt op
203// netwerkniveau, naar meer dan één server en zonder één HTTP-antwoord, dan
204// ligt het aan ons: alles een paar minuten opschuiven, de telling ongemoeid,
205// en niets laten verlopen. Slapen kost al niets, want dan loopt de timer niet.
206// - BACKOFF PER SERVER, VOLGORDE PER INBOX (xzab.3). Eén mislukking naar een
207// server schuift al zijn rijen op. En per inbox oudste eerst, ook bij het
208// directe versturen: bij een venster van dagen mag een herhaalde Create niet
209// NA de Delete van dezelfde post aankomen.
210const DELIVERY_MAX_AGE_MS = 5 * 24 * 60 * 60 * 1000;
211// Minuten tot de volgende poging, op de poging die net mislukte; de laatste
212// trede herhaalt tot de bezorging verloopt.
213const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360, 720, 1440];
214// Hoe ver een beurt opschuift als WIJ offline blijken.
215const OFFLINE_RETRY_MIN = 2;
216
217export function deliveryBackoffMinutes(attemptsSoFar) {
218 return DELIVERY_BACKOFF_MIN[Math.min(Math.max(0, attemptsSoFar), DELIVERY_BACKOFF_MIN.length - 1)];
219}
220
221/** De host (met poort) van een inbox, in kleine letters; null als het geen URL is. */
222export function inboxHost(inbox) {
223 try { return new URL(inbox).host.toLowerCase(); } catch { return null; }
224}
225
226/** Is een bezorging zo oud dat hij opgegeven wordt? created_at is SQLite-UTC
227 * ('YYYY-MM-DD HH:MM:SS', zonder zone). */
228export function deliveryExpired(createdAt, now = Date.now()) {
229 if (!createdAt) return false;
230 const t = Date.parse(String(createdAt).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(createdAt) ? '' : 'Z'));
231 return Number.isFinite(t) && now - t >= DELIVERY_MAX_AGE_MS;
232}
233
234/** Faalde dit op NETWERKNIVEAU (geen HTTP-antwoord)? Een SSRF-weigering is
235 * dat niet: die is een besluit, en blijft een besluit als we weer online zijn. */
236function isNetworkError(e) {
237 return !/^ssrf/.test(String((e && e.message) || ''));
238}
239
240let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv, _hostDeliv, _offDeliv, _pendInbox, _hostWaits;
241function deliveryStmts() {
242 if (!_insDeliv) {
243 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, host, body, attempts, next_at) VALUES (?,?,?,?,0,CURRENT_TIMESTAMP)');
244 // Alleen de OUDSTE openstaande rij per inbox: een nieuwere wacht op hem, ook
245 // als hij zelf al aan de beurt zou zijn. Op id, want dat is de volgorde
246 // waarin ze ontstonden.
247 _dueDeliv = db.prepare(`SELECT d.* FROM ap_delivery d
248 WHERE datetime(d.next_at) <= datetime('now')
249 AND NOT EXISTS (SELECT 1 FROM ap_delivery o WHERE o.inbox = d.inbox AND o.id < d.id)
250 ORDER BY d.id LIMIT 30`);
251 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
252 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
253 // Een server die faalde: al zijn rijen niet eerder dan de nieuwe poging.
254 _hostDeliv = db.prepare('UPDATE ap_delivery SET next_at = ? WHERE host = ? AND datetime(next_at) < datetime(?)');
255 // Wij offline: opschuiven, de telling ongemoeid.
256 _offDeliv = db.prepare('UPDATE ap_delivery SET next_at = ? WHERE id = ?');
257 _pendInbox = db.prepare('SELECT 1 FROM ap_delivery WHERE inbox = ? LIMIT 1');
258 _hostWaits = db.prepare("SELECT 1 FROM ap_delivery WHERE host = ? AND datetime(next_at) > datetime('now') LIMIT 1");
259 }
260 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv, host: _hostDeliv, off: _offDeliv, pendInbox: _pendInbox, hostWaits: _hostWaits };
261}
262export function enqueueDelivery(slug, inbox, activity) {
263 if (!slug || !inbox || !activity) return;
264 try { deliveryStmts().ins.run(slug, inbox, inboxHost(inbox), JSON.stringify(activity)); } catch { /* ignore */ }
265}
266// Record delivery health per follower so the followers list can flag dead accounts.
267// Keyed by inbox: a shared-inbox POST reaches every follower behind it, so all of them
268// are marked. A non-follower inbox (inline @mention) simply matches 0 rows.
269let _fDelivOk, _fDelivErr;
270function markFollowerDelivery(slug, inbox, ok) {
271 if (!slug || !inbox) return;
272 try {
273 if (!_fDelivOk) {
274 _fDelivOk = db.prepare('UPDATE ap_followers SET last_delivery_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
275 _fDelivErr = db.prepare('UPDATE ap_followers SET last_error_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
276 }
277 (ok ? _fDelivOk : _fDelivErr).run(slug, inbox, inbox);
278 } catch { /* health tracking is non-fatal */ }
279}
280// Deliver now, or queue: zegt welke van de twee het werd ('sent' | 'queued'),
281// voor de aanroepers die tellen wat er meteen aankwam.
282//
283// Niet meteen als er voor deze inbox al iets in de wachtrij staat (dan zou dit
284// er voorbij gaan: een Delete vóór de Create die nog wacht), en niet als deze
285// server net faalde en in zijn backoff zit (dan kost het alleen acht seconden
286// time-out om hetzelfde te horen). Dan achteraan in de rij.
287//
288// DE ENIGE WEG om iets direct te versturen dat bij een mislukking in de rij
289// hoort (shaer-xzab.3). Drie plekken deden eerst zelf deliver() en pas bij een
290// mislukking enqueueDelivery(), en gingen zo langs de volgorde per inbox.
291export async function deliverNowOrQueue(slug, inbox, activity, keyId, privPem) {
292 if (!inbox) return 'queued';
293 try {
294 const st = deliveryStmts();
295 if (st.pendInbox.get(inbox) || st.hostWaits.get(inboxHost(inbox))) { enqueueDelivery(slug, inbox, activity); return 'queued'; }
296 } catch { /* geen wachtrij te lezen: gewoon proberen */ }
297 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return 'sent'; } } catch { /* queue below */ }
298 enqueueDelivery(slug, inbox, activity);
299 return 'queued';
300}
301// Deliver now; queue for retry if it fails.
302export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
303 await deliverNowOrQueue(slug, inbox, activity, keyId, privPem);
304}
305let _processingDeliv = false;
306export async function processDeliveryQueue() {
307 if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
308 _processingDeliv = true;
309 try {
310 let rows;
311 try { rows = deliveryStmts().due.all(); } catch { return; }
312 if (!rows || !rows.length) return;
313 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
314 // Eerst alles proberen, pas daarna beslissen: of het aan ons lag, zie je
315 // pas aan de hele beurt.
316 const failed = []; // { row, network }
317 const down = new Set(); // servers die in deze beurt al faalden
318 const skipped = []; // rijen van zo'n server, niet meer geprobeerd
319 let answered = false; // kwam er ergens een HTTP-antwoord?
320 for (const row of rows) {
321 const host = row.host || inboxHost(row.inbox);
322 if (host && down.has(host)) { skipped.push(row); continue; }
323 let ok = false, network = false;
324 try {
325 const keys = getOrCreateKeys(row.slug);
326 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
327 answered = true;
328 ok = st >= 200 && st < 300;
329 } catch (e) { network = isNetworkError(e); }
330 if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }
331 failed.push({ row, network, host });
332 if (host) down.add(host);
333 }
334 if (!failed.length) return;
335
336 // Lag het aan ons? Niets beantwoord, alles op netwerkniveau, en naar meer
337 // dan één server -- bij één server is "hij ligt eruit" net zo waarschijnlijk.
338 const hosts = new Set(failed.map((f) => f.host));
339 const offline = !answered && failed.every((f) => f.network) && hosts.size > 1;
340 if (offline) {
341 const later = new Date(Date.now() + OFFLINE_RETRY_MIN * 60000).toISOString();
342 for (const r of [...failed.map((f) => f.row), ...skipped]) deliveryStmts().off.run(later, r.id);
343 console.warn('[AP] delivery: no network for', failed.length, 'deliveries to', hosts.size, 'servers; retrying in', OFFLINE_RETRY_MIN, 'min, attempts untouched');
344 return;
345 }
346
347 for (const { row, host } of failed) {
348 if (deliveryExpired(row.created_at)) {
349 markFollowerDelivery(row.slug, row.inbox, false);
350 deliveryStmts().del.run(row.id);
351 console.warn('[AP] delivery gave up after', row.attempts + 1, 'tries over 5 days →', row.inbox);
352 continue;
353 }
354 const next = new Date(Date.now() + deliveryBackoffMinutes(row.attempts) * 60000).toISOString();
355 deliveryStmts().bump.run(row.attempts + 1, next, row.id);
356 // De hele server wacht mee: zijn andere rijen niet eerder dan deze.
357 if (host) deliveryStmts().host.run(next, host, next);
358 }
359 } finally { _processingDeliv = false; }
360}
361let _delivTimer = null;
362export function startDeliveryWorker() {
363 if (_delivTimer) return;
364 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
365 if (_delivTimer.unref) _delivTimer.unref();
366}
367
368/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
369 * (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
370let _signSlug;
371export function anySigningSlug() {
372 if (_signSlug !== undefined) return _signSlug;
373 try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }
374 catch { _signSlug = null; }
375 return _signSlug;
376}
377
378// Best-effort verification of an incoming signed request. Returns the sender's
379// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
380// Max clock skew for the signed Date header (replay window). Generous default to tolerate
381// federating servers with drifting clocks; an operator can widen it via env.
382const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
383export async function verifyRequest(req, asSlug = null) {
384 const sigH = req.headers['signature'];
385 if (!sigH) return null;
386 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
387 if (!p.keyId || !p.signature) return null;
388 // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized
389 // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we
390 // als een willekeurige lokale actor: elke Klonkt-actor is een geldige
391 // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.
392 const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });
393 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
394 if (!pem) return null;
395 // Bind the key to the actor it speaks for. Without this we hand back whatever
396 // `id` the fetched document claims, so anyone could host a document carrying a
397 // VICTIM's id next to their OWN public key, sign with their own private half,
398 // and be believed: the victim's server is never contacted. The caller decides on
399 // `verified.id`, so the identity has to come from where the key was FETCHED,
400 // never from what the document says about itself.
401 // Adds conditions only, and there is no exemption list on purpose: an
402 // "unless it's a known peer" escape hatch is exactly the door this closes.
403 // Note this does not narrow what we accept in practice, since the line above
404 // already requires the embedded publicKey object (an array or a bare URI
405 // reference never worked here).
406 const key = actor.publicKey;
407 try {
408 if (new URL(p.keyId).host !== new URL(actor.id).host) return null; // same origin as the key
409 if (key.id && key.id !== p.keyId) return null; // this key, not a neighbour's
410 if (key.owner && key.owner !== actor.id) return null; // and it belongs to this actor
411 } catch { return null; } // unparseable id or keyId
412 const hs = (p.headers || '(request-target) host date').split(/\s+/);
413 // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
414 // the proxy doesn't preserve it — Apache .htaccess [P] proxying), but the sender signed the
415 // HTTP-Signature over the PUBLIC host. Try each candidate host (the configured PUBLIC_BASE_URL
416 // host, the proxy's X-Forwarded-Host, and the raw Host) and accept if the signature verifies
417 // against any. An attacker can't forge a match (no private key), so this only rescues the
418 // legitimate proxied case. Also normalise a leading double-slash in the request-target.
419 let _pubHost = null;
420 if (process.env.PUBLIC_BASE_URL) { try { _pubHost = new URL(process.env.PUBLIC_BASE_URL).host; } catch { /* ignore */ } }
421 const _hosts = [...new Set([_pubHost, req.headers['x-forwarded-host'], req.headers['host']].filter(Boolean))];
422 const _target = `${req.method.toLowerCase()} ${String(req.originalUrl || '').replace(/^\/{2,}/, '/')}`;
423 const _sig = Buffer.from(p.signature, 'base64');
424 let ok = false;
425 for (const _h of _hosts) {
426 const line = hs.map((x) => x === '(request-target)'
427 ? `(request-target): ${_target}`
428 : x === 'host' ? `host: ${_h}`
429 : `${x}: ${req.headers[x] || ''}`).join('\n');
430 try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }
431 }
432 // Replay defence: the Date header must be signed and recent. A captured signed request
433 // replayed later (or with a swapped body) is rejected.
434 if (ok) {
435 if (!hs.includes('date')) ok = false;
436 else {
437 const t = Date.parse(req.headers['date'] || '');
438 if (isNaN(t) || Math.abs(Date.now() - t) > SIG_MAX_SKEW_MS) ok = false;
439 }
440 }
441 // Digest is MANDATORY when the request carries a body: without a signed digest the body
442 // isn't covered by the signature and could be swapped on a replay.
443 if (ok && req.rawBody && req.rawBody.length) {
444 if (!hs.includes('digest')) ok = false;
445 else {
446 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
447 if (req.headers['digest'] !== exp) ok = false;
448 }
449 }
450 return ok ? actor : null;
451}
452
453// A generic SSRF-safe AP GET (collections / pages).
454/**
455 * A signed GET as one of our local actors (friends-history, 30-7): the remote
456 * server can then recognise the caller and serve what THAT caller may see,
457 * exactly like the guardian's authorized fetch. The signature covers
458 * (request-target) host date, the set verifyRequest checks.
459 */
460/**
461 * De handtekening-headers voor een GET als `slug`. Losgetrokken uit
462 * signedGetJson omdat een verhuizing ook BYTES moet kunnen ophalen (FEP-1580:
463 * gehoste audio zit achter dezelfde poort als de rest, en een ongetekende fetch
464 * krijgt daar terecht een 403).
465 */
466export function signedGetHeaders(slug, url, accept = 'application/activity+json') {
467 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
468 if (!base || !slug) return null;
469 const me = actorId(base, slug);
470 const keys = getOrCreateKeys(slug);
471 const u = new URL(url);
472 const date = new Date().toUTCString();
473 const target = `${u.pathname}${u.search || ''}`;
474 const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
475 const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
476 return {
477 Accept: accept,
478 Date: date,
479 Signature: `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`,
480 };
481}
482
483export async function signedGetJson(slug, url, onStatus) {
484 try {
485 const headers = signedGetHeaders(slug, url);
486 if (!headers) return apGetJson(url);
487 const r = await safeFetch(url, { headers });
488 // De status doorgeven aan wie erom vroeg: null alleen zegt "het lukte
489 // niet", en dat is te weinig om een WEIGERING van een STORING te
490 // onderscheiden. Wie geen callback meegeeft merkt hier niets van.
491 if (typeof onStatus === 'function') onStatus(r.status);
492 if (!r.ok) return null;
493 const len = Number(r.headers.get('content-length') || 0);
494 if (len > 3_000_000) return null;
495 return await r.json();
496 } catch { return null; }
497}
498
499export async function apGetJson(url) {
500 try {
501 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
502 if (!r.ok) return null;
503 const len = Number(r.headers.get('content-length') || 0);
504 if (len > 3_000_000) return null;
505 return await r.json();
506 } catch { return null; }
507}
Note: See TracBrowser for help on using the repository browser.