Files
WRNexusJS/packages/encryption/src/http.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

368 lines
12 KiB
TypeScript

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<T> {
method: string;
path: string;
requestId: string;
timestamp: number;
body: T;
}
export interface ReplayStore {
consume(id: string, expiresAt: number): boolean | Promise<boolean>;
}
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<T> {
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<EncryptedHttpEnvelope>;
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<string, number>();
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<T>(
body: T,
input: {
keyring: EncryptionKeyring;
method?: string;
url: string | URL;
requestId?: string;
timestamp?: number;
},
): Promise<EncryptedHttpEnvelope> {
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<T> = {
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<T>(
value: unknown,
input: {
keyring: EncryptionKeyring;
method?: string;
url: string | URL;
maxAgeMs?: number;
replayStore?: ReplayStore;
now?: () => number;
expectedRequestId?: string;
},
): Promise<DecryptedHttpBody<T>> {
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<EncryptedHttpPayload<T>>;
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<T>(
url: string | URL,
body: T,
input: Omit<RequestInit, "body"> & { keyring: EncryptionKeyring; requestId?: string },
): Promise<Request> {
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<T>(
request: Request,
options: EncryptedHttpOptions,
): Promise<DecryptedHttpBody<T>> {
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<T>(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<T>(
body: T,
request: Request,
options: EncryptedHttpOptions & { status?: number; headers?: HeadersInit },
): Promise<Response> {
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<T>(
response: Response,
request: Request,
options: EncryptedHttpOptions,
): Promise<DecryptedHttpBody<T>> {
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<T>(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<TRequest, TResponse>(
url: string | URL,
body: TRequest,
input: Omit<RequestInit, "body"> & EncryptedHttpOptions,
): Promise<TResponse> {
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<TResponse>(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<unknown>;
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,
});
};
}