368 lines
12 KiB
TypeScript
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,
|
|
});
|
|
};
|
|
}
|