import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; 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 = path.join(__dirname, 'federation_key.pem'); if (!privateKeyPem && fs.existsSync(keyPath)) { privateKeyPem = fs.readFileSync(keyPath, 'utf8'); } if (!privateKeyPem) { console.log('Generating new Ed25519 federation 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'); console.log(`Saved new federation 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) { console.error('Signature verification error:', e.message); return false; } } /** * Discover a remote Redlight instance's federation API base URL. * Fetches https://{domain}/.well-known/redlight and caches the result. * @param {string} domain * @returns {Promise<{ baseUrl: string, publicKey: string }>} */ export async function discoverInstance(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) { if (e.message.includes('fetch') && wellKnownUrl.startsWith('https://localhost')) { 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: baseUrl.replace('https://localhost', 'http://localhost'), publicKey: data.public_key, cachedAt: Date.now(), }; discoveryCache.set(domain, result); return result; } catch (error) { console.error(`Federation 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), }; }