Skip to content

Secure hybrid encryption packages — encrypt requests/responses between browser and Node.js.

License

Notifications You must be signed in to change notification settings

rahulxsh/lets-encrypt

Repository files navigation

Secure Hybrid Encryption

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


What does "hybrid encryption" mean?

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.


How it works (minimal example)

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.


Packages

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)

Supported Algorithms

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.


Guide

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)

Quick Start

Installation

# Backend
pnpm add @rahulxsh/secure-server @rahulxsh/secure-core

# Frontend
pnpm add @rahulxsh/secure-client @rahulxsh/secure-core

Use Case 1 — Encrypted REST API (RSA variant)

The 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.

Browser (client side)

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" }

Node.js (server side)

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;
  }
});

Use Case 2 — Long-lived Client Identity (ECDH variant)

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.

Browser (client side)

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);

Node.js (server side)

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;
  }
});

Use Case 3 — High-performance / Mobile Apps (X25519 variant)

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"
);

The Encrypted Payload Format

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

Real-World Use Cases

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

Security Guarantees

  • 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 guardassertValidPayload rejects payloads whose alg field isn't a known value before any crypto is attempted.

For the full threat model see docs/security.md.


Repository Layout

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

Running Tests

# 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 build

Package documentation

Each 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

License

This project is licensed under the MIT License. See LICENSE for the full text.


Further Reading

About

Secure hybrid encryption packages — encrypt requests/responses between browser and Node.js.

Resources

License

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors