import { createHmac, timingSafeEqual } from 'node:crypto'; import { MemoryTrustedContextReplayBackend, SharedFileTrustedContextReplayBackend, type TrustedContextReplayBackend, } from './TrustedContextReplayBackend'; export const TRUSTED_CONTEXT_HEADER = 'x-soma-request-context'; export const TRUSTED_CONTEXT_SIGNATURE_HEADER = 'x-soma-request-signature'; export const TRUSTED_CONTEXT_SECRET_ENV = 'SOMA_TRUSTED_PROXY_SECRET'; export const TRUSTED_CONTEXT_REPLAY_STORE_ENV = 'SOMA_TRUSTED_CONTEXT_REPLAY_STORE'; const MAX_AGE_MS = 5 * 60_000; const MAX_FUTURE_SKEW_MS = 30_000; export interface TrustedRequestContext { /** Unix epoch milliseconds. */ issuedAt: number; /** Unique per request; a verified nonce is accepted only once. */ nonce: string; clientIp?: string; userId?: string; orgId?: string; memberId?: string; envLabel?: string; } let replayBackend: TrustedContextReplayBackend = new MemoryTrustedContextReplayBackend(); let configuredReplayStore: string | undefined; let injectedReplayBackend = false; export function signTrustedRequestContext( context: TrustedRequestContext, secret: string, ): { value: string; signature: string } { const value = toBase64Url(JSON.stringify(context)); return { value, signature: signatureFor(value, secret) }; } export async function resolveTrustedRequestContext( req: { headers?: { get(name: string): string | null } }, options: { now?: number; consumeNonce?: boolean } = {}, ): Promise { const secret = process.env[TRUSTED_CONTEXT_SECRET_ENV]?.trim() ?? ''; if (!secret) return null; const value = req.headers?.get(TRUSTED_CONTEXT_HEADER)?.trim() ?? ''; const signature = req.headers?.get(TRUSTED_CONTEXT_SIGNATURE_HEADER)?.trim() ?? ''; if (!value || !signature || !safeEqual(signatureFor(value, secret), signature)) return null; let parsed: unknown; try { parsed = JSON.parse(fromBase64Url(value)); } catch { return null; } const context = normalizeContext(parsed); if (!context) return null; const now = options.now ?? Date.now(); if (context.issuedAt - now > MAX_FUTURE_SKEW_MS) return null; const expiresAt = context.issuedAt + MAX_AGE_MS; if (expiresAt <= now) return null; if (options.consumeNonce !== false) { const backend = currentReplayBackend(); try { if (!(await backend.claim(context.nonce, expiresAt, now))) return null; } catch { // 防重放状态不可用时不能把签名上下文降级成“仍可信”;按匿名处理。 return null; } } return context; } export function trustedActorKey(context: TrustedRequestContext | null): string | null { if (!context) return null; if (context.orgId && context.memberId) return `org:${context.orgId}:member:${context.memberId}`; if (context.userId) return `user:${context.userId}`; if (context.clientIp) return `ip:${context.clientIp}`; return null; } export function setTrustedContextReplayBackend(backend: TrustedContextReplayBackend): void { replayBackend = backend; configuredReplayStore = undefined; injectedReplayBackend = true; } export async function resetTrustedRequestContextReplayCache(): Promise { await replayBackend.reset?.(); replayBackend = new MemoryTrustedContextReplayBackend(); configuredReplayStore = undefined; injectedReplayBackend = false; } function currentReplayBackend(): TrustedContextReplayBackend { const store = process.env[TRUSTED_CONTEXT_REPLAY_STORE_ENV]?.trim() || undefined; if (injectedReplayBackend) return replayBackend; if (store === configuredReplayStore) return replayBackend; configuredReplayStore = store; replayBackend = store ? new SharedFileTrustedContextReplayBackend(store) : new MemoryTrustedContextReplayBackend(); return replayBackend; } function normalizeContext(value: unknown): TrustedRequestContext | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const raw = value as Record; if (!Number.isSafeInteger(raw.issuedAt)) return null; const nonce = requiredString(raw.nonce, 128); if (!nonce) return null; const context: TrustedRequestContext = { issuedAt: raw.issuedAt as number, nonce }; for (const key of ['clientIp', 'userId', 'orgId', 'memberId', 'envLabel'] as const) { if (raw[key] === undefined) continue; const normalized = requiredString(raw[key], 200); if (!normalized) return null; context[key] = normalized; } if ((context.orgId && !context.memberId) || (!context.orgId && context.memberId)) return null; return context; } function requiredString(value: unknown, max: number): string { if (typeof value !== 'string') return ''; const normalized = value.trim(); return normalized !== '' && normalized.length <= max ? normalized : ''; } function signatureFor(value: string, secret: string): string { return createHmac('sha256', secret).update(value, 'utf8').digest('base64url'); } function safeEqual(expected: string, actual: string): boolean { const left = Buffer.from(expected, 'utf8'); const right = Buffer.from(actual, 'utf8'); return left.length === right.length && timingSafeEqual(left, right); } function toBase64Url(value: string): string { return Buffer.from(value, 'utf8').toString('base64url'); } function fromBase64Url(value: string): string { return Buffer.from(value, 'base64url').toString('utf8'); } export type { TrustedContextReplayBackend } from './TrustedContextReplayBackend'; export { MemoryTrustedContextReplayBackend, SharedFileTrustedContextReplayBackend, } from './TrustedContextReplayBackend';