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

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

Bezorging voor een Klonkt die niet altijd aan staat: leeftijd, offline, per server

De uitgaande helft van shaer-xzab (Bart, 26-10): een Klonkt op een Mac thuis
slaapt, gaat de trein in, staat een weekend uit, en de bezorgwachtrij was daar
niet op gebouwd.

xzab.1 Verlopen op LEEFTIJD (5 dagen sinds created_at), niet na zes pogingen.
Zes pogingen waren ~4,5 uur, en de trede van 360 minuten werd nooit gebruikt:
de telling was er eerder. Treden nu 1m, 5m, 15m, 1u, 3u, 6u, 12u, dan elke 24u.

xzab.2 Offline zijn kost geen pogingen. Faalt een hele beurt op netwerkniveau
(geen enkel HTTP-antwoord) naar meer dan één server, dan ligt het aan ons:
alles twee minuten opschuiven, de telling ongemoeid, en niets laten verlopen.
Bij één server is "hij ligt eruit" net zo waarschijnlijk, dus dat telt gewoon,
en een SSRF-weigering is een besluit en geen netwerkfout.

xzab.3 Backoff per server, volgorde per inbox. Eén mislukking naar een server
schuift al zijn rijen op (nieuwe kolom ap_delivery.host, met een eenmalige
backfill), in plaats van dertig rijen met elk acht seconden time-out op
dezelfde dode server. Per inbox gaat de oudste eerst -- en ook het directe
versturen in deliverWithRetry wacht als er voor die inbox nog iets in de rij
staat, of als die server in zijn backoff zit. Anders kwam een Delete aan vóór
de Create die nog wachtte, en stond de post daarna weer op die server.

test/delivery-queue.test.js: twaalf toetsen tegen echte HTTP-servers en dichte
poorten. Twaalf mutaties apart gedraaid, elk gevangen; één toets bleek eerst
gemaskeerd (de backoff hield tegen wat de volgorde moest tegenhouden) en is
aangescherpt. Volledige suite 1362 groen.

Nog open in het epic: xzab.6 (wat zet bij Mastodon e.a. een domein terug op
beschikbaar), en daarna het wakker worden, xzab.4 en xzab.5.

Niet in deze ronde: drie plekken versturen zelf direct en zetten pas bij een
mislukking in de rij (guardianship/delivery.js, ActivityPubService bij een
antwoord en bij het wissen van een reactie). Die gaan niet langs de volgorde
per inbox.

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

  • Property mode set to 100644
File size: 25.8 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; queue for retry if it fails.
281//
282// Niet meteen als er voor deze inbox al iets in de wachtrij staat (dan zou dit
283// er voorbij gaan: een Delete vóór de Create die nog wacht), en niet als deze
284// server net faalde en in zijn backoff zit (dan kost het alleen acht seconden
285// time-out om hetzelfde te horen). Dan achteraan in de rij.
286export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
287 if (!inbox) return;
288 try {
289 const st = deliveryStmts();
290 if (st.pendInbox.get(inbox) || st.hostWaits.get(inboxHost(inbox))) { enqueueDelivery(slug, inbox, activity); return; }
291 } catch { /* geen wachtrij te lezen: gewoon proberen */ }
292 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return; } } catch { /* queue below */ }
293 enqueueDelivery(slug, inbox, activity);
294}
295let _processingDeliv = false;
296export async function processDeliveryQueue() {
297 if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
298 _processingDeliv = true;
299 try {
300 let rows;
301 try { rows = deliveryStmts().due.all(); } catch { return; }
302 if (!rows || !rows.length) return;
303 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
304 // Eerst alles proberen, pas daarna beslissen: of het aan ons lag, zie je
305 // pas aan de hele beurt.
306 const failed = []; // { row, network }
307 const down = new Set(); // servers die in deze beurt al faalden
308 const skipped = []; // rijen van zo'n server, niet meer geprobeerd
309 let answered = false; // kwam er ergens een HTTP-antwoord?
310 for (const row of rows) {
311 const host = row.host || inboxHost(row.inbox);
312 if (host && down.has(host)) { skipped.push(row); continue; }
313 let ok = false, network = false;
314 try {
315 const keys = getOrCreateKeys(row.slug);
316 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
317 answered = true;
318 ok = st >= 200 && st < 300;
319 } catch (e) { network = isNetworkError(e); }
320 if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }
321 failed.push({ row, network, host });
322 if (host) down.add(host);
323 }
324 if (!failed.length) return;
325
326 // Lag het aan ons? Niets beantwoord, alles op netwerkniveau, en naar meer
327 // dan één server -- bij één server is "hij ligt eruit" net zo waarschijnlijk.
328 const hosts = new Set(failed.map((f) => f.host));
329 const offline = !answered && failed.every((f) => f.network) && hosts.size > 1;
330 if (offline) {
331 const later = new Date(Date.now() + OFFLINE_RETRY_MIN * 60000).toISOString();
332 for (const r of [...failed.map((f) => f.row), ...skipped]) deliveryStmts().off.run(later, r.id);
333 console.warn('[AP] delivery: no network for', failed.length, 'deliveries to', hosts.size, 'servers; retrying in', OFFLINE_RETRY_MIN, 'min, attempts untouched');
334 return;
335 }
336
337 for (const { row, host } of failed) {
338 if (deliveryExpired(row.created_at)) {
339 markFollowerDelivery(row.slug, row.inbox, false);
340 deliveryStmts().del.run(row.id);
341 console.warn('[AP] delivery gave up after', row.attempts + 1, 'tries over 5 days →', row.inbox);
342 continue;
343 }
344 const next = new Date(Date.now() + deliveryBackoffMinutes(row.attempts) * 60000).toISOString();
345 deliveryStmts().bump.run(row.attempts + 1, next, row.id);
346 // De hele server wacht mee: zijn andere rijen niet eerder dan deze.
347 if (host) deliveryStmts().host.run(next, host, next);
348 }
349 } finally { _processingDeliv = false; }
350}
351let _delivTimer = null;
352export function startDeliveryWorker() {
353 if (_delivTimer) return;
354 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
355 if (_delivTimer.unref) _delivTimer.unref();
356}
357
358/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
359 * (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
360let _signSlug;
361export function anySigningSlug() {
362 if (_signSlug !== undefined) return _signSlug;
363 try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }
364 catch { _signSlug = null; }
365 return _signSlug;
366}
367
368// Best-effort verification of an incoming signed request. Returns the sender's
369// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
370// Max clock skew for the signed Date header (replay window). Generous default to tolerate
371// federating servers with drifting clocks; an operator can widen it via env.
372const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
373export async function verifyRequest(req, asSlug = null) {
374 const sigH = req.headers['signature'];
375 if (!sigH) return null;
376 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
377 if (!p.keyId || !p.signature) return null;
378 // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized
379 // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we
380 // als een willekeurige lokale actor: elke Klonkt-actor is een geldige
381 // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.
382 const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });
383 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
384 if (!pem) return null;
385 // Bind the key to the actor it speaks for. Without this we hand back whatever
386 // `id` the fetched document claims, so anyone could host a document carrying a
387 // VICTIM's id next to their OWN public key, sign with their own private half,
388 // and be believed: the victim's server is never contacted. The caller decides on
389 // `verified.id`, so the identity has to come from where the key was FETCHED,
390 // never from what the document says about itself.
391 // Adds conditions only, and there is no exemption list on purpose: an
392 // "unless it's a known peer" escape hatch is exactly the door this closes.
393 // Note this does not narrow what we accept in practice, since the line above
394 // already requires the embedded publicKey object (an array or a bare URI
395 // reference never worked here).
396 const key = actor.publicKey;
397 try {
398 if (new URL(p.keyId).host !== new URL(actor.id).host) return null; // same origin as the key
399 if (key.id && key.id !== p.keyId) return null; // this key, not a neighbour's
400 if (key.owner && key.owner !== actor.id) return null; // and it belongs to this actor
401 } catch { return null; } // unparseable id or keyId
402 const hs = (p.headers || '(request-target) host date').split(/\s+/);
403 // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
404 // the proxy doesn't preserve it — Apache .htaccess [P] proxying), but the sender signed the
405 // HTTP-Signature over the PUBLIC host. Try each candidate host (the configured PUBLIC_BASE_URL
406 // host, the proxy's X-Forwarded-Host, and the raw Host) and accept if the signature verifies
407 // against any. An attacker can't forge a match (no private key), so this only rescues the
408 // legitimate proxied case. Also normalise a leading double-slash in the request-target.
409 let _pubHost = null;
410 if (process.env.PUBLIC_BASE_URL) { try { _pubHost = new URL(process.env.PUBLIC_BASE_URL).host; } catch { /* ignore */ } }
411 const _hosts = [...new Set([_pubHost, req.headers['x-forwarded-host'], req.headers['host']].filter(Boolean))];
412 const _target = `${req.method.toLowerCase()} ${String(req.originalUrl || '').replace(/^\/{2,}/, '/')}`;
413 const _sig = Buffer.from(p.signature, 'base64');
414 let ok = false;
415 for (const _h of _hosts) {
416 const line = hs.map((x) => x === '(request-target)'
417 ? `(request-target): ${_target}`
418 : x === 'host' ? `host: ${_h}`
419 : `${x}: ${req.headers[x] || ''}`).join('\n');
420 try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }
421 }
422 // Replay defence: the Date header must be signed and recent. A captured signed request
423 // replayed later (or with a swapped body) is rejected.
424 if (ok) {
425 if (!hs.includes('date')) ok = false;
426 else {
427 const t = Date.parse(req.headers['date'] || '');
428 if (isNaN(t) || Math.abs(Date.now() - t) > SIG_MAX_SKEW_MS) ok = false;
429 }
430 }
431 // Digest is MANDATORY when the request carries a body: without a signed digest the body
432 // isn't covered by the signature and could be swapped on a replay.
433 if (ok && req.rawBody && req.rawBody.length) {
434 if (!hs.includes('digest')) ok = false;
435 else {
436 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
437 if (req.headers['digest'] !== exp) ok = false;
438 }
439 }
440 return ok ? actor : null;
441}
442
443// A generic SSRF-safe AP GET (collections / pages).
444/**
445 * A signed GET as one of our local actors (friends-history, 30-7): the remote
446 * server can then recognise the caller and serve what THAT caller may see,
447 * exactly like the guardian's authorized fetch. The signature covers
448 * (request-target) host date, the set verifyRequest checks.
449 */
450/**
451 * De handtekening-headers voor een GET als `slug`. Losgetrokken uit
452 * signedGetJson omdat een verhuizing ook BYTES moet kunnen ophalen (FEP-1580:
453 * gehoste audio zit achter dezelfde poort als de rest, en een ongetekende fetch
454 * krijgt daar terecht een 403).
455 */
456export function signedGetHeaders(slug, url, accept = 'application/activity+json') {
457 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
458 if (!base || !slug) return null;
459 const me = actorId(base, slug);
460 const keys = getOrCreateKeys(slug);
461 const u = new URL(url);
462 const date = new Date().toUTCString();
463 const target = `${u.pathname}${u.search || ''}`;
464 const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
465 const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
466 return {
467 Accept: accept,
468 Date: date,
469 Signature: `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`,
470 };
471}
472
473export async function signedGetJson(slug, url, onStatus) {
474 try {
475 const headers = signedGetHeaders(slug, url);
476 if (!headers) return apGetJson(url);
477 const r = await safeFetch(url, { headers });
478 // De status doorgeven aan wie erom vroeg: null alleen zegt "het lukte
479 // niet", en dat is te weinig om een WEIGERING van een STORING te
480 // onderscheiden. Wie geen callback meegeeft merkt hier niets van.
481 if (typeof onStatus === 'function') onStatus(r.status);
482 if (!r.ok) return null;
483 const len = Number(r.headers.get('content-length') || 0);
484 if (len > 3_000_000) return null;
485 return await r.json();
486 } catch { return null; }
487}
488
489export async function apGetJson(url) {
490 try {
491 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
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}
Note: See TracBrowser for help on using the repository browser.