import type { Context, Middleware } from "@wrnexus/core"; import { open, seal, sealedKeyId, type EncryptionKeyring } from "./keyring.ts"; export const ENCRYPTED_HTTP_CONTENT_TYPE = "application/wrn+json"; export const ENCRYPTED_HTTP_VERSION = "wrn-http-1"; const REQUEST_ID = /^[A-Za-z0-9._:-]{8,128}$/; const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/; export interface EncryptedHttpEnvelope { version: typeof ENCRYPTED_HTTP_VERSION; keyId: string; requestId: string; timestamp: number; ciphertext: string; } interface EncryptedHttpPayload { method: string; path: string; requestId: string; timestamp: number; body: T; } export interface ReplayStore { consume(id: string, expiresAt: number): boolean | Promise; } export interface EncryptedHttpOptions { keyring: EncryptionKeyring; maxAgeMs?: number; maxBodyBytes?: number; replayStore?: ReplayStore; now?: () => number; /** Require the clear request-id header used to bind encrypted responses. Default true. */ requireRequestIdHeader?: boolean; } export interface DecryptedHttpBody { body: T; requestId: string; timestamp: number; keyId: string; } function createRequestId(): string { if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); return [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""); } function normalizePath(input: string | URL): string { const url = input instanceof URL ? input : new URL(input, "https://wrnexus.local"); return `${url.pathname}${url.search}`; } function methodOf(method: string | undefined): string { return (method ?? "POST").toUpperCase(); } function parseEnvelope(value: unknown): EncryptedHttpEnvelope { if (!value || typeof value !== "object") throw new Error("WRN-ENCRYPTION-HTTP-ENVELOPE"); const envelope = value as Partial; if ( envelope.version !== ENCRYPTED_HTTP_VERSION || typeof envelope.keyId !== "string" || !KEY_ID.test(envelope.keyId) || typeof envelope.requestId !== "string" || !REQUEST_ID.test(envelope.requestId) || typeof envelope.timestamp !== "number" || !Number.isFinite(envelope.timestamp) || envelope.timestamp <= 0 || typeof envelope.ciphertext !== "string" || envelope.ciphertext.length < 16 || sealedKeyId(envelope.ciphertext) !== envelope.keyId ) { throw new Error("WRN-ENCRYPTION-HTTP-ENVELOPE"); } return envelope as EncryptedHttpEnvelope; } function requestHeaderId(request: Request, required: boolean): string | undefined { const id = request.headers.get("x-wrn-request-id")?.trim(); if (!id) { if (required) throw new Error("WRN-ENCRYPTION-HTTP-REQUEST-ID"); return undefined; } if (!REQUEST_ID.test(id)) throw new Error("WRN-ENCRYPTION-HTTP-REQUEST-ID"); return id; } export function createMemoryReplayStore(now: () => number = Date.now): ReplayStore { const seen = new Map(); return { consume(id, expiresAt) { const current = now(); for (const [key, expiry] of seen) if (expiry <= current) seen.delete(key); if (seen.has(id)) return false; seen.set(id, expiresAt); return true; }, }; } export async function encryptHttpBody( body: T, input: { keyring: EncryptionKeyring; method?: string; url: string | URL; requestId?: string; timestamp?: number; }, ): Promise { const id = input.requestId ?? createRequestId(); if (!REQUEST_ID.test(id)) throw new TypeError("WRN-ENCRYPTION-HTTP-REQUEST-ID"); const timestamp = input.timestamp ?? Date.now(); if (!Number.isFinite(timestamp) || timestamp <= 0) { throw new TypeError("WRN-ENCRYPTION-HTTP-TIMESTAMP"); } const payload: EncryptedHttpPayload = { method: methodOf(input.method), path: normalizePath(input.url), requestId: id, timestamp, body, }; const ciphertext = await seal(JSON.stringify(payload), input.keyring); return { version: ENCRYPTED_HTTP_VERSION, keyId: input.keyring.active().id, requestId: id, timestamp, ciphertext, }; } export async function decryptHttpBody( value: unknown, input: { keyring: EncryptionKeyring; method?: string; url: string | URL; maxAgeMs?: number; replayStore?: ReplayStore; now?: () => number; expectedRequestId?: string; }, ): Promise> { const envelope = parseEnvelope(value); if (input.expectedRequestId && envelope.requestId !== input.expectedRequestId) { throw new Error("WRN-ENCRYPTION-HTTP-REQUEST-ID"); } const plaintext = await open(envelope.ciphertext, input.keyring); const payload = JSON.parse(plaintext) as Partial>; const now = input.now?.() ?? Date.now(); const maxAgeMs = Math.max(1_000, input.maxAgeMs ?? 5 * 60_000); if ( payload.requestId !== envelope.requestId || payload.timestamp !== envelope.timestamp || payload.method !== methodOf(input.method) || payload.path !== normalizePath(input.url) || !("body" in payload) ) { throw new Error("WRN-ENCRYPTION-HTTP-CONTEXT"); } if (Math.abs(now - envelope.timestamp) > maxAgeMs) { throw new Error("WRN-ENCRYPTION-HTTP-EXPIRED"); } if (input.replayStore) { const accepted = await input.replayStore.consume(envelope.requestId, now + maxAgeMs); if (!accepted) throw new Error("WRN-ENCRYPTION-HTTP-REPLAY"); } return { body: payload.body as T, requestId: envelope.requestId, timestamp: envelope.timestamp, keyId: envelope.keyId, }; } export async function createEncryptedRequest( url: string | URL, body: T, input: Omit & { keyring: EncryptionKeyring; requestId?: string }, ): Promise { const { keyring, requestId, ...requestInit } = input; const method = methodOf(requestInit.method); const envelope = await encryptHttpBody(body, { keyring, method, url, requestId, }); const headers = new Headers(requestInit.headers); headers.set("content-type", ENCRYPTED_HTTP_CONTENT_TYPE); headers.set("accept", ENCRYPTED_HTTP_CONTENT_TYPE); headers.set("x-wrn-request-id", envelope.requestId); return new Request(url, { ...requestInit, method, headers, body: JSON.stringify(envelope), }); } export async function decryptRequest( request: Request, options: EncryptedHttpOptions, ): Promise> { const contentLength = Number(request.headers.get("content-length") ?? "0"); const maxBodyBytes = Math.max(1, options.maxBodyBytes ?? 1_048_576); if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) { throw new Error("WRN-ENCRYPTION-HTTP-BODY-LIMIT"); } const source = await request.text(); if (new TextEncoder().encode(source).byteLength > maxBodyBytes) { throw new Error("WRN-ENCRYPTION-HTTP-BODY-LIMIT"); } const envelope = parseEnvelope(JSON.parse(source)); const headerRequestId = requestHeaderId(request, options.requireRequestIdHeader !== false); if (headerRequestId && headerRequestId !== envelope.requestId) { throw new Error("WRN-ENCRYPTION-HTTP-REQUEST-ID"); } return decryptHttpBody(envelope, { keyring: options.keyring, method: request.method, url: request.url, maxAgeMs: options.maxAgeMs, replayStore: options.replayStore, now: options.now, expectedRequestId: headerRequestId, }); } export async function encryptResponse( body: T, request: Request, options: EncryptedHttpOptions & { status?: number; headers?: HeadersInit }, ): Promise { const originalRequestId = requestHeaderId(request, options.requireRequestIdHeader !== false); const envelope = await encryptHttpBody(body, { keyring: options.keyring, method: request.method, url: request.url, requestId: originalRequestId, }); const headers = new Headers(options.headers); headers.set("content-type", `${ENCRYPTED_HTTP_CONTENT_TYPE}; charset=utf-8`); headers.set("cache-control", "no-store"); headers.set("x-wrn-request-id", envelope.requestId); return new Response(JSON.stringify(envelope), { status: options.status ?? 200, headers }); } export async function decryptEncryptedResponse( response: Response, request: Request, options: EncryptedHttpOptions, ): Promise> { if ( !response.headers.get("content-type")?.toLowerCase().startsWith(ENCRYPTED_HTTP_CONTENT_TYPE) ) { throw new Error("WRN-ENCRYPTION-HTTP-RESPONSE-CONTENT-TYPE"); } const requestId = requestHeaderId(request, options.requireRequestIdHeader !== false); const responseRequestId = response.headers.get("x-wrn-request-id")?.trim(); if (requestId && responseRequestId !== requestId) { throw new Error("WRN-ENCRYPTION-HTTP-REQUEST-ID"); } return decryptHttpBody(await response.json(), { keyring: options.keyring, method: request.method, url: request.url, maxAgeMs: options.maxAgeMs, replayStore: options.replayStore, now: options.now, expectedRequestId: requestId, }); } export async function encryptedFetch( url: string | URL, body: TRequest, input: Omit & EncryptedHttpOptions, ): Promise { const request = await createEncryptedRequest(url, body, input); const response = await fetch(request); if (!response.ok) throw new Error(`WRN-ENCRYPTION-HTTP-RESPONSE: ${response.status}`); const decrypted = await decryptEncryptedResponse(response, request, input); return decrypted.body; } export function encryptedBody(options: EncryptedHttpOptions): Middleware { return async (ctx: Context, next) => { if ( !ctx.req.headers.get("content-type")?.toLowerCase().startsWith(ENCRYPTED_HTTP_CONTENT_TYPE) ) { return Response.json( { ok: false, error: "Encrypted request body required" }, { status: 415 }, ); } try { const result = await decryptRequest(ctx.req, options); ctx.locals.encryptedBody = result.body; ctx.locals.encryptedRequest = result; return next(); } catch (error) { return Response.json( { ok: false, error: error instanceof Error ? error.message : "Invalid encrypted body" }, { status: 400, headers: { "cache-control": "no-store" } }, ); } }; } export function encryptedExchange( options: EncryptedHttpOptions & { encryptResponses?: boolean }, ): Middleware { return async (ctx: Context, next) => { if ( !ctx.req.headers.get("content-type")?.toLowerCase().startsWith(ENCRYPTED_HTTP_CONTENT_TYPE) ) { return Response.json( { ok: false, error: "Encrypted request body required" }, { status: 415, headers: { "cache-control": "no-store" } }, ); } let result: DecryptedHttpBody; try { result = await decryptRequest(ctx.req, options); } catch (error) { return Response.json( { ok: false, error: error instanceof Error ? error.message : "Invalid encrypted exchange" }, { status: 400, headers: { "cache-control": "no-store" } }, ); } ctx.locals.encryptedBody = result.body; ctx.locals.encryptedRequest = result; const response = await next(); if (options.encryptResponses === false || response.status === 204 || response.status === 304) { return response; } if ( response.headers.get("content-type")?.toLowerCase().startsWith(ENCRYPTED_HTTP_CONTENT_TYPE) ) { return response; } const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; const body = contentType.includes("json") ? await response.clone().json() : await response.text(); const headers = new Headers(response.headers); headers.delete("content-length"); headers.delete("content-encoding"); headers.delete("etag"); return encryptResponse(body, ctx.req, { ...options, status: response.status, headers, }); }; }