npm.io
1.2.0 • Published 5h ago

hybrid-crypto-express

Licence
MIT
Version
1.2.0
Deps
0
Size
49 kB
Vulns
0
Weekly
0

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

  • 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).

Installation

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:

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.
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
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):

{
  "ciphertext": "<base64 AES-GCM ciphertext of JSON payload>",
  "iv": "<base64 12-byte IV>",
  "authTag": "<base64 16-byte auth tag>"
}

Encrypted response body (server ? client):

{
  "success": true,
  "encrypted": true,
  "algorithm": "AES-GCM-256",
  "ciphertext": "<base64>",
  "iv": "<base64>",
  "authTag": "<base64>",
  "timestamp": "<ISO8601>",
  "metadata": { "clientId": "...", "originalDataType": "object" }
}

Handshake request body:

{
  "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.

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:

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

Keywords