Files
backspace/packages/server/src/utils/federationAuth.ts
T
Jannis Braun 3c7bb02901 fix(dm): ownership transfer divergence after back-and-forth — canonicalize ownerHomeInstance + normalize authority checks
Manual ownership transfers between two federated instances diverged because
`dm_channels.ownerHomeInstance` was stored as a BARE host (`orbit.ddns.net`)
for federated owners — via `transferGroupDmOwnership` copying `users.homeInstance`
verbatim — while `sourceInstance` always arrives as a full URL on the wire.
`processOwnershipTransferEvent` and `processMemberRemoveEvent` then compared the
two with strict equality and rejected legitimate inbound events as
`unauthorized_source`, keeping ownership permanently divergent across peers.
Live DB inspection on the two test instances confirmed both rows (nova + orbit)
had a BARE `owner_home_instance`, matching the bug report exactly.

Three compounding fixes:

1. Receiver authority checks now compare via `normalizeOriginForCompare` so
   legacy bare-vs-full rows accept legitimate transfers (and kicks).
2. New `canonicalizeHomeInstance` helper in `federationAuth.ts`; every write
   site that persists `ownerHomeInstance` (`transferGroupDmOwnership`, group DM
   creation, lazy federation in member-add, `processMemberAddEvent` bootstrap,
   `processOwnershipTransferEvent` receiver storage) routes through it. Full URL
   is the canonical storage form, matching how `sourceInstance` arrives.
3. `dm_owner_updated` WS event extended with optional `newOwnerHomeUserId` and
   `newOwnerHomeInstance` fields. Client `updateDmOwner` writes them when
   present and leaves existing values untouched otherwise (legacy-server safe).
   Without this, `getOwnerInstanceForDm` returned the previous owner's home
   after a successful WS broadcast, routing the next owner-only op to the wrong
   instance.

Coverage: new `federation.ownershipTransfer.test.ts` (7 receiver tests including
the headline bare-vs-full regression and the dedup replay guard); new bare-vs-full
case in `federation.kick.test.ts`; two new client-side cases in
`groupDm.ownerRouting.test.ts` covering both the extended-payload write path and
the legacy-server passthrough. Tests: 1053 server + 364 web, all green.

Specs updated: `dm-system.md` historical bugs + frontend handler table + WS
state-change events table; `federation.md` `ownership_transfer` receiver flow;
`websocket.md` event-fields table.
2026-05-10 22:38:03 +02:00

250 lines
8.9 KiB
TypeScript

import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { config } from '../config.js';
const DEFAULT_MAX_AGE_MS = 15 * 60 * 1000; // 15 minutes
/**
* Generate a 256-bit random hex secret for HMAC signing.
*/
export function generateHmacSecret(): string {
return randomBytes(32).toString('hex');
}
/**
* Compute HMAC-SHA256 of `${timestamp}.${nonce}.${body}` (or `${timestamp}.${body}` when nonce
* is absent, for backward compat with legacy peers) using the given secret.
* Returns a hex-encoded signature string.
*/
export function signRequest(body: string, secret: string, timestamp: number, nonce: string | null = null): string {
const payload = nonce ? `${timestamp}.${nonce}.${body}` : `${timestamp}.${body}`;
return createHmac('sha256', secret)
.update(payload)
.digest('hex');
}
/**
* Verify a federation request signature.
*
* Returns true only if:
* - The timestamp is within maxAgeMs of Date.now()
* - The HMAC-SHA256 of `${timestamp}.${nonce}.${body}` (or `${timestamp}.${body}` when nonce
* is absent) matches the provided signature
*
* Uses constant-time comparison to prevent timing attacks.
*/
export function verifySignature(
body: string,
signature: string,
secret: string,
timestamp: number,
nonce: string | null = null,
maxAgeMs: number = DEFAULT_MAX_AGE_MS,
): boolean {
// Guard against empty or obviously invalid inputs
if (!body && body !== '') return false;
if (!signature || !secret) return false;
// Validate timestamp is within the acceptable window
const age = Math.abs(Date.now() - timestamp);
if (age > maxAgeMs) return false;
const expected = signRequest(body, secret, timestamp, nonce);
// Convert both to Buffers for constant-time comparison
const expectedBuf = Buffer.from(expected, 'hex');
const actualBuf = Buffer.from(signature, 'hex');
// timingSafeEqual requires identical lengths; mismatched lengths mean invalid
if (expectedBuf.length !== actualBuf.length) return false;
return timingSafeEqual(expectedBuf, actualBuf);
}
/** Grace period during which both old and new secrets are accepted for verification. */
export const ROTATION_GRACE_PERIOD_MS = 15 * 60 * 1000; // 15 minutes
/**
* Verify a federation request signature against a peer's secrets.
*
* During secret rotation (when pendingHmacSecret is set and within the grace period),
* accepts signatures made with either the primary or pending secret.
*/
export function verifyPeerSignature(
body: string,
signature: string,
timestamp: number,
nonce: string | null,
peer: {
hmacSecret: string;
pendingHmacSecret: string | null;
secretRotationAt: number | null;
},
): boolean {
// Try primary secret first
if (verifySignature(body, signature, peer.hmacSecret, timestamp, nonce)) return true;
// During grace period, try pending secret
if (peer.pendingHmacSecret && peer.secretRotationAt) {
const elapsed = Date.now() - peer.secretRotationAt;
if (elapsed <= ROTATION_GRACE_PERIOD_MS) {
return verifySignature(body, signature, peer.pendingHmacSecret, timestamp, nonce);
}
}
return false;
}
/**
* Build the HTTP headers required for an outbound federation request.
*
* Returned headers:
* X-Federation-Signature: sha256=<hmac-hex>
* X-Federation-Origin: <origin>
* X-Federation-Timestamp: <unix-ms>
* X-Federation-Nonce: <uuid-v4>
* Content-Type: application/json
*/
export function buildFederationHeaders(
body: string,
secret: string,
origin: string,
): Record<string, string> {
const timestamp = Date.now();
const nonce = randomUUID();
const sig = signRequest(body, secret, timestamp, nonce);
return {
'X-Federation-Signature': `sha256=${sig}`,
'X-Federation-Origin': origin,
'X-Federation-Timestamp': String(timestamp),
'X-Federation-Nonce': nonce,
'Content-Type': 'application/json',
};
}
/**
* Parse and validate the federation headers from an inbound request.
*
* Returns the extracted { origin, timestamp, signature, nonce } on success,
* or null if any required header is missing or malformed.
* `nonce` is null when the peer did not send X-Federation-Nonce (legacy peers).
*
* Signature header format: `sha256=<hex>`
*/
export function parseFederationHeaders(
headers: Record<string, string | string[] | undefined>,
): { origin: string; timestamp: number; signature: string; nonce: string | null } | null {
// Helper to extract a single string value from a potentially multi-value header
const getHeader = (name: string): string | null => {
const value = headers[name];
if (value === undefined || value === null) return null;
if (Array.isArray(value)) {
return value.length > 0 ? (value[0] ?? null) : null;
}
return value;
};
const rawSignature = getHeader('x-federation-signature') ?? getHeader('X-Federation-Signature');
const rawOrigin = getHeader('x-federation-origin') ?? getHeader('X-Federation-Origin');
const rawTimestamp = getHeader('x-federation-timestamp') ?? getHeader('X-Federation-Timestamp');
if (!rawSignature || !rawOrigin || !rawTimestamp) return null;
// Signature must be in the format "sha256=<hex>"
const sigPrefix = 'sha256=';
if (!rawSignature.startsWith(sigPrefix)) return null;
const signature = rawSignature.slice(sigPrefix.length);
if (!signature || !/^[0-9a-f]+$/i.test(signature)) return null;
const timestampMs = Number(rawTimestamp);
if (!Number.isFinite(timestampMs) || timestampMs <= 0) return null;
const origin = rawOrigin.trim();
if (!origin) return null;
const rawNonce = getHeader('x-federation-nonce') ?? getHeader('X-Federation-Nonce');
const nonce = rawNonce && typeof rawNonce === 'string' && rawNonce.trim() ? rawNonce.trim() : null;
// Reject nonces that are unreasonably long (UUID v4 is 36 chars)
if (nonce && nonce.length > 64) return null;
return { origin, timestamp: timestampMs, signature, nonce };
}
/**
* Return the canonical origin URL for this instance.
*
* Resolution order:
* 1. `PUBLIC_ORIGIN` env (verbatim, trailing slash stripped) — overrides everything.
* Used by integration test harnesses that bind to 127.0.0.1:<ephemeral> and by
* reverse-proxy / dev-without-TLS setups where federation must advertise an
* explicit origin distinct from the public DOMAIN. The override is the URL
* transport layer; identity (homeInstance) still derives from DOMAIN.
* 2. `https://${DOMAIN}` — production default.
* 3. `http://localhost:${PORT}` — dev fallback when DOMAIN is unset.
*/
export function getOurOrigin(): string {
const override = config.publicOrigin;
if (override && override.trim()) {
return override.trim().replace(/\/$/, '');
}
if (config.domain) {
return `https://${config.domain}`;
}
return `http://localhost:${config.port}`;
}
/**
* Canonicalize a homeInstance / origin value for comparison.
*
* The homeInstance column is stored in two shapes depending on the code path
* that wrote it:
* - `auth.ts` registration writes the bare host the client sent (e.g. `nova.ddns.net`).
* - `resolveOrCreateReplicatedUser` writes the bare host (`extractDomain(...)`).
* - `getOurOrigin()` returns the full URL (`https://nova.ddns.net`).
*
* All federation authority / self-friend comparisons must route through this
* helper to avoid false-fires across the dual storage convention. Returns the
* lowercased host (with optional :port), no scheme, no trailing slash.
*
* NOTE: A federation-wide audit + canonical-storage migration is tracked
* separately. This helper papers over the inconsistency at comparison sites.
*/
export function normalizeOriginForCompare(value: string | null | undefined): string | null {
if (!value) return null;
let s = value.trim();
if (!s) return null;
// Strip scheme if present
s = s.replace(/^https?:\/\//i, '');
// Strip trailing slashes
s = s.replace(/\/+$/, '');
if (!s) return null;
return s.toLowerCase();
}
/**
* Canonicalize a homeInstance value to a full origin URL
* (e.g. `https://nova.ddns.net`) for storage on columns whose authority
* checks compare against `sourceInstance` (which is always a full URL).
*
* Accepts bare host (`nova.ddns.net`), scheme-prefixed (`https://...`), or
* `null` / empty / whitespace (returns `null`). `localhost`-style values keep
* `http://` if already present; otherwise the canonical default is `https://`.
*
* Use this at every write site that persists `dm_channels.ownerHomeInstance`
* so that S2S authority checks aren't broken by a bare-vs-full mismatch.
* Read paths should still normalize via `normalizeOriginForCompare` for
* defensive parity with legacy rows.
*/
export function canonicalizeHomeInstance(value: string | null | undefined): string | null {
if (!value) return null;
const s = value.trim();
if (!s) return null;
if (/^https?:\/\//i.test(s)) {
// Strip trailing slashes only — preserve the explicit scheme.
return s.replace(/\/+$/, '');
}
return `https://${s.replace(/\/+$/, '')}`;
}