A TypeScript monorepo that gives you production-ready, end-to-end encrypted messaging between a Node.js backend and a browser (or any WebCrypto environment) using battle-tested hybrid encryption.
License: MIT
Packages on npm: @rahulxsh/secure-core · @rahulxsh/secure-client · @rahulxsh/secure-server
Encrypting directly with an asymmetric (RSA / ECDH) key is slow and has size limits.
Encrypting with a symmetric (AES) key is fast but you need a secure way to share that key.
Hybrid encryption combines both:
[Plain data]
│
▼ AES-256-GCM (fast symmetric cipher, authenticated)
[Ciphertext + Auth Tag]
[AES key]
│
▼ RSA-OAEP or ECDH/X25519 (slow but safe key exchange)
[Encrypted AES key / ephemeral public key]
The result: fast encryption of any-size data, with the security of asymmetric cryptography.
Flow: client sends secret data to server (RSA variant)
CLIENT SERVER
------ ------
1. Generate random AES key + IV
2. Encrypt JSON with AES-256-GCM ──────► (ciphertext is unreadable without key)
3. Wrap AES key with SERVER's
RSA public key (RSA-OAEP)
4. Send payload:
{ iv, encrypted_key, ciphertext, auth_tag }
5. Unwrap encrypted_key with
SERVER's RSA private key
→ get AES key
6. Decrypt ciphertext with
AES-GCM → get original JSON
One-way example (client → server):
// ═══ CLIENT (browser) ═══
import {
encryptRequest,
importRsaPublicKey,
} from "@rahulxsh/secure-client";
// Server's public key (you get this from your API or config)
const serverPublicKeyPem = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----`;
const serverPublicKey = await importRsaPublicKey(serverPublicKeyPem);
// Encrypt: inside this call:
// - a random AES key + IV are generated
// - your data is encrypted with AES-256-GCM
// - the AES key is wrapped with the server's RSA public key
const payload = await encryptRequest(
{ username: "alice", password: "secret123" },
serverPublicKey,
"key-v1"
);
// Send to server (e.g. POST body)
await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});// ═══ SERVER (Node.js) ═══
import { decryptRequest, DecryptionError } from "@rahulxsh/secure-server";
// Your RSA private key (from env / secrets manager)
const SERVER_PRIVATE_KEY_PEM = process.env.RSA_PRIVATE_KEY;
app.post("/api/login", async (req, res) => {
try {
const payload = req.body; // { iv, encrypted_key, ciphertext, auth_tag, ... }
// Decrypt: unwraps encrypted_key with RSA private key → AES key,
// then decrypts ciphertext with AES-GCM
const data = await decryptRequest(payload, SERVER_PRIVATE_KEY_PEM);
// data = { username: "alice", password: "secret123" }
// ... authenticate user and respond
} catch (err) {
if (err instanceof DecryptionError) {
return res.status(400).json({ error: "Invalid or tampered payload" });
}
throw err;
}
});Why hybrid? The data is encrypted with AES (fast, no size limit). The AES key is protected with RSA (only the server’s private key can unwrap it). So only the server can recover the key and read the data.
| Package | Who uses it | Environment |
|---|---|---|
@rahulxsh/secure-core |
Both | Any — pure utility types and helpers |
@rahulxsh/secure-server |
Backend | Node.js ≥ 18 (node:crypto) |
@rahulxsh/secure-client |
Frontend | Browser / Node.js ≥ 18 (WebCrypto API) |
Three algorithms are available. All use AES-256-GCM for actual data encryption — they differ only in how the AES key is established:
| Algorithm | How AES key is established | Best for |
|---|---|---|
RSA-OAEP-AES-256-GCM |
RSA-OAEP wraps a random AES key | APIs where clients generate throwaway key pairs per session |
ECDH-AES-256-GCM |
ECDH P-256 + HKDF derives AES key | Long-lived client identities, IoT devices |
X25519-AES-256-GCM |
X25519 + HKDF derives AES key | Same as ECDH but faster + smaller keys |
Not sure which to pick? See docs/algorithms.md.
| Topic | Where |
|---|---|
| Installation | Below (Quick Start) |
| Usage & examples | Below (Use Case 1–3) and package READMEs |
| Algorithm choice | docs/algorithms.md (if present) or Supported Algorithms below |
| License | LICENSE (MIT) |
# Backend
pnpm add @rahulxsh/secure-server @rahulxsh/secure-core
# Frontend
pnpm add @rahulxsh/secure-client @rahulxsh/secure-coreThe most common pattern. The client generates a throwaway RSA key pair per session, sends the public key to the server, and both sides use it for the lifetime of the session.
import {
generateEphemeralKeyPair,
exportPublicKeySpki,
encryptRequest,
decryptResponse,
importRsaPublicKey,
} from "@rahulxsh/secure-client";
// 1. On app load — generate a throwaway RSA key pair (never stored)
const keyPair = await generateEphemeralKeyPair();
// 2. Export the public key and register it with the server
const publicKeyBytes = await exportPublicKeySpki(keyPair.publicKey);
await fetch("/api/register-key", {
method: "POST",
body: JSON.stringify({
kid: "session-key-v1",
publicKey: btoa(String.fromCharCode(...publicKeyBytes)),
}),
});
// 3. Encrypt a request using the SERVER's public key
const serverPublicKey = await importRsaPublicKey(SERVER_RSA_PUBLIC_KEY_PEM);
const payload = await encryptRequest(
{ username: "alice", password: "hunter2" },
serverPublicKey,
"server-key-v1"
);
await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
// 4. Decrypt a response the server sent back (encrypted for our public key)
const encryptedResponse = await fetch("/api/profile").then(r => r.json());
const profile = await decryptResponse(encryptedResponse, keyPair.privateKey);
console.log(profile); // { userId: 42, name: "Alice" }import {
decryptRequest,
encryptResponse,
DecryptionError,
} from "@rahulxsh/secure-server";
// Server has its own RSA key pair — loaded at startup from env / secrets manager
const SERVER_PRIVATE_KEY = process.env.RSA_PRIVATE_KEY_PEM!;
// Registry of client public keys (populated via /api/register-key endpoint)
const clientPublicKeys: Record<string, string> = {};
// Receive and decrypt a client request
app.post("/api/login", async (req, res) => {
try {
const payload = req.body; // already parsed JSON
const data = await decryptRequest(payload, SERVER_PRIVATE_KEY);
// data = { username: "alice", password: "hunter2" }
// ... authenticate the user ...
// Encrypt the response for this specific client
const clientPubKey = clientPublicKeys[payload.kid];
const response = await encryptResponse(
{ userId: 42, token: "jwt-here" },
clientPubKey,
"session-key-v1"
);
res.json(response);
} catch (err) {
if (err instanceof DecryptionError) {
// Never expose why decryption failed — prevents oracle attacks
return res.status(400).json({ error: "Invalid request" });
}
throw err;
}
});The client has a persistent P-256 key pair (stored in IndexedDB or a secure enclave). Good for IoT devices, mobile apps, or users who log in repeatedly.
import {
generateEcdhKeyPair,
exportEcdhPublicKeySpki,
encryptRequestEcdh,
decryptResponseEcdh,
importEcdhPublicKey,
} from "@rahulxsh/secure-client";
// Generate once and persist (e.g. in IndexedDB)
const keyPair = await generateEcdhKeyPair();
const myPublicSpki = await exportEcdhPublicKeySpki(keyPair.publicKey);
// Import the server's static ECDH public key
const serverEcdhKey = await importEcdhPublicKey(serverEcdhSpkiBytes);
// Encrypt a request
const payload = await encryptRequestEcdh(
{ action: "getBalance" },
serverEcdhKey,
"ecdh-srv-v1"
);
// Decrypt a response
const encryptedResponse = await fetch("/api/balance").then(r => r.json());
const result = await decryptResponseEcdh(encryptedResponse, keyPair.privateKey);import {
generateEcdhKeyPair,
exportEcdhPublicKeySpki,
encryptResponseEcdh,
decryptRequestEcdh,
DecryptionError,
} from "@rahulxsh/secure-server";
// Server generates its ECDH key pair once at startup
const serverEcdhKeys = generateEcdhKeyPair();
app.post("/api/action", async (req, res) => {
try {
const data = await decryptRequestEcdh(req.body, serverEcdhKeys.privateKey);
// data = { action: "getBalance" }
const response = await encryptResponseEcdh(
{ balance: 1000 },
clientEcdhPublicKey, // retrieved from your user database
"ecdh-cli-v1"
);
res.json(response);
} catch (err) {
if (err instanceof DecryptionError) {
return res.status(400).json({ error: "Invalid request" });
}
throw err;
}
});Same pattern as ECDH but uses Curve25519, which is ~2-3× faster and produces 44-byte keys instead of 91-byte P-256 keys. Ideal for mobile apps, high-throughput APIs, or any latency-sensitive path.
// Browser — exactly like ECDH but import from x25519 functions
import {
generateX25519KeyPair,
exportX25519PublicKeySpki,
encryptRequestX25519,
decryptResponseX25519,
importX25519PublicKey,
} from "@rahulxsh/secure-client";
const keyPair = await generateX25519KeyPair();
const serverX25519Key = await importX25519PublicKey(serverX25519SpkiBytes);
const payload = await encryptRequestX25519(
{ message: "hello" },
serverX25519Key,
"x25519-srv-v1"
);// Server — exactly like ECDH but import from x25519 functions
import {
generateX25519KeyPair,
encryptResponseX25519,
decryptRequestX25519,
} from "@rahulxsh/secure-server";
const serverX25519Keys = generateX25519KeyPair();
const data = await decryptRequestX25519(req.body, serverX25519Keys.privateKey);
const response = await encryptResponseX25519(
{ ok: true },
clientX25519PublicKey,
"x25519-cli-v1"
);Every function returns (or accepts) the same EncryptedPayload object, which is safe to transmit as a JSON body:
{
"version": "1",
"alg": "RSA-OAEP-AES-256-GCM",
"kid": "server-key-v1",
"iv": "dGhpcyBpcyBhIHRlc3Q",
"encrypted_key": "base64url-of-rsa-wrapped-aes-key-or-ephemeral-spki",
"auth_tag": "base64url-of-16-byte-gcm-tag",
"ciphertext": "base64url-of-encrypted-data"
}| Field | Description |
|---|---|
version |
Schema version, always "1" |
alg |
Which algorithm was used — drives the decrypt path |
kid |
Key ID — tells the receiver which private key to use |
iv |
12-byte random nonce for AES-GCM, base64url |
encrypted_key |
RSA variant: RSA-wrapped AES key. ECDH/X25519: sender's ephemeral public key |
auth_tag |
16-byte GCM authentication tag — proves nothing was tampered with |
ciphertext |
The actual encrypted data |
| Scenario | Recommended algorithm | Reason |
|---|---|---|
| Login / signup form | RSA-OAEP-AES-256-GCM | Throwaway key per session, no persistent identity needed |
| Sending credit card data | RSA-OAEP-AES-256-GCM | Standard, widely understood, no key management overhead |
| Chat / messaging app | X25519-AES-256-GCM | Fast, small keys, forward secrecy per message |
| IoT sensor → cloud | X25519-AES-256-GCM | Tiny key footprint, very fast on constrained hardware |
| Healthcare portal | ECDH-AES-256-GCM | P-256 is FIPS-approved, may be required by compliance |
| File transfer / download | Any | Wrap the file bytes instead of JSON — same API |
| Internal microservice calls | ECDH or X25519 | Services have long-lived identities, no throwaway keys needed |
| End-to-end encrypted notes | X25519-AES-256-GCM | Persistent user key pair, forward secrecy, compact |
- Confidentiality — only the holder of the correct private key can decrypt.
- Integrity — AES-256-GCM authentication tag detects any bit-flip tampering.
- Forward secrecy — ECDH and X25519 variants generate a fresh ephemeral key per call; compromising long-term keys does not reveal past messages.
- Oracle protection — all decryption errors are wrapped in a generic
DecryptionError; the caller cannot tell why decryption failed. - Zero-fill — ephemeral AES key bytes are zeroed from memory after use.
- Algorithm confusion guard —
assertValidPayloadrejects payloads whosealgfield isn't a known value before any crypto is attempted.
For the full threat model see docs/security.md.
secure-encryption/
├── packages/
│ ├── secure-core/ # Shared types, base64url, nonce, timestamp, validation
│ │ └── src/
│ │ ├── types.ts # EncryptedPayload, SupportedAlgorithm, KeyPair
│ │ ├── base64.ts # toBase64Url / fromBase64Url
│ │ ├── nonce.ts # randomBytes / randomNonce
│ │ ├── timestamp.ts # nowSeconds / isFresh
│ │ ├── buffer.ts # stringToBytes / bytesToString / concatBytes
│ │ └── validate.ts # assertValidPayload / PayloadValidationError
│ │
│ ├── secure-server/ # Node.js backend package
│ │ └── src/
│ │ ├── encrypt.ts # encryptResponse (RSA)
│ │ ├── decrypt.ts # decryptRequest (RSA), DecryptionError
│ │ ├── ecdh.ts # encryptResponseEcdh / decryptRequestEcdh
│ │ ├── x25519.ts # encryptResponseX25519 / decryptRequestX25519
│ │ ├── aes.ts # aesEncrypt / aesDecrypt (Node.js)
│ │ ├── rsa.ts # rsaEncryptKey / rsaDecryptKey
│ │ └── kdf.ts # deriveAesKeyHkdf (HKDF-SHA-256)
│ │
│ └── secure-client/ # Browser / WebCrypto package
│ └── src/
│ ├── encrypt.ts # encryptRequest (RSA)
│ ├── decrypt.ts # decryptResponse (RSA), DecryptionError
│ ├── ecdh.ts # encryptRequestEcdh / decryptResponseEcdh
│ ├── x25519.ts # encryptRequestX25519 / decryptResponseX25519
│ ├── keygen.ts # generateEphemeralKeyPair / exportPublicKeySpki
│ ├── aes.ts # aesEncrypt / aesDecrypt (WebCrypto)
│ ├── rsa.ts # rsaEncryptKey / rsaDecryptKey
│ └── kdf.ts # deriveAesKeyHkdf (HKDF-SHA-256 via WebCrypto)
│
├── docs/
│ ├── algorithms.md # Choosing the right algorithm
│ ├── api-reference.md # Full public API documentation
│ └── security.md # Threat model and security properties
│
├── tsconfig.base.json
├── pnpm-workspace.yaml
└── README.md # ← you are here
# Install dependencies
pnpm install
# Run all tests across all packages
pnpm -r test
# Run tests for a single package
pnpm --filter @rahulxsh/secure-server test
pnpm --filter @rahulxsh/secure-client test
pnpm --filter @rahulxsh/secure-core test
# Build all packages (ESM + CJS + .d.ts)
pnpm -r buildEach package has its own README with install and usage:
| Package | README | Description |
|---|---|---|
@rahulxsh/secure-core |
packages/secure-core/README.md | Shared types and utilities (base64url, random, validation) |
@rahulxsh/secure-client |
packages/secure-client/README.md | Browser encryption (RSA, ECDH, X25519) |
@rahulxsh/secure-server |
packages/secure-server/README.md | Node.js server decryption/encryption |
This project is licensed under the MIT License. See LICENSE for the full text.
- Choosing an Algorithm (if present)
- Full API Reference (if present)
- Security Model (if present)