import { createHash, createPublicKey, sign, verify, randomUUID } from "node:crypto" import { lookup } from "node:dns/promises" import { BlockList, isIP } from "node:net" import https from "node:https" import { fail } from "./types" const covered = '("@method" "@target-uri" "content-type" "content-digest" "x-fedeo-origin")' export type FederationKey = { keyId: string; publicKey: string; privateKey?: string } export type Headers = Record export type Transport = (url: string, method: "GET" | "POST", headers: Headers, body?: string) => Promise export function instanceUrl(value: string) { let url: URL try { url = new URL(value) } catch { fail("Ungültige Instanzadresse") } if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || (url.port && url.port !== "443") || !/^\/(?:[A-Za-z0-9_-]+\/?)*$/.test(url.pathname)) { fail("Die Instanz benötigt eine öffentliche HTTPS-Adresse auf Port 443") } return url.href.replace(/\/$/, "") } export const digest = (body: string) => `sha-256=:${createHash("sha256").update(body, "utf8").digest("base64")}:` export function signatureBase(method: string, target: string, headers: Headers, params: string) { return `"@method": ${method}\n"@target-uri": ${target}\n"content-type": ${headers["content-type"]}\n"content-digest": ${headers["content-digest"]}\n"x-fedeo-origin": ${headers["x-fedeo-origin"]}\n"@signature-params": ${params}` } /** Deliberately narrow RFC 9421 application profile; no algorithm negotiation or remote key URLs. */ export function signedHeaders(key: FederationKey, origin: string, target: string, body: string, now = Math.floor(Date.now() / 1000), nonce = randomUUID()): Headers { const params = `${covered};created=${now};expires=${now + 120};nonce="${nonce}";keyid="${key.keyId}";alg="ed25519"` const headers = { "content-type": "application/json", "content-digest": digest(body), "x-fedeo-origin": origin } const signature = sign(null, Buffer.from(signatureBase("POST", target, headers, params)), key.privateKey!).toString("base64") return { ...headers, "signature-input": `fedeo=${params}`, signature: `fedeo=:${signature}:` } } export function verifyHeaders(key: FederationKey, target: string, body: string, headers: Headers, now = Math.floor(Date.now() / 1000)) { const raw = headers["signature-input"] || "" const suffix = raw.slice(`fedeo=${covered}`.length) const match = /^;created=(\d+);expires=(\d+);nonce="([0-9a-f-]{36})";keyid="([0-9a-f-]{36})";alg="ed25519"$/.exec(suffix) if (!raw.startsWith(`fedeo=${covered}`) || !match || match[4] !== key.keyId) fail("Ungültiges Signaturprofil", 401) const created = Number(match[1]), expires = Number(match[2]) if (created > now + 30 || created < now - 300 || expires < now || expires > created + 300 || expires <= created) fail("Signatur ist abgelaufen", 401) if (headers["content-type"] !== "application/json" || headers["content-digest"] !== digest(body)) fail("Ungültiger Inhaltsdigest", 401) const signature = /^fedeo=:([A-Za-z0-9+/]+={0,2}):$/.exec(headers.signature || "") if (!signature) fail("Ungültige Signatur", 401) const bytes = Buffer.from(signature[1], "base64") if (bytes.length !== 64 || !verify(null, Buffer.from(signatureBase("POST", target, headers, raw.slice(6))), key.publicKey, bytes)) fail("Ungültige Signatur", 401) return { nonce: match[3], expires } } const privateV4 = new BlockList() for (const [address, bits] of [["0.0.0.0",8],["10.0.0.0",8],["100.64.0.0",10],["127.0.0.0",8],["169.254.0.0",16],["172.16.0.0",12], ["192.0.0.0",24],["192.0.2.0",24],["192.168.0.0",16],["198.18.0.0",15],["198.51.100.0",24],["203.0.113.0",24],["224.0.0.0",3]] as [string,number][]) privateV4.addSubnet(address,bits) const publicV6 = new BlockList(); publicV6.addSubnet("2000::",3,"ipv6") const specialV6 = new BlockList() for (const [address, bits] of [["2001::",23],["2001:db8::",32],["2002::",16],["3fff::",20]] as [string,number][]) specialV6.addSubnet(address,bits,"ipv6") export function publicAddress(address: string) { return isIP(address) === 4 ? !privateV4.check(address) : isIP(address) === 6 && publicV6.check(address,"ipv6") && !specialV6.check(address,"ipv6") } export function validatePublicKey(pem: unknown) { if (typeof pem !== "string" || pem.length > 2000) fail("Ungültiger Instanzschlüssel") try { if (createPublicKey(pem).asymmetricKeyType !== "ed25519") fail("Ed25519-Schlüssel erforderlich") } catch { fail("Ungültiger Instanzschlüssel") } return pem } export const safeTransport: Transport = async (target, method, headers, body) => { const url = new URL(target) if (url.protocol !== "https:" || url.username || url.password || (url.port && url.port !== "443")) fail("Unzulässiges Föderationsziel") let timer: ReturnType const addresses = await Promise.race([ lookup(url.hostname, { all: true }), new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error("DNS-Zeitüberschreitung"), { statusCode: 503 })), 3000) }), ]).finally(() => clearTimeout(timer)) if (!addresses.length || addresses.some(item => !publicAddress(item.address))) fail("Föderation darf keine privaten oder reservierten Netzwerkadressen aufrufen", 400) const address = addresses[0] return new Promise((resolve, reject) => { // Pin the checked address on this connection; preserve hostname for TLS verification and SNI. const request = https.request(url, { method, headers: { ...headers, ...(body ? { "content-length": Buffer.byteLength(body).toString() } : {}) }, agent: false, lookup: ((_hostname: string, options: any, callback: any) => { if (options?.all) callback(null, [address]); else callback(null, address.address, address.family) }) as any, }, response => { const chunks: Buffer[] = []; let length = 0 response.on("data", chunk => { length += chunk.length if (length > 1024 * 1024) request.destroy(new Error("Föderationsantwort zu groß")) else chunks.push(chunk) }) response.on("end", () => { const status = response.statusCode || 502 // Redirects are intentionally not followed, including during discovery. if (status < 200 || status >= 300) { reject(Object.assign(new Error(status >= 500 ? "Partnerinstanz ist nicht erreichbar" : "Partnerinstanz hat die Anfrage abgelehnt"), { statusCode: status })); return } try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))) } catch { reject(Object.assign(new Error("Ungültige Föderationsantwort"), { statusCode: 502 })) } }) }) const deadline = setTimeout(() => request.destroy(new Error("Föderations-Zeitüberschreitung")), 6000) request.on("error", error => { clearTimeout(deadline); reject(Object.assign(error, { statusCode: 503 })) }) request.on("close", () => clearTimeout(deadline)) request.end(body) }) }