feat(federation): add nonce to HMAC sign/verify and header build/parse (FED-008)
- signRequest now accepts optional nonce; payload becomes `${timestamp}.${nonce}.${body}` when present, falling back to `${timestamp}.${body}` for legacy peers
- verifySignature accepts matching nonce parameter and passes it through
- buildFederationHeaders generates a UUID nonce per request and includes X-Federation-Nonce header
- parseFederationHeaders extracts X-Federation-Nonce into nonce field (null when absent)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
|
||||||
const DEFAULT_MAX_AGE_MS = 15 * 60 * 1000; // 15 minutes
|
const DEFAULT_MAX_AGE_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
@@ -11,12 +11,14 @@ export function generateHmacSecret(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute HMAC-SHA256 of `${timestamp}.${body}` using the given secret.
|
* 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.
|
* Returns a hex-encoded signature string.
|
||||||
*/
|
*/
|
||||||
export function signRequest(body: string, secret: string, timestamp: number): 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)
|
return createHmac('sha256', secret)
|
||||||
.update(`${timestamp}.${body}`)
|
.update(payload)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +27,8 @@ export function signRequest(body: string, secret: string, timestamp: number): st
|
|||||||
*
|
*
|
||||||
* Returns true only if:
|
* Returns true only if:
|
||||||
* - The timestamp is within maxAgeMs of Date.now()
|
* - The timestamp is within maxAgeMs of Date.now()
|
||||||
* - The HMAC-SHA256 of `${timestamp}.${body}` matches the provided signature
|
* - 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.
|
* Uses constant-time comparison to prevent timing attacks.
|
||||||
*/
|
*/
|
||||||
@@ -34,6 +37,7 @@ export function verifySignature(
|
|||||||
signature: string,
|
signature: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
|
nonce: string | null = null,
|
||||||
maxAgeMs: number = DEFAULT_MAX_AGE_MS,
|
maxAgeMs: number = DEFAULT_MAX_AGE_MS,
|
||||||
): boolean {
|
): boolean {
|
||||||
// Guard against empty or obviously invalid inputs
|
// Guard against empty or obviously invalid inputs
|
||||||
@@ -44,7 +48,7 @@ export function verifySignature(
|
|||||||
const age = Math.abs(Date.now() - timestamp);
|
const age = Math.abs(Date.now() - timestamp);
|
||||||
if (age > maxAgeMs) return false;
|
if (age > maxAgeMs) return false;
|
||||||
|
|
||||||
const expected = signRequest(body, secret, timestamp);
|
const expected = signRequest(body, secret, timestamp, nonce);
|
||||||
|
|
||||||
// Convert both to Buffers for constant-time comparison
|
// Convert both to Buffers for constant-time comparison
|
||||||
const expectedBuf = Buffer.from(expected, 'hex');
|
const expectedBuf = Buffer.from(expected, 'hex');
|
||||||
@@ -63,6 +67,7 @@ export function verifySignature(
|
|||||||
* X-Federation-Signature: sha256=<hmac-hex>
|
* X-Federation-Signature: sha256=<hmac-hex>
|
||||||
* X-Federation-Origin: <origin>
|
* X-Federation-Origin: <origin>
|
||||||
* X-Federation-Timestamp: <unix-ms>
|
* X-Federation-Timestamp: <unix-ms>
|
||||||
|
* X-Federation-Nonce: <uuid-v4>
|
||||||
* Content-Type: application/json
|
* Content-Type: application/json
|
||||||
*/
|
*/
|
||||||
export function buildFederationHeaders(
|
export function buildFederationHeaders(
|
||||||
@@ -71,12 +76,14 @@ export function buildFederationHeaders(
|
|||||||
origin: string,
|
origin: string,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const sig = signRequest(body, secret, timestamp);
|
const nonce = randomUUID();
|
||||||
|
const sig = signRequest(body, secret, timestamp, nonce);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'X-Federation-Signature': `sha256=${sig}`,
|
'X-Federation-Signature': `sha256=${sig}`,
|
||||||
'X-Federation-Origin': origin,
|
'X-Federation-Origin': origin,
|
||||||
'X-Federation-Timestamp': String(timestamp),
|
'X-Federation-Timestamp': String(timestamp),
|
||||||
|
'X-Federation-Nonce': nonce,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -84,14 +91,15 @@ export function buildFederationHeaders(
|
|||||||
/**
|
/**
|
||||||
* Parse and validate the federation headers from an inbound request.
|
* Parse and validate the federation headers from an inbound request.
|
||||||
*
|
*
|
||||||
* Returns the extracted { origin, timestamp, signature } on success,
|
* Returns the extracted { origin, timestamp, signature, nonce } on success,
|
||||||
* or null if any required header is missing or malformed.
|
* 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>`
|
* Signature header format: `sha256=<hex>`
|
||||||
*/
|
*/
|
||||||
export function parseFederationHeaders(
|
export function parseFederationHeaders(
|
||||||
headers: Record<string, string | string[] | undefined>,
|
headers: Record<string, string | string[] | undefined>,
|
||||||
): { origin: string; timestamp: number; signature: string } | null {
|
): { origin: string; timestamp: number; signature: string; nonce: string | null } | null {
|
||||||
// Helper to extract a single string value from a potentially multi-value header
|
// Helper to extract a single string value from a potentially multi-value header
|
||||||
const getHeader = (name: string): string | null => {
|
const getHeader = (name: string): string | null => {
|
||||||
const value = headers[name];
|
const value = headers[name];
|
||||||
@@ -121,7 +129,10 @@ export function parseFederationHeaders(
|
|||||||
const origin = rawOrigin.trim();
|
const origin = rawOrigin.trim();
|
||||||
if (!origin) return null;
|
if (!origin) return null;
|
||||||
|
|
||||||
return { origin, timestamp: timestampMs, signature };
|
const rawNonce = getHeader('x-federation-nonce') ?? getHeader('X-Federation-Nonce');
|
||||||
|
const nonce = rawNonce && typeof rawNonce === 'string' && rawNonce.trim() ? rawNonce.trim() : null;
|
||||||
|
|
||||||
|
return { origin, timestamp: timestampMs, signature, nonce };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user