Files
FEDEO/backend/src/modules/collaboration/transport.ts

106 lines
7.1 KiB
TypeScript

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<string, string>
export type Transport = (url: string, method: "GET" | "POST", headers: Headers, body?: string) => Promise<any>
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<typeof setTimeout>
const addresses = await Promise.race([
lookup(url.hostname, { all: true }),
new Promise<never>((_, 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)
})
}