# hybrid-crypto-express

> Hybrid encryption for Express: RSA-OAEP key exchange and AES-256-GCM payloads. Redis-backed session store required (no in-memory AES sessions). Middleware for decryptRequest, encryptResponse, and crypto routes.

Latest version **1.2.0** (published 2026-09-24) · MIT license · 0 weekly downloads

## Install

```sh
npm install hybrid-crypto-express
pnpm add hybrid-crypto-express
yarn add hybrid-crypto-express
bun add hybrid-crypto-express
```

## Health

**Score 60/100 (C)** — status: active.

Positive: esm support; no vulnerabilities; recently updated; high maintenance score.

Warnings: low downloads; no types.

## Facts

| | |
|---|---|
| Version | 1.2.0 |
| Published | 2026-09-24 |
| First published | 2026-03-05 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | none |
| Module format | ESM + CommonJS |
| Node | >=22.0.0 |
| Dependencies | 0 |
| Unpacked size | 49.5 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Maintainers | ecomobility |
| Keywords | encryption, decryption, hybrid, RSA, AES-GCM, express, microservice, security, redis |

## Links

- npm: https://www.npmjs.com/package/hybrid-crypto-express
- npm.io page: https://npm.io/package/hybrid-crypto-express

## Alternatives

- [memory-cache](https://npm.io/package/memory-cache.md) — 795.0K weekly downloads
- [@httptoolkit/proxy-agent](https://npm.io/package/@httptoolkit/proxy-agent.md) — 11.2K weekly downloads
- [express-cache-controller](https://npm.io/package/express-cache-controller.md) — 5.3K weekly downloads
- [http-cache-middleware](https://npm.io/package/http-cache-middleware.md) — 4.5K weekly downloads
- [cache2](https://npm.io/package/cache2.md) — 1.5K weekly downloads

## Recent versions

- 1.2.0 (latest) — 2026-09-24
- 1.0.0 — 2026-03-05

## README

# hybrid-crypto-express

**Hybrid (RSA + AES-GCM) encryption for Node.js and Express** ? zero dependencies, built on Node.js `crypto`. Lets APIs optionally encrypt request/response bodies per client using a short-lived session key established via a one-time RSA key exchange.

---

## Table of contents

- [Overview](#overview)
- [How it works](#how-it-works)
- [Cryptography](#cryptography)
- [Installation](#installation)
- [Server setup](#server-setup)
- [Client usage](#client-usage)
- [API reference](#api-reference)
- [Request / response format](#request--response-format)
- [HTTP headers](#http-headers)
- [Options & configuration](#options--configuration)
- [Redis session store (multi-pod)](#redis-session-store-multi-pod)
- [Security considerations](#security-considerations)
- [CORS](#cors)
- [Subpath imports](#subpath-imports)
- [License](#license)

---

## Overview

- **Purpose:** Add optional **end-to-end style** encryption for HTTP JSON bodies between a client and an Express server, without changing your business logic. The server decrypts before your handlers and encrypts responses when the client asks for it.
- **Model:** **Hybrid encryption** ? the client gets the server?s **RSA public key**, generates a random **AES-256 key**, encrypts that key with RSA and sends it once (handshake). All later request/response bodies use that **AES-256-GCM** key (and a fresh IV per message).
- **Session:** The server stores the AES key per **client ID** with a configurable TTL (e.g. 5 minutes). After expiry the client must handshake again.
- **Zero dependencies:** Only Node.js built-in `crypto` is used.
- **Runtime:** Node.js **? 22** (see `package.json` engines).

---

## How it works

1. **Client** calls `GET /api/crypto/public-key` and receives the server?s RSA public key (PEM).
2. **Client** generates a random 32-byte AES key, encrypts it with that public key (RSA-OAEP), and sends it in a **handshake** request with a stable **client ID** (e.g. `X-Client-ID: client_123`).
3. **Server** decrypts the AES key with its RSA private key, stores it under that client ID with an expiry, and responds with success and `sessionExpiry`.
4. For **encrypted requests:** client sends JSON body `{ ciphertext, iv, authTag }` (AES-GCM ciphertext of the real payload) and headers `X-Encrypted: true` and `X-Client-ID: <id>`.
5. **Server** (via `decryptRequest` middleware) looks up the AES key for that client, decrypts the body, and sets `req.body` to the decrypted object before your route runs.
6. For **encrypted responses:** when the same headers are present, the server (via `encryptResponse` middleware) wraps `res.json` so the body is encrypted as `{ success, encrypted, ciphertext, iv, authTag, ... }` and the client can decrypt with its stored AES key.

So: **RSA is used only once per session to protect the AES key;** all actual payloads use AES-256-GCM with a unique IV per message.

---

## Cryptography

| Layer            | Algorithm     | Parameters / usage |
|-----------------|---------------|--------------------|
| Key exchange    | RSA-OAEP      | 2048-bit (configurable), SHA-256 |
| Payload crypto  | AES-256-GCM   | 12-byte IV, 128-bit auth tag per message |
| Session key     | 256-bit random| One per client session, used only for AES-GCM |

- IVs are generated randomly per encrypt operation.
- Session keys require a pluggable `sessionStore` (typically `RedisSessionStore`).
  There is **no in-memory AES session store**. See [Redis session store (multi-pod)](#redis-session-store-multi-pod).

---

## Installation

```bash
npm install hybrid-crypto-express
```

**Requirements:** Node.js **>= 22.0.0**.

---

## Server setup

1. Create a `HybridCrypto` instance (optionally with `sessionExpiryMs` and `rsaModulusLength`).
2. Use **`decryptRequest(hybridCrypto, options?)`** **before** any body parser so the middleware can read the raw body for encrypted requests. Skip paths like `/health`, `/api/crypto/public-key`, `/api/crypto/handshake` so they are not treated as encrypted.
3. Use `express.json()` / `express.urlencoded()` as usual.
4. Call **`registerCryptoRoutes(app, hybridCrypto, options?)`** to mount `GET /api/crypto/public-key` and `POST /api/crypto/handshake` (and optionally `POST /api/crypto/test`).
5. Use **`encryptResponse(hybridCrypto)`** so that when a request has `X-Encrypted: true` and `X-Client-ID`, `res.json()` will encrypt the body before sending.

**Middleware order matters:**  
`decryptRequest` ? `express.json()` ? your routes + `registerCryptoRoutes` ? `encryptResponse` (so it can wrap `res.json`).

Example:

```js
const express = require('express');
const {
  HybridCrypto,
  decryptRequest,
  encryptResponse,
  registerCryptoRoutes
} = require('hybrid-crypto-express');

const app = express();
const hybridCrypto = new HybridCrypto({
  sessionExpiryMs: 5 * 60 * 1000,  // 5 minutes
  rsaModulusLength: 2048
});

// 1) Decrypt encrypted requests (before body parser)
app.use(decryptRequest(hybridCrypto, {
  skipPaths: ['/health', '/', '/api/crypto/public-key', '/api/crypto/handshake']
}));

app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(express.json({ limit: '10mb' }));

// 2) Crypto endpoints + optional test route
registerCryptoRoutes(app, hybridCrypto, {
  pathPrefix: '/api/crypto',
  testRoute: true
});

// 3) Encrypt responses when client sends X-Encrypted: true
app.use(encryptResponse(hybridCrypto));

app.get('/health', (req, res) => {
  res.json({ status: 'ok', encryption: true });
});

app.post('/api/secure-action', (req, res) => {
  // req.body is decrypted when request was encrypted
  res.json({ received: req.body });
});

app.listen(3000);
```

---

## Client usage

**Node (or any environment with `fetch`):**

- Use **`HybridCryptoClient`**: call `handshake(baseUrl)` once, then use `encryptPayload(data)` for the body and `getEncryptedRequestHeaders()` for headers. For encrypted responses, parse JSON and call `decryptPayload(body)` when `body.encrypted === true`.

```js
const { HybridCryptoClient } = require('hybrid-crypto-express');

const client = new HybridCryptoClient('my-service-id');

async function run() {
  await client.handshake('http://localhost:3000');

  const payload = { userId: 1, action: 'submit' };
  const encrypted = client.encryptPayload(payload);

  const res = await fetch('http://localhost:3000/api/secure-action', {
    method: 'POST',
    headers: client.getEncryptedRequestHeaders(),
    body: JSON.stringify(encrypted)
  });
  const body = await res.json();

  if (body.encrypted) {
    const decrypted = client.decryptPayload(body);
    console.log(decrypted);
  } else {
    console.log(body);
  }
}
run();
```

**Browser / SPA:**  
Use the same flow with the Web Crypto API: fetch public key, generate AES key, encrypt it with the server?s RSA public key (e.g. `crypto.subtle`), send handshake with `X-Client-ID`, then send requests with `{ ciphertext, iv, authTag }` and headers `X-Encrypted: true`, `X-Client-ID`. This package?s **client** is Node-oriented (uses `crypto`); for browsers you typically reimplement the same protocol with `crypto.subtle` and the same endpoints/headers.

---

## API reference

### Main export

```js
const {
  HybridCrypto,
  DEFAULT_SESSION_EXPIRY_MS,
  HybridCryptoClient,
  getPublicKey,
  decryptRequest,
  encryptResponse,
  registerCryptoRoutes
} = require('hybrid-crypto-express');
```

---

### Server

#### `HybridCrypto`

Server-side state: RSA key pair + per-client AES session keys.

- **`new HybridCrypto(options?)`**
  - `options.sessionExpiryMs` ? TTL for each client?s AES key in ms (default: `5 * 60 * 1000`).
  - `options.rsaModulusLength` ? RSA modulus size in bits (default: `2048`).

- **`getPublicKey()`**  
  Returns `{ publicKey, algorithm, hash, keySize, timestamp }` (PEM string and metadata).

- **`decryptAESKey(encryptedAESKeyBase64)`**  
  Decrypts the client?s AES key (base64). Returns 32-byte `Buffer`. Throws if invalid.

- **`storeSessionKey(clientId, aesKey)`**  
  Stores the AES key for `clientId` with the configured session expiry.

- **`getSessionKey(clientId)`**  
  Returns the stored AES key for `clientId`. Throws if missing or expired.

- **`decryptData(ciphertextBase64, ivBase64, authTagBase64, clientId)`**  
  Async. Decrypts one message. Returns `{ success, data?, raw?, error?, timestamp }`.

- **`encryptData(data, clientId)`**  
  Async. Encrypts `data` (JSON-serialized) for that client. Returns `{ success, ciphertext?, iv?, authTag?, algorithm?, error?, timestamp }`.

- **`processHandshake(handshakeData, clientId)`**  
  Async. Expects `handshakeData.encryptedAESKey`. Decrypts it, stores session, returns `{ success, message?, clientIV?, sessionExpiry?, error?, timestamp }`.

---

#### `decryptRequest(hybridCrypto, options?)`

Express middleware. Runs **before** `express.json()`.

- Reads raw body when `X-Encrypted: true` and `X-Client-ID` are set.
- Expects JSON body `{ ciphertext, iv, authTag }`.
- On success: sets `req.body` to the decrypted object, and `req.encryptionMetadata` (e.g. `type`, `clientId`, `algorithm`).
- **`options.skipPaths`** ? array of paths that skip decryption (default includes `/`, `/health`, `/api/crypto/public-key`, `/api/crypto/handshake`).

---

#### `encryptResponse(hybridCrypto)`

Express middleware. Wraps `res.json`.

- When request has `X-Encrypted: true` and `X-Client-ID`, encrypts the JSON body with the client?s session key and sends `{ success, encrypted, algorithm, ciphertext, iv, authTag, timestamp, metadata }`.
- Does not encrypt error responses (e.g. `res.statusCode >= 400`).
- Sets headers `X-Encrypted`, `X-Encryption-Algorithm`, `X-Client-ID` on encrypted responses.

---

#### `registerCryptoRoutes(app, hybridCrypto, options?)`

Adds routes to `app`:

- **`GET {pathPrefix}/public-key`** ? returns `hybridCrypto.getPublicKey()`.
- **`POST {pathPrefix}/handshake`** ? body must contain `encryptedAESKey`; `X-Client-ID` optional (defaults to generated id). Responds with handshake result and `X-Session-Expiry`, `X-Client-ID`.
- **`options.pathPrefix`** ? default `'/api/crypto'`.
- **`options.testRoute`** ? if true, adds **`POST {pathPrefix}/test`** that echoes back a small JSON object (useful to verify encryption).

**Note:** Handshake route needs a body parser; so mount `decryptRequest` first, then `express.json()`, then `registerCryptoRoutes` so handshake body is parsed as JSON.

---

### Client

#### `HybridCryptoClient`

- **`new HybridCryptoClient(clientId?)`**  
  `clientId` defaults to `client_<timestamp>`.

- **`handshake(baseUrl, fetchOptions?)`**  
  Fetches `GET {baseUrl}/api/crypto/public-key`, then `POST {baseUrl}/api/crypto/handshake` with encrypted AES key. Stores session on success. Returns the handshake JSON.

- **`encryptPayload(data)`**  
  Returns `{ ciphertext, iv, authTag }` (base64). Throws if no session (handshake first).

- **`decryptPayload(encrypted)`**  
  Expects `{ ciphertext, iv, authTag }`. Returns parsed JSON. Throws if no session or decryption fails.

- **`getEncryptedRequestHeaders()`**  
  Returns `{ 'Content-Type': 'application/json', 'X-Client-ID': this.clientId, 'X-Encrypted': 'true' }`.

---

#### `getPublicKey(baseUrl, fetchOptions?)`

Standalone helper. Fetches `GET {baseUrl}/api/crypto/public-key` and returns the JSON (e.g. for custom client flows).

---

### Constant

- **`DEFAULT_SESSION_EXPIRY_MS`** ? default session TTL in ms (5 minutes).

---

## Request / response format

**Encrypted request body (client ? server):**

```json
{
  "ciphertext": "<base64 AES-GCM ciphertext of JSON payload>",
  "iv": "<base64 12-byte IV>",
  "authTag": "<base64 16-byte auth tag>"
}
```

**Encrypted response body (server ? client):**

```json
{
  "success": true,
  "encrypted": true,
  "algorithm": "AES-GCM-256",
  "ciphertext": "<base64>",
  "iv": "<base64>",
  "authTag": "<base64>",
  "timestamp": "<ISO8601>",
  "metadata": { "clientId": "...", "originalDataType": "object" }
}
```

**Handshake request body:**

```json
{
  "encryptedAESKey": "<base64 RSA-OAEP encrypted 32-byte AES key>"
}
```

---

## HTTP headers

| Header (request)   | Meaning |
|--------------------|--------|
| `X-Client-ID`      | Stable client/session identifier; required for encryption. |
| `X-Encrypted`       | `true` = body is `{ ciphertext, iv, authTag }` and client wants encrypted response. |

| Header (response)  | Meaning |
|--------------------|--------|
| `X-Encrypted`      | `true` when body is the encrypted envelope. |
| `X-Encryption-Algorithm` | e.g. `RSA-AES-GCM`. |
| `X-Session-Expiry` | Session TTL in ms (handshake response). |
| `X-Client-ID`      | Echo of client id (handshake / encrypted response). |

---

## Options & configuration

- **Session expiry:** Set `sessionExpiryMs` in `HybridCrypto` constructor. Shorter = more handshakes, better forward secrecy; longer = fewer round-trips. TTL is **fixed from handshake** (not sliding).
- **RSA size:** `rsaModulusLength` (e.g. 2048 or 3072) in `HybridCrypto` constructor.
- **Shared RSA PEMs:** Pass `privateKey` / `publicKey` so all pods share the same keypair.
- **Session store (required):** Pass `sessionStore` implementing async `get` / `set` / `delete` ? use `RedisSessionStore`. Constructor throws if omitted (no in-memory fallback).
- **Paths:** Use `skipPaths` in `decryptRequest` so health checks, static assets, and the crypto routes themselves are never treated as encrypted.
- **Route prefix:** Use `pathPrefix` in `registerCryptoRoutes` (e.g. `'/api/crypto'`) so paths are `GET /api/crypto/public-key`, `POST /api/crypto/handshake`, etc. Optional `POST .../invalidate` ends a session before TTL.

---

## Redis session store (multi-pod)

For Kubernetes / multiple replicas, AES sessions must live in Redis ? not process memory.

```js
const { HybridCrypto, RedisSessionStore } = require('hybrid-crypto-express');

const sessionStore = new RedisSessionStore(redisClient, {
  serviceName: 'user-service', // keys: hybrid:aes:user-service:<clientId>
  // allowReadReplica: true, readClient: redisReadClient, // opt-in only
});

const hybridCrypto = new HybridCrypto({
  sessionExpiryMs: 5 * 60 * 1000,
  sessionStore,
  privateKey: process.env.HYBRID_CRYPTO_PRIVATE_KEY, // optional shared PEM
  publicKey: process.env.HYBRID_CRYPTO_PUBLIC_KEY,
});
```

**Behaviour**

| Topic | Behavior |
|-------|----------|
| Expiry | Redis TTL only (no app-side `expiresAt`) ? avoids clock skew across pods |
| Concurrent handshake | Last-write-wins for the same `clientId` |
| Redis down on handshake | Returns `{ success: false, error: 'Session storage unavailable' }` ? never a silent memory session |
| Redis down on decrypt | `get()` ? `null` + `redis_unreachable` warn (distinct from `session_miss`) |
| Stale AES key (GCM auth fail) | Distinct error: `Invalid session key ? possible stale session` (`stale_session_key`) |
| `clientId` | Must match `[A-Za-z0-9_-]{1,128}` |
| Connection ownership | Pass an **already-connected** client; the store never opens/closes Redis |

**Security:** Raw AES-256 session keys are stored in Redis. Production **must** use Redis AUTH + TLS in transit (and preferably encryption at rest). Rate-limit `/api/crypto/handshake` per IP to limit keyspace growth.

**RSA rotation note:** Existing AES sessions in Redis remain valid for decrypt/encrypt until TTL even if RSA keys rotate ? AES and RSA are independent after handshake.

Host apps should close their Redis client on graceful shutdown (the store does not own the connection).

---

## Security considerations

- **RSA key:** Prefer mounting shared PEMs (`privateKey`/`publicKey`) across pods; otherwise use your host Redis RSA sync. Consider KMS for production rotation.
- **Session keys:** Always stored via `RedisSessionStore` (or your own Redis-backed store). See above for Redis AUTH/TLS requirements.
- **TLS:** Use HTTPS in production so the encrypted payloads are not the only protection.
- **Client ID:** Stable per browser tab/session; sanitize enforced by `RedisSessionStore`.
- **No logging:** Do not log raw request/response bodies, AES keys, or private keys.
- **Observability:** Listen for events (`handshake_success`, `handshake_failure`, `session_miss`, `session_corrupted`, `decrypt_failure`) via `EventEmitter` or `onEvent` callback.

---

## CORS

If your API is called from a browser, allow and expose these so encryption works:

- **Allow headers:** `X-Client-ID`, `X-Encrypted`, `Content-Type`, etc.
- **Expose headers:** `X-Encrypted`, `X-Encryption-Algorithm`, `X-Session-Expiry`, `X-Client-ID`.

---

## Subpath imports

You can require server, client, or middleware only:

```js
const { HybridCrypto, DEFAULT_SESSION_EXPIRY_MS } = require('hybrid-crypto-express/server');
const { HybridCryptoClient, getPublicKey } = require('hybrid-crypto-express/client');
const { decryptRequest, encryptResponse, registerCryptoRoutes } = require('hybrid-crypto-express/middleware');
```

---

## License

MIT

---
_Source: https://npm.io/package/hybrid-crypto-express · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
