first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
# @wrnexus/encryption
> Dependency-free crypto helpers for WrNexus: authenticated symmetric encryption (AES-256-GCM), hashing, and HMAC signing.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
## Overview
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:
```ts
import { generateKey, encrypt, decrypt } from "@wrnexus/encryption";
const key = await generateKey(); // store this safely (env/secret manager)
const box = await encrypt("card #1234", key); // opaque base64 string, safe to persist
const plain = await decrypt(box, key); // "card #1234"
```
Deriving a key from a user password instead of a random key:
```ts
import { deriveKey, encrypt } from "@wrnexus/encryption";
const key = await deriveKey("correct horse battery staple", "per-user-salt");
const box = await encrypt("secret note", key);
```
Hashing and webhook signature verification:
```ts
import { sha256, hmacSign, hmacVerify } from "@wrnexus/encryption";
const digest = await sha256("some content"); // 64-char hex string
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.
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.12",
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* @wrnexus/encryption — authenticated symmetric encryption (AES-256-GCM) via
* WebCrypto, dependency-free. Use it to encrypt secrets, tokens, or database
* fields at rest.
*
* const key = await generateKey(); // store this safely
* const box = await encrypt("card #1234", key); // opaque base64 string
* const plain = await decrypt(box, key); // "card #1234"
*
* A key derived from a password (PBKDF2) is also supported via `deriveKey`.
*/
const enc = new TextEncoder();
const dec = new TextDecoder();
const IV_BYTES = 12;
function toB64(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
function fromB64(str: string): Uint8Array {
const bin = atob(str);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function toHex(bytes: Uint8Array): string {
let out = "";
for (const b of bytes) out += b.toString(16).padStart(2, "0");
return out;
}
/** SHA-256 hex digest of a string (e.g. content hashing, dedup keys). */
export async function sha256(data: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", enc.encode(data) as BufferSource);
return toHex(new Uint8Array(digest));
}
/** HMAC-SHA256 hex signature of `data` with `secret` (e.g. signing webhooks). */
export async function hmacSign(data: string, secret: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret) as BufferSource,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(data) as BufferSource);
return toHex(new Uint8Array(sig));
}
/** Constant-time verify of an HMAC-SHA256 signature. */
export async function hmacVerify(
data: string,
secret: string,
signature: string,
): Promise<boolean> {
const expected = await hmacSign(data, secret);
if (expected.length !== signature.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++)
diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
return diff === 0;
}
/** Generate a random 256-bit key, base64-encoded. Store it as a secret. */
export async function generateKey(): Promise<string> {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return toB64(bytes);
}
async function importAesKey(key: string): Promise<CryptoKey> {
const raw = fromB64(key);
if (raw.length !== 32) throw new Error("Encryption key must be a base64 256-bit key");
return crypto.subtle.importKey("raw", raw as BufferSource, "AES-GCM", false, [
"encrypt",
"decrypt",
]);
}
/**
* Encrypt a string. Output is base64 of `iv(12) || ciphertext+tag`, safe to
* store or transmit. Each call uses a fresh random IV.
*/
export async function encrypt(plaintext: string, key: string): Promise<string> {
const cryptoKey = await importAesKey(key);
const iv = new Uint8Array(IV_BYTES);
crypto.getRandomValues(iv);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv as BufferSource },
cryptoKey,
enc.encode(plaintext) as BufferSource,
),
);
const packed = new Uint8Array(iv.length + ciphertext.length);
packed.set(iv, 0);
packed.set(ciphertext, iv.length);
return toB64(packed);
}
/** Decrypt a value produced by `encrypt`. Throws if the key is wrong or data tampered. */
export async function decrypt(payload: string, key: string): Promise<string> {
const cryptoKey = await importAesKey(key);
const packed = fromB64(payload);
if (packed.length <= IV_BYTES) throw new Error("Invalid ciphertext");
const iv = packed.slice(0, IV_BYTES);
const ciphertext = packed.slice(IV_BYTES);
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv as BufferSource },
cryptoKey,
ciphertext as BufferSource,
);
return dec.decode(plain);
}
/** Derive a base64 AES key from a password + salt (PBKDF2, 100k iterations). */
export async function deriveKey(password: string, salt: string): Promise<string> {
const baseKey = await crypto.subtle.importKey(
"raw",
enc.encode(password) as BufferSource,
"PBKDF2",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: enc.encode(salt) as BufferSource,
iterations: 100_000,
hash: "SHA-256",
},
baseKey,
256,
);
return toB64(new Uint8Array(bits));
}
@@ -0,0 +1,58 @@
import { test, expect } from "bun:test";
import {
generateKey,
encrypt,
decrypt,
deriveKey,
sha256,
hmacSign,
hmacVerify,
} from "../src/index.ts";
test("sha256 is stable and hex-encoded", async () => {
const a = await sha256("hello");
expect(a).toBe("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
expect(await sha256("hello")).toBe(a);
expect(await sha256("world")).not.toBe(a);
});
test("hmacSign / hmacVerify (webhook signatures)", async () => {
const sig = await hmacSign("payload", "secret");
expect(await hmacVerify("payload", "secret", sig)).toBe(true);
expect(await hmacVerify("payload", "wrong", sig)).toBe(false);
expect(await hmacVerify("tampered", "secret", sig)).toBe(false);
});
test("encrypt/decrypt round-trip", async () => {
const key = await generateKey();
const box = await encrypt("card #1234 secret", key);
expect(box).not.toContain("card"); // opaque
expect(await decrypt(box, key)).toBe("card #1234 secret");
});
test("each encryption uses a fresh IV (different ciphertexts)", async () => {
const key = await generateKey();
const a = await encrypt("same", key);
const b = await encrypt("same", key);
expect(a).not.toBe(b);
expect(await decrypt(a, key)).toBe("same");
expect(await decrypt(b, key)).toBe("same");
});
test("wrong key or tampered data fails (authenticated)", async () => {
const key = await generateKey();
const other = await generateKey();
const box = await encrypt("secret", key);
await expect(decrypt(box, other)).rejects.toThrow();
await expect(decrypt(box.slice(0, -4) + "AAAA", key)).rejects.toThrow();
});
test("deriveKey is deterministic for the same password+salt", async () => {
const k1 = await deriveKey("hunter2", "user-salt");
const k2 = await deriveKey("hunter2", "user-salt");
const k3 = await deriveKey("hunter2", "other-salt");
expect(k1).toBe(k2);
expect(k1).not.toBe(k3);
// usable as an encryption key
expect(await decrypt(await encrypt("x", k1), k1)).toBe("x");
});