release: WRNexusJS 0.8.0
This commit is contained in:
@@ -1,80 +1,67 @@
|
||||
# @wrnexus/encryption
|
||||
|
||||
> Dependency-free crypto helpers for WrNexus: authenticated symmetric encryption (AES-256-GCM), hashing, and HMAC signing.
|
||||
Authenticated encryption, hashing, HMAC, key rotation, and optional encrypted HTTP exchanges for WRNexusJS.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
## Core helpers
|
||||
|
||||
## Overview
|
||||
- `generateKey()` — random 256-bit AES key encoded as base64.
|
||||
- `deriveKey(password, salt)` — PBKDF2-derived AES key.
|
||||
- `encrypt(plaintext, key)` / `decrypt(payload, key)` — AES-256-GCM.
|
||||
- `sha256(data)` — SHA-256 digest.
|
||||
- `hmacSign(data, secret)` / `hmacVerify(...)` — HMAC-SHA256.
|
||||
- `createKeyring(keys)` — active/previous key management.
|
||||
- `seal()` / `open()` — versioned ciphertext with key ID.
|
||||
|
||||
This package provides small, focused cryptographic primitives for server-side use: encrypting secrets/tokens/database fields at rest with AES-256-GCM, deriving keys from passwords via PBKDF2, computing SHA-256 digests, and signing/verifying payloads with HMAC-SHA256. It is built entirely on the standard **Web Crypto API** (`crypto.subtle`) plus `btoa`/`atob` and `TextEncoder`/`TextDecoder` — no third-party dependencies. Reach for it whenever you need to protect sensitive values or verify webhook signatures. All functions are `async` (Web Crypto is promise-based).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/encryption
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
All keys are exchanged as **base64 strings** and all digests/signatures as **hex strings**.
|
||||
|
||||
| Export | Signature | Description |
|
||||
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `generateKey` | `() => Promise<string>` | Generate a random 256-bit AES key, base64-encoded. Store it as a secret. |
|
||||
| `deriveKey` | `(password: string, salt: string) => Promise<string>` | Derive a base64 AES-256 key from a password + salt using PBKDF2 (100,000 iterations, SHA-256). |
|
||||
| `encrypt` | `(plaintext: string, key: string) => Promise<string>` | AES-256-GCM encrypt a string. Returns base64 of `iv(12 bytes) ‖ ciphertext+tag`. A fresh random IV is used each call. |
|
||||
| `decrypt` | `(payload: string, key: string) => Promise<string>` | Decrypt a value produced by `encrypt`. Throws if the key is wrong or the data was tampered with. |
|
||||
| `sha256` | `(data: string) => Promise<string>` | SHA-256 hex digest of a string (e.g. content hashing, dedup keys). |
|
||||
| `hmacSign` | `(data: string, secret: string) => Promise<string>` | HMAC-SHA256 hex signature of `data` with `secret` (e.g. signing webhooks). |
|
||||
| `hmacVerify` | `(data: string, secret: string, signature: string) => Promise<boolean>` | Constant-time verify of an HMAC-SHA256 hex signature. |
|
||||
|
||||
Notes:
|
||||
|
||||
- `generateKey` produces a 32-byte (256-bit) key via `crypto.getRandomValues`.
|
||||
- `encrypt`/`decrypt` require a base64-encoded 256-bit key; anything else throws `"Encryption key must be a base64 256-bit key"`.
|
||||
- `decrypt` throws `"Invalid ciphertext"` if the payload is shorter than the 12-byte IV, and the underlying Web Crypto call throws on any authentication (tag) mismatch.
|
||||
- `hmacVerify` compares in constant time (length check plus XOR accumulation) to avoid timing leaks.
|
||||
|
||||
## Usage
|
||||
|
||||
Symmetric encryption of a secret at rest:
|
||||
## Encrypted HTTP envelope
|
||||
|
||||
```ts
|
||||
import { generateKey, encrypt, decrypt } from "@wrnexus/encryption";
|
||||
import {
|
||||
createEncryptedRequest,
|
||||
createKeyring,
|
||||
createMemoryReplayStore,
|
||||
decryptEncryptedResponse,
|
||||
encryptedExchange,
|
||||
} from "@wrnexus/encryption";
|
||||
|
||||
const key = await generateKey(); // store this safely (env/secret manager)
|
||||
const keyring = createKeyring([{ id: "2026-08", secret: process.env.API_BODY_KEY!, active: true }]);
|
||||
|
||||
const box = await encrypt("card #1234", key); // opaque base64 string, safe to persist
|
||||
const plain = await decrypt(box, key); // "card #1234"
|
||||
const replayStore = createMemoryReplayStore();
|
||||
|
||||
// Server middleware.
|
||||
app.use(
|
||||
encryptedExchange({
|
||||
keyring,
|
||||
replayStore,
|
||||
maxAgeMs: 60_000,
|
||||
maxBodyBytes: 1_048_576,
|
||||
}),
|
||||
);
|
||||
|
||||
// Controlled service/native client.
|
||||
const request = await createEncryptedRequest(
|
||||
"https://api.example.com/private/report",
|
||||
{ reportId: "report-1" },
|
||||
{ method: "POST", keyring },
|
||||
);
|
||||
const response = await fetch(request);
|
||||
const result = await decryptEncryptedResponse(response, request, { keyring });
|
||||
```
|
||||
|
||||
Deriving a key from a user password instead of a random key:
|
||||
The envelope binds authenticated ciphertext to:
|
||||
|
||||
```ts
|
||||
import { deriveKey, encrypt } from "@wrnexus/encryption";
|
||||
- HTTP method
|
||||
- URL path and query
|
||||
- request ID
|
||||
- timestamp and expiry window
|
||||
- encryption key ID
|
||||
- optional replay-store consumption
|
||||
|
||||
const key = await deriveKey("correct horse battery staple", "per-user-salt");
|
||||
const box = await encrypt("secret note", key);
|
||||
```
|
||||
`encryptedBody()` decrypts request bodies only. `encryptedExchange()` also encrypts successful downstream responses while allowing application exceptions to propagate normally. `encryptedFetch()` provides a convenient controlled-client call.
|
||||
|
||||
Hashing and webhook signature verification:
|
||||
## Security boundary
|
||||
|
||||
```ts
|
||||
import { sha256, hmacSign, hmacVerify } from "@wrnexus/encryption";
|
||||
Encrypted HTTP bodies **do not replace TLS/HTTPS**. Always use HTTPS.
|
||||
|
||||
const digest = await sha256("some content"); // 64-char hex string
|
||||
This layer is appropriate for service-to-service traffic, native/mobile applications, controlled agents, and selected fields protected with server-managed keys. It cannot conceal data from an end user when browser JavaScript receives the decryption key. Never ship a long-lived server encryption key to a browser.
|
||||
|
||||
const signature = await hmacSign(rawBody, webhookSecret);
|
||||
const ok = await hmacVerify(rawBody, webhookSecret, incomingSignatureHeader);
|
||||
if (!ok) throw new Error("Invalid webhook signature");
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Relies on the Web Crypto API (`crypto.subtle`, `crypto.getRandomValues`) and the global `btoa`/`atob`, `TextEncoder`/`TextDecoder` — all available in Bun's runtime.
|
||||
- **No dependencies.** The package has an empty dependency set; nothing is bundled beyond standard runtime APIs.
|
||||
- Algorithms: AES-256-GCM (encryption), PBKDF2 with 100k SHA-256 iterations (key derivation), SHA-256 (digest), HMAC-SHA256 (signing).
|
||||
- Keep generated/derived keys and HMAC secrets out of source control; treat them as first-class secrets.
|
||||
Use a shared replay store such as Redis in multi-instance deployments. The memory replay store is process-local.
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"description": "Authenticated encryption, key rotation, hashing, and optional application-layer encrypted HTTP envelopes.",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "bun run typecheck && bun run test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -140,3 +140,23 @@ export async function deriveKey(password: string, salt: string): Promise<string>
|
||||
}
|
||||
export { createKeyring, seal, open, sealedKeyId, needsRotation } from "./keyring.ts";
|
||||
export type { EncryptionKey, EncryptionKeyring } from "./keyring.ts";
|
||||
export {
|
||||
ENCRYPTED_HTTP_CONTENT_TYPE,
|
||||
ENCRYPTED_HTTP_VERSION,
|
||||
createMemoryReplayStore,
|
||||
encryptHttpBody,
|
||||
decryptHttpBody,
|
||||
createEncryptedRequest,
|
||||
decryptRequest,
|
||||
encryptResponse,
|
||||
decryptEncryptedResponse,
|
||||
encryptedFetch,
|
||||
encryptedBody,
|
||||
encryptedExchange,
|
||||
} from "./http.ts";
|
||||
export type {
|
||||
EncryptedHttpEnvelope,
|
||||
EncryptedHttpOptions,
|
||||
DecryptedHttpBody,
|
||||
ReplayStore,
|
||||
} from "./http.ts";
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createEncryptedRequest,
|
||||
createKeyring,
|
||||
createMemoryReplayStore,
|
||||
decryptEncryptedResponse,
|
||||
decryptHttpBody,
|
||||
decryptRequest,
|
||||
encryptHttpBody,
|
||||
encryptResponse,
|
||||
encryptedExchange,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const keyring = createKeyring([
|
||||
{
|
||||
id: "primary",
|
||||
secret: "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=",
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
|
||||
describe("encrypted HTTP envelopes", () => {
|
||||
test("binds ciphertext to method, URL, request id, age, and replay state", async () => {
|
||||
const now = 10_000;
|
||||
const replay = createMemoryReplayStore(() => now);
|
||||
const envelope = await encryptHttpBody(
|
||||
{ message: "secret" },
|
||||
{
|
||||
keyring,
|
||||
method: "POST",
|
||||
url: "https://api.example.test/private?view=full",
|
||||
requestId: "request-1",
|
||||
timestamp: now,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await decryptHttpBody<{ message: string }>(envelope, {
|
||||
keyring,
|
||||
method: "POST",
|
||||
url: "https://api.example.test/private?view=full",
|
||||
replayStore: replay,
|
||||
now: () => now,
|
||||
expectedRequestId: "request-1",
|
||||
});
|
||||
expect(result.body).toEqual({ message: "secret" });
|
||||
|
||||
await expect(
|
||||
decryptHttpBody(envelope, {
|
||||
keyring,
|
||||
method: "POST",
|
||||
url: "https://api.example.test/private?view=full",
|
||||
replayStore: replay,
|
||||
now: () => now,
|
||||
}),
|
||||
).rejects.toThrow("REPLAY");
|
||||
|
||||
await expect(
|
||||
decryptHttpBody(envelope, {
|
||||
keyring,
|
||||
method: "GET",
|
||||
url: "https://api.example.test/private?view=full",
|
||||
now: () => now,
|
||||
}),
|
||||
).rejects.toThrow("CONTEXT");
|
||||
});
|
||||
|
||||
test("checks the clear request-id header against the encrypted envelope", async () => {
|
||||
const request = await createEncryptedRequest(
|
||||
"https://api.example.test/private",
|
||||
{ value: 1 },
|
||||
{ keyring, requestId: "request-2", method: "POST" },
|
||||
);
|
||||
request.headers.set("x-wrn-request-id", "tampered-id");
|
||||
await expect(decryptRequest(request, { keyring })).rejects.toThrow("REQUEST-ID");
|
||||
});
|
||||
|
||||
test("encrypts a response using and verifying the original request context", async () => {
|
||||
const request = await createEncryptedRequest(
|
||||
"https://api.example.test/private",
|
||||
{ value: 1 },
|
||||
{ keyring, requestId: "request-3", method: "POST" },
|
||||
);
|
||||
const response = await encryptResponse({ ok: true }, request, { keyring });
|
||||
const result = await decryptEncryptedResponse<{ ok: boolean }>(response, request, { keyring });
|
||||
expect(result.body.ok).toBe(true);
|
||||
expect(response.headers.get("x-wrn-request-id")).toBe("request-3");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
|
||||
const other = await createEncryptedRequest(
|
||||
"https://api.example.test/private",
|
||||
{ value: 2 },
|
||||
{ keyring, requestId: "request-other", method: "POST" },
|
||||
);
|
||||
await expect(decryptEncryptedResponse(response, other, { keyring })).rejects.toThrow(
|
||||
"REQUEST-ID",
|
||||
);
|
||||
});
|
||||
|
||||
test("provides transparent encrypted request and response middleware", async () => {
|
||||
const request = await createEncryptedRequest(
|
||||
"https://api.example.test/private",
|
||||
{ value: 7 },
|
||||
{ keyring, requestId: "request-4", method: "POST" },
|
||||
);
|
||||
const context = {
|
||||
req: request,
|
||||
locals: {},
|
||||
} as Parameters<ReturnType<typeof encryptedExchange>>[0];
|
||||
const response = await encryptedExchange({ keyring })(context, () =>
|
||||
Response.json({ received: context.locals.encryptedBody }),
|
||||
);
|
||||
const result = await decryptEncryptedResponse<{
|
||||
received: { value: number };
|
||||
}>(response, request, { keyring });
|
||||
expect(result.body.received.value).toBe(7);
|
||||
});
|
||||
|
||||
test("does not convert application exceptions into invalid-body responses", async () => {
|
||||
const request = await createEncryptedRequest(
|
||||
"https://api.example.test/private",
|
||||
{ value: 7 },
|
||||
{ keyring, requestId: "request-5", method: "POST" },
|
||||
);
|
||||
const context = {
|
||||
req: request,
|
||||
locals: {},
|
||||
} as Parameters<ReturnType<typeof encryptedExchange>>[0];
|
||||
await expect(
|
||||
encryptedExchange({ keyring })(context, () => {
|
||||
throw new Error("application failed");
|
||||
}),
|
||||
).rejects.toThrow("application failed");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user