release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+50 -63
View File
@@ -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.
+20 -2
View File
@@ -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:*"
}
}
+367
View File
@@ -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,
});
};
}
+20
View File
@@ -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";
+134
View File
@@ -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");
});
});