import { digestBase64, parseSignatureHeader, signString, verifyWithPem } from "./crypto"; import { actorCacheStale, claimOutgoingDelivery, deleteActorFromCache, enqueueOutgoingDeliveries, ensureActorLocalId, getActorByKeyId, getActorFromCache, getUserById, listDueOutgoingDeliveries, markOutgoingDeliveryDelivered, markOutgoingDeliveryFailed, recordNotification, upsertActorCache } from "./db"; import type { ActorCache, Json, OutgoingDelivery, RemoteActor, Status, User } from "./types"; import { SIGNATURE_MAX_SKEW_MS } from "./types"; import { actorUrl, base64Decode, encoder, hostFromBaseUrl, parseAcctFromActor } from "./util"; const ACTIVITY_HEADERS = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; const DELIVERY_BATCH_SIZE = 20; const DELIVERY_MAX_ATTEMPTS = 8; const DELIVERY_LEASE_MS = 60_000; const DELIVERY_MAX_BACKOFF_SECONDS = 60 * 60; export async function resolveRemoteActor(env: Env, actorId: string, opts: { force?: boolean } = {}): Promise { if (!actorId) return null; const cached = await getActorFromCache(env, actorId); if (cached && !opts.force && !actorCacheStale(cached)) return ensureActorLocalId(env, cached); const fetched = await fetchRemoteActor(actorId); if (!fetched) return cached ? ensureActorLocalId(env, cached) : null; return upsertActorCache(env, fetched); } export async function fetchRemoteActor(actorId: string): Promise { try { const response = await fetch(actorId, { headers: { accept: ACTIVITY_HEADERS }, cf: { cacheTtl: 60 } }); if (!response.ok) return null; const data = await response.json() as RemoteActor; if (!data || !data.id) return null; return data; } catch { return null; } } export async function discoverActorByKeyId(env: Env, keyId: string): Promise { const cached = await getActorByKeyId(env, keyId); if (cached && !actorCacheStale(cached)) return cached; const actorIdGuess = keyId.split("#")[0]; const refreshed = await resolveRemoteActor(env, actorIdGuess, { force: true }); if (refreshed?.public_key_id === keyId) return refreshed; return cached; } export type VerifiedSignature = { actor: ActorCache; keyId: string; }; export async function verifyInboundSignature(request: Request, body: string, env: Env): Promise { const sigHeader = request.headers.get("signature"); if (!sigHeader) return null; const parsed = parseSignatureHeader(sigHeader); if (!parsed || !parsed.headers.includes("(request-target)")) return null; const dateHeader = request.headers.get("date"); if (dateHeader) { const stamp = Date.parse(dateHeader); if (!Number.isFinite(stamp)) return null; if (Math.abs(Date.now() - stamp) > SIGNATURE_MAX_SKEW_MS) return null; } else if (parsed.headers.includes("date")) { return null; } const digestHeader = request.headers.get("digest"); if (digestHeader) { const expected = `SHA-256=${await digestBase64(body)}`; if (digestHeader !== expected) return null; } else if (request.method.toUpperCase() === "POST" && body) { return null; } const url = new URL(request.url); const hostHeader = request.headers.get("host"); if (parsed.headers.includes("host") && hostHeader && hostHeader.toLowerCase() !== url.host.toLowerCase()) { return null; } const lines: string[] = []; for (const headerName of parsed.headers) { if (headerName === "(request-target)") { lines.push(`(request-target): ${request.method.toLowerCase()} ${url.pathname}${url.search}`); continue; } const value = request.headers.get(headerName); if (value === null) return null; lines.push(`${headerName}: ${value}`); } const actor = await discoverActorByKeyId(env, parsed.keyId); if (!actor || !actor.public_key_pem) return null; try { const ok = await verifyWithPem(actor.public_key_pem, lines.join("\n"), parsed.signature); if (!ok) return null; } catch { return null; } return { actor, keyId: parsed.keyId }; } export async function sendSignedActivity(env: Env, user: User, inboxUrl: string, activity: Json): Promise<{ ok: boolean; status: number; text: string }> { const target = new URL(inboxUrl); const body = JSON.stringify(activity); const digest = `SHA-256=${await digestBase64(body)}`; const date = new Date().toUTCString(); const host = target.host; const path = `${target.pathname}${target.search}`; const signingString = [ `(request-target): post ${path}`, `host: ${host}`, `date: ${date}`, `digest: ${digest}` ].join("\n"); const signature = await signString(signingString, JSON.parse(user.private_key_jwk) as JsonWebKey); const headerValue = [ `keyId="${actorUrl(env, user)}#main-key"`, `algorithm="rsa-sha256"`, `headers="(request-target) host date digest"`, `signature="${signature}"` ].join(","); try { const response = await fetch(inboxUrl, { method: "POST", headers: { accept: "application/activity+json", "content-type": "application/activity+json", date, digest, signature: headerValue, "user-agent": `toot-worker (+https://${hostFromBaseUrl(env)})` }, body }); const text = response.ok ? "" : await response.text().catch(() => ""); if (!response.ok) console.warn("signed-delivery", inboxUrl, response.status, text.slice(0, 200)); return { ok: response.ok, status: response.status, text }; } catch (error) { console.warn("signed-delivery-error", inboxUrl, String(error)); return { ok: false, status: 0, text: String(error) }; } } export async function deliverToInboxes(env: Env, user: User, inboxes: Iterable, activity: Json): Promise { await enqueueOutgoingDeliveries(env, user.id, inboxes, activity); } export async function processOutgoingDeliveries(env: Env): Promise { const now = new Date().toISOString(); const deliveries = await listDueOutgoingDeliveries(env, now, DELIVERY_BATCH_SIZE); for (const delivery of deliveries) { await processOutgoingDelivery(env, delivery); } } async function processOutgoingDelivery(env: Env, delivery: OutgoingDelivery): Promise { const now = new Date(); const nowIso = now.toISOString(); const lockedUntil = new Date(now.getTime() + DELIVERY_LEASE_MS).toISOString(); const claimed = await claimOutgoingDelivery(env, delivery.id, nowIso, lockedUntil); if (!claimed) return; let activity: Json; try { activity = JSON.parse(delivery.activity_json) as Json; } catch { await markOutgoingDeliveryFailed(env, delivery.id, DELIVERY_MAX_ATTEMPTS, null, "invalid_activity_json"); return; } const user = await getUserById(env, delivery.user_id); if (!user) { await markOutgoingDeliveryFailed(env, delivery.id, DELIVERY_MAX_ATTEMPTS, null, "delivery_user_missing"); return; } const result = await sendSignedActivity(env, user, delivery.inbox, activity).catch((error) => ({ ok: false, status: 0, text: String(error) })); if (result.ok) { await markOutgoingDeliveryDelivered(env, delivery.id); return; } const attempts = delivery.attempts + 1; const nextAttemptAt = attempts >= DELIVERY_MAX_ATTEMPTS ? null : nextDeliveryAttemptAt(attempts); const error = result.status ? `${result.status} ${result.text}` : result.text; await markOutgoingDeliveryFailed(env, delivery.id, attempts, nextAttemptAt, error); } function nextDeliveryAttemptAt(attempts: number): string { const delaySeconds = Math.min(DELIVERY_MAX_BACKOFF_SECONDS, 60 * (2 ** Math.max(0, attempts - 1))); return new Date(Date.now() + delaySeconds * 1000).toISOString(); } export async function gatherFollowerInboxes(env: Env, userId: string): Promise { const rows = await env.DB.prepare( "SELECT inbox FROM follows WHERE local_user_id = ? AND accepted = 1" ).bind(userId).all<{ inbox: string }>(); const inboxes = new Set(); for (const row of rows.results) { if (row.inbox) { const actor = await getActorFromCache(env, row.inbox); const shared = (await getActorByKeyId(env, row.inbox))?.shared_inbox; inboxes.add(actor?.shared_inbox ?? shared ?? row.inbox); } } return [...inboxes]; } export async function resolveDeliveryInboxes(env: Env, actorIds: Iterable): Promise { const inboxes = new Set(); for (const actorId of actorIds) { const actor = await resolveRemoteActor(env, actorId); if (!actor) continue; inboxes.add(actor.shared_inbox ?? actor.inbox); } return [...inboxes]; } export type InboundActivity = { body: Json; bodyText: string; activityId: string; type: string; actor: string; }; export function parseActivity(bodyText: string): InboundActivity | null { try { const body = JSON.parse(bodyText) as Json; return { body, bodyText, activityId: String(body.id ?? ""), type: String(body.type ?? ""), actor: typeof body.actor === "string" ? body.actor : String((body.actor as Json | undefined)?.id ?? "") }; } catch { return null; } } export function activityObjectField(activity: InboundActivity, field: string): unknown { const object = activity.body[field]; return object; } export function objectAsJson(value: unknown): Json | null { return value && typeof value === "object" ? value as Json : null; } export function objectIdString(value: unknown): string | null { if (typeof value === "string") return value; const obj = objectAsJson(value); if (obj && typeof obj.id === "string") return obj.id; return null; } export async function recordRemoteActivity(env: Env, activity: InboundActivity, verified: boolean): Promise { await env.DB.prepare( "INSERT OR IGNORE INTO remote_activities (id, actor, type, payload, received_at) VALUES (?, ?, ?, ?, ?)" ) .bind( activity.activityId || crypto.randomUUID(), activity.actor, activity.type, JSON.stringify({ ...activity.body, signature_verified: verified }), new Date().toISOString() ) .run(); } export async function isDuplicateActivity(env: Env, activityId: string): Promise { if (!activityId) return false; const row = await env.DB.prepare("SELECT id FROM remote_activities WHERE id = ?").bind(activityId).first<{ id: string }>(); return Boolean(row); } export async function notifyForLocalStatus(env: Env, status: Status, type: string, actor: string): Promise { await recordNotification(env, status.user_id, type, actor, status.id); } export function mentionAcct(env: Env, actorId: string): string { return parseAcctFromActor(env, actorId); } export async function isFollowedByAnyLocalUser(env: Env, actorId: string): Promise { const row = await env.DB.prepare("SELECT 1 AS hit FROM outgoing_follows WHERE target_actor = ? AND accepted = 1 LIMIT 1").bind(actorId).first<{ hit: number }>(); return Boolean(row); } export async function decodeRemoteSignatureBase64(value: string): Promise { return base64Decode(value); } export function activitySignableData(input: string): Uint8Array { return encoder.encode(input); }