Files
redlight/server/config/federation.js
Michelle e4001cb33f
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
feat(federation): update key path handling and ensure directory creation for federation keys
2026-03-02 23:01:15 +01:00

162 lines
5.4 KiB
JavaScript

import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { log } from './logger.js';
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;
}
}
/**
* 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) {
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),
};
}