All checks were successful
Build & Push Docker Image / build (push) Successful in 6m38s
224 lines
7.5 KiB
JavaScript
224 lines
7.5 KiB
JavaScript
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<void>} 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),
|
|
};
|
|
}
|