import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { log } from './logger.js'; import dns from 'dns'; import net from 'net'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const FEDERATION_DOMAIN = process.env.FEDERATION_DOMAIN || ''; let privateKeyPem = process.env.FEDERATION_PRIVATE_KEY || ''; let publicKeyPem = ''; // Load or generate Ed25519 keys if (FEDERATION_DOMAIN) { const keyPath = process.env.FEDERATION_KEY_PATH || '/app/keys/federation_key.pem'; const keyDir = path.dirname(keyPath); if (!fs.existsSync(keyDir)) { fs.mkdirSync(keyDir, { recursive: true }); } if (!privateKeyPem && fs.existsSync(keyPath)) { privateKeyPem = fs.readFileSync(keyPath, 'utf8'); } if (!privateKeyPem) { log.federation.info('Generating new Ed25519 key pair...'); const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', { publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); privateKeyPem = privateKey; fs.writeFileSync(keyPath, privateKeyPem, 'utf8'); log.federation.info(`Saved new private key to ${keyPath}`); } // Derive public key from the loaded private key const currentPrivateKey = crypto.createPrivateKey(privateKeyPem); publicKeyPem = crypto.createPublicKey(currentPrivateKey).export({ type: 'spki', format: 'pem' }); } // Instance discovery cache (domain → { baseUrl, publicKey, cachedAt }) const discoveryCache = new Map(); const DISCOVERY_TTL_MS = 5 * 60 * 1000; // 5 minutes /** * Get this instance's federation domain. */ export function getFederationDomain() { return FEDERATION_DOMAIN; } /** * Get this instance's Ed25519 public key (PEM format). */ export function getPublicKey() { return publicKeyPem; } /** * Check if federation is configured on this instance. */ export function isFederationEnabled() { return !!(FEDERATION_DOMAIN && privateKeyPem); } /** * Ed25519 sign a JSON payload. * @param {object} payload * @returns {string} base64 signature */ export function signPayload(payload) { if (!privateKeyPem) throw new Error("Federation private key not available"); const data = Buffer.from(JSON.stringify(payload)); return crypto.sign(null, data, privateKeyPem).toString('base64'); } /** * Verify an Ed25519 signature against a JSON payload using a remote public key. * @param {object} payload * @param {string} signature base64 signature * @param {string} remotePublicKeyPem * @returns {boolean} */ export function verifyPayload(payload, signature, remotePublicKeyPem) { if (!remotePublicKeyPem || !signature) return false; try { const data = Buffer.from(JSON.stringify(payload)); return crypto.verify(null, data, remotePublicKeyPem, Buffer.from(signature, 'base64')); } catch (e) { log.federation.error(`Signature verification error: ${e.message}`); return false; } } /** * Check if a domain resolves to a private/internal IP address (SSRF protection). * Blocks RFC 1918, loopback, link-local, and cloud metadata IPs. * @param {string} domain * @returns {Promise} throws if domain resolves to a blocked IP */ async function assertPublicDomain(domain) { // Allow localhost only in development if (domain === 'localhost' || domain === '127.0.0.1' || domain === '::1') { if (process.env.NODE_ENV === 'production') { throw new Error('Federation to localhost is blocked in production'); } return; // allow in dev } // If domain is a raw IP, check it directly if (net.isIP(domain)) { if (isPrivateIP(domain)) { throw new Error(`Federation blocked: ${domain} resolves to a private IP`); } return; } // Resolve domain and check all resulting IPs const { resolve4, resolve6 } = dns.promises; const ips = []; try { ips.push(...await resolve4(domain)); } catch {} try { ips.push(...await resolve6(domain)); } catch {} if (ips.length === 0) { throw new Error(`Federation blocked: could not resolve ${domain}`); } for (const ip of ips) { if (isPrivateIP(ip)) { throw new Error(`Federation blocked: ${domain} resolves to a private IP (${ip})`); } } } function isPrivateIP(ip) { // IPv4 private ranges if (/^10\./.test(ip)) return true; if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true; if (/^192\.168\./.test(ip)) return true; if (/^127\./.test(ip)) return true; if (/^0\./.test(ip)) return true; if (/^169\.254\./.test(ip)) return true; // link-local if (ip === '::1' || ip === '::' || ip.startsWith('fe80:') || ip.startsWith('fc') || ip.startsWith('fd')) return true; return false; } /** * Discover a remote Redlight instance's federation API base URL. * Fetches https://{domain}/.well-known/redlight and caches the result. * Includes SSRF protection: blocks private/internal IPs. * @param {string} domain * @returns {Promise<{ baseUrl: string, publicKey: string }>} */ export async function discoverInstance(domain) { // SSRF protection: validate domain doesn't resolve to internal IP await assertPublicDomain(domain); const cached = discoveryCache.get(domain); if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) { return cached; } const wellKnownUrl = `https://${domain}/.well-known/redlight`; const TIMEOUT_MS = 10_000; // 10 seconds try { let response; try { response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) }); } catch (e) { // HTTP fallback only allowed in development for localhost if (e.message.includes('fetch') && domain === 'localhost' && process.env.NODE_ENV !== 'production') { response = await fetch(`http://${domain}/.well-known/redlight`, { signal: AbortSignal.timeout(TIMEOUT_MS) }); } else throw e; } if (!response.ok) { throw new Error(`Discovery failed: HTTP ${response.status}`); } const data = await response.json(); if (!data.public_key) { throw new Error(`Remote instance at ${domain} did not provide a public key`); } const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`; const result = { baseUrl: (domain === 'localhost' && process.env.NODE_ENV !== 'production') ? baseUrl.replace('https://localhost', 'http://localhost') : baseUrl, publicKey: data.public_key, cachedAt: Date.now(), }; discoveryCache.set(domain, result); return result; } catch (error) { log.federation.error(`Discovery failed for ${domain}: ${error.message}`); throw new Error(`Could not discover Redlight instance at ${domain}: ${error.message}`); } } /** * Parse a federated address like "username@domain.com". * @param {string} address * @returns {{ username: string, domain: string | null }} */ export function parseAddress(address) { if (!address) return { username: address, domain: null }; // Accept both @user@domain (Mastodon-style) and user@domain const normalized = address.startsWith('@') ? address.slice(1) : address; if (!normalized.includes('@')) { return { username: normalized, domain: null }; } const atIndex = normalized.lastIndexOf('@'); return { username: normalized.substring(0, atIndex), domain: normalized.substring(atIndex + 1), }; }