"60% of this code was written by an AI (Cursor), but I am not a passive coder β every line has been verified, fixed, and tested by me (a human)."
A plug-and-play TypeScript package providing End-to-End Encryption (E2EE) middleware for Express.js and NestJS applications using hybrid encryption (AES-CBC + RSA).
- Hybrid Encryption: AES-CBC for data encryption + RSA for key exchange
- Express.js Middleware: Easy integration with Express applications
- NestJS Interceptor: Seamless integration with NestJS applications
- Client SDK: TypeScript client for making encrypted requests
- Header-based Flow: Secure transmission using custom headers
- Automatic Key Management: Server generates and manages RSA key pairs
- Full Bidirectional Encryption: Request and response encryption support
- GET Request Encryption: Encrypt responses for GET requests (even with empty request bodies)
- Multi-Domain Support: Multiple encryption keys for different domains/tenants
- Custom Header Configuration: Configurable header names for flexibility
- Path & Method Exclusion: Exclude specific paths and HTTP methods from encryption
- Enforcement Modes: Strict or flexible encryption enforcement
- Empty Request Body Support: Encrypt responses for requests without data
- Callback Hooks: Error handling, decryption, and encryption callbacks
- Utility Functions: Key generation, encryption, and decryption utilities
- Automatic Response Encryption: Transparent response encryption using
res.send() - Plug-and-Play Integration: Zero configuration required for basic setup
- AES-256-CBC: Symmetric encryption for data
- RSA-2048-OAEP: Asymmetric encryption for key exchange
- Random IV Generation: Unique initialization vectors for each request
- Secure Key Exchange: RSA encryption for AES key transmission
npm install e2ee-adapter expressnpm install e2ee-adapter @nestjs/common rxjsThe middleware implements a secure hybrid encryption flow that supports both request encryption and response encryption for all HTTP methods, including GET requests with empty bodies:
+-------------------------+ +-------------------------+
| CLIENT | | SERVER |
+-------------------------+ +-------------------------+
| β²
| 1. Fetch server's public key from /e2ee.json |
| β key: RSA public PEM |
| β key_id: version info |
| |
| 2. Generate AES key (32 bytes) and IV (16 bytes) |
| |
| 3. Encrypt request payload using: |
| AES-CBC(payload, AES_key, IV) |
| |
| 4. Encrypt AES key using server's RSA public key |
| RSA_encrypt(AES_key, server_pubkey) |
| |
| 5. Send HTTPS request: |
| ---------------------------------------------- |
| Headers: |
| x-custom-key: RSA_encrypted_AES_key (base64) |
| x-custom-iv: IV (base64) |
| x-key-id: key_id |
| Content-Type: application/json |
| Body: |
| Encrypted AES payload (base64) |
| ---------------------------------------------- |
| |
| βΌ
| +------------------------------------------+
| | 6. Decrypt x-custom-key using RSA private|
| | AES_key = RSA_decrypt(x-custom-key) |
| +------------------------------------------+
| βΌ
| +------------------------------------------+
| | 7. Decrypt payload using AES-CBC |
| | plaintext = AES_decrypt(body, AES_key,|
| | IV)|
| +------------------------------------------+
| βΌ
| +------------------------------------------+
| | 8. Process request, prepare response JSON |
| +------------------------------------------+
| βΌ
| +------------------------------------------+
| | 9. Encrypt response using AES-CBC |
| | response_encrypted = AES_encrypt( |
| | JSON, AES_key, IV) |
| +------------------------------------------+
| βΌ
| +------------------------------------------+
| | 10. Send encrypted response |
| | Body: response_encrypted (base64) |
| +------------------------------------------+
| β²
| 11. Decrypt response using AES_key and IV |
| plaintext_response = AES_decrypt(body, key, IV) |
| |
+-------------------------+ +-------------------------+
The E2EE adapter is designed to be plug-and-play - you can get started with minimal configuration:
// Express.js - Just add the middleware
import { createE2EEMiddleware, generateMultipleKeyPairs } from 'e2ee-adapter';
const keys = await generateMultipleKeyPairs(['domain1']);
const e2eeMiddleware = createE2EEMiddleware({ config: { keys } });
app.use(e2eeMiddleware); // That's it!
// NestJS - Just add the interceptor
import { E2EEInterceptor } from 'e2ee-adapter';
@UseInterceptors(E2EEInterceptor)
@Controller('api')
export class UsersController {
// Your endpoints are now encrypted!
}The E2EE middleware and interceptor should be the first one to be used in your application stack. This ensures that:
- Request decryption happens before any other middleware processes the request
- Response encryption happens after your application logic but before any response middleware
- Security headers are properly set and maintained throughout the request lifecycle
- No data leakage occurs through other middleware that might log or process request/response data
For Express.js: Place the E2EE middleware as early as possible in your middleware stack, typically right after basic middleware like express.json() and express.urlencoded().
For NestJS: Apply the E2EE interceptor globally or at the controller level to ensure it runs before other interceptors and guards.
The package provides specific module paths for middleware, client, and interceptor:
// Main exports (everything)
import { createE2EEMiddleware, E2EEClient, E2EEInterceptor, generateMultipleKeyPairs } from 'e2ee-adapter';
// Specific modules (optional)
import { createE2EEMiddleware } from 'e2ee-adapter/middleware';
import { E2EEClient } from 'e2ee-adapter/client';
import { E2EEInterceptor } from 'e2ee-adapter/interceptor';import express from 'express';
import { generateMultipleKeyPairs } from 'e2ee-adapter';
import { createE2EEMiddleware } from 'e2ee-adapter/middleware';
const app = express();
// Generate multiple RSA key pairs
const keys = await generateMultipleKeyPairs(['domain1', 'domain2', 'domain3']);
// Create E2EE middleware
const e2eeMiddleware = createE2EEMiddleware({
config: {
keys,
enableRequestDecryption: true,
enableResponseEncryption: true,
excludePaths: ['/health', '/keys', '/e2ee.json'],
excludeMethods: ['GET', 'HEAD', 'OPTIONS'],
enforced: false // Allow both encrypted and non-encrypted requests
},
onError: (error, req, res) => {
console.error('E2EE Error:', error.message);
},
onDecrypt: (decryptedData, req) => {
console.log('Request decrypted successfully');
},
onEncrypt: (encryptedData, res) => {
console.log('Response encrypted successfully');
}
});
// Apply middleware (IMPORTANT: Place this early in your middleware stack)
app.use(e2eeMiddleware);
// Add server configuration endpoint
app.get('/e2ee.json', (req, res) => {
res.json({
keys: {
domain1: keys.domain1.publicKey,
domain2: keys.domain2.publicKey,
domain3: keys.domain3.publicKey
},
keySize: 2048
});
});
// Protected endpoints
app.post('/api/users', (req, res) => {
// req.body contains decrypted data
const user = { id: Date.now(), ...req.body };
// The middleware will automatically encrypt the response if encryption context is available
res.send({ success: true, user });
});
// GET endpoint with encrypted response
app.get('/api/users/:id', (req, res) => {
const user = { id: req.params.id, name: 'John Doe' };
res.send({ success: true, user }); // Will be automatically encrypted
});Important: Configure bodyParser for plain text requests
Before setting up the E2EE interceptor, you need to configure your NestJS application to handle plain text requests:
import * as bodyParser from 'body-parser';
// Add this to your main.ts or app.module.ts
app.use(bodyParser.text({ type: 'text/plain' }));E2EE Interceptor Setup:
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { E2EEInterceptor } from 'e2ee-adapter/interceptor';
@Injectable()
export class E2EEInterceptor extends E2EEInterceptor {
constructor() {
super({
config: {
keys: {
domain1: {
privateKey: process.env.E2EE_DOMAIN1_PRIVATE_KEY,
publicKey: process.env.E2EE_DOMAIN1_PUBLIC_KEY
},
domain2: {
privateKey: process.env.E2EE_DOMAIN2_PRIVATE_KEY,
publicKey: process.env.E2EE_DOMAIN2_PUBLIC_KEY
}
},
enableRequestDecryption: true,
enableResponseEncryption: true,
enforced: true // Strictly require encryption for all requests
}
});
}
}
// Apply to controller or globally (IMPORTANT: Apply early in the interceptor chain)
@UseInterceptors(E2EEInterceptor)
@Controller('api')
export class UsersController {
@Post('users')
createUser(@Body() userData: any) {
// userData is automatically decrypted
return { success: true, user: userData };
}
}import { E2EEClient } from 'e2ee-adapter/client';
// Create client with multiple server keys
const client = new E2EEClient({
serverKeys: {
domain1: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----',
domain2: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----'
}
});
// Make encrypted requests
const response = await client.request({
url: 'https://api.example.com/api/users',
method: 'POST',
data: {
name: 'John Doe',
email: 'john@example.com'
},
keyId: 'domain1' // Required: specify which key to use
});
console.log(response.data); // Automatically decrypted responseinterface KeyPair {
/** RSA public key in PEM format */
publicKey: string;
/** RSA private key in PEM format */
privateKey: string;
}
interface KeyStore {
/** Mapping of keyId to key pair */
[keyId: string]: KeyPair;
}
interface E2EEConfig {
/** Multiple keys store for multi-domain support */
keys: KeyStore;
/** Custom key header name (default: x-custom-key) */
customKeyHeader?: string;
/** Custom IV header name (default: x-custom-iv) */
customIVHeader?: string;
/** Key ID header name (default: x-key-id) */
keyIdHeader?: string;
/** Enable request decryption (default: true) */
enableRequestDecryption?: boolean;
/** Enable response encryption (default: true) */
enableResponseEncryption?: boolean;
/** Paths to exclude from encryption (default: ['/health', '/keys', '/e2ee.json']) */
excludePaths?: string[];
/** HTTP methods to exclude from encryption (default: ['GET', 'HEAD', 'OPTIONS']) */
excludeMethods?: string[];
/** If true, strictly enforce encryption for all requests. If false, only check for encryption after identifying headers (default: false) */
enforced?: boolean;
/** If true, allow empty request bodies while still enabling encrypted responses (default: false) */
allowEmptyRequestBody?: boolean;
}
interface E2EEMiddlewareOptions {
/** E2EE configuration */
config: E2EEConfig;
/** Error callback for handling E2EE errors */
onError?: (error: Error, req: any, res: any) => void;
/** Callback triggered when request is successfully decrypted */
onDecrypt?: (decryptedData: DecryptedData, req: any) => void;
/** Callback triggered when response is successfully encrypted */
onEncrypt?: (encryptedData: EncryptedData, res: any) => void;
}interface E2EEClientConfig {
/** Multiple server keys for multi-domain support */
serverKeys: { [keyId: string]: string };
/** Key ID for versioning */
keyId?: string;
}The middleware automatically encrypts responses when encryption context is available. Simply use the standard res.send() method:
// Encrypted response (when E2EE context exists)
app.post('/api/data', (req, res) => {
const data = { message: 'Hello World' };
res.send(data); // Automatically encrypted
});
// Encrypted response for GET requests
app.get('/api/data', (req, res) => {
const data = { message: 'Hello World' };
res.send(data); // Automatically encrypted if E2EE context exists
});
// Non-encrypted response (bypasses E2EE)
app.get('/api/public', (req, res) => {
const data = { message: 'Public data' };
res.json(data); // Never encrypted
});See examples/express-server/server.js for a complete working example.
See examples/client-example/client.js for a complete working example.
See examples/vanilla-js-client/ for a browser-based vanilla JavaScript client with interactive UI.
See examples/nestjs-server/ for a complete NestJS application with proper architecture, DTOs, entities, and global E2EE interceptor configuration.
-
Install the package:
# For Express.js npm install e2ee-adapter express # For NestJS npm install e2ee-adapter @nestjs/common rxjs
-
Generate multiple key pairs:
import { generateMultipleKeyPairs } from 'e2ee-adapter'; const keys = await generateMultipleKeyPairs(['domain1', 'domain2', 'domain3']);
-
Set up Express middleware:
import { createE2EEMiddleware } from 'e2ee-adapter/middleware'; const e2eeMiddleware = createE2EEMiddleware({ config: { keys } }); // IMPORTANT: Place this early in your middleware stack app.use(e2eeMiddleware);
-
Create client:
import { E2EEClient } from 'e2ee-adapter/client'; const client = new E2EEClient({ serverKeys: { domain1: keys.domain1.publicKey, domain2: keys.domain2.publicKey, domain3: keys.domain3.publicKey } });
-
Make encrypted requests:
// POST request with encrypted data const response = await client.request({ url: 'http://localhost:3000/api/users', method: 'POST', data: { name: 'John Doe' }, keyId: 'domain1' // Required: specify which key to use }); // GET request with encrypted response (no request body needed) const userResponse = await client.request({ url: 'http://localhost:3000/api/users/123', method: 'GET', keyId: 'domain1' // Required: specify which key to use });
The library supports multiple encryption keys for different domains or tenants sharing the same server infrastructure. This is useful for:
- Multi-tenant applications where each tenant has their own encryption keys
- Domain-specific encryption where different domains use different keys
- Key rotation where new keys can be added while old ones remain valid
- Isolation ensuring data encrypted with one key cannot be decrypted with another
- Explicit key selection requiring users to specify which key to use for each request
- Server Configuration: Configure multiple key pairs with unique keyIds
- Client Configuration: Store public keys for all domains you need to communicate with
- Request-Level Key Selection: Specify which keyId to use for each request (required)
- Automatic Key Resolution: Server automatically selects the correct private key based on the keyId header
// Server setup for multiple domains
const keys = await generateMultipleKeyPairs(['tenant1', 'tenant2', 'tenant3']);
const middleware = createE2EEMiddleware({
config: { keys }
});
// Client setup for multiple domains
const client = new E2EEClient({
serverKeys: {
tenant1: keys.tenant1.publicKey,
tenant2: keys.tenant2.publicKey,
tenant3: keys.tenant3.publicKey
}
});
// Use different keys for different requests (keyId is required)
await client.request({ url: '/api/data', method: 'POST', data: data1, keyId: 'tenant1' });
await client.request({ url: '/api/data', method: 'POST', data: data2, keyId: 'tenant2' });- Key Management: Store private keys securely and never expose them
- Key Rotation: Implement key rotation mechanisms for production use
- HTTPS: Always use HTTPS in production to protect against MITM attacks
- Key Size: Use 2048-bit RSA keys minimum for production
- Algorithm: The middleware uses RSA-OAEP with SHA-256 for optimal security
- Key Isolation: Ensure proper key isolation between different domains/tenants
The library supports two enforcement modes to control how encryption is handled:
- Only processes requests that include encryption headers
- Allows both encrypted and non-encrypted requests to coexist
- Useful for gradual migration or mixed environments
- Requests without encryption headers are passed through unchanged
- Strictly requires all requests to include encryption headers
- Rejects requests without proper encryption headers with a 400 error
- Ensures complete end-to-end encryption compliance
- Recommended for production environments with strict security requirements
// Non-enforced mode (default) - allows mixed requests
const middleware = createE2EEMiddleware({
config: {
keys,
enforced: false // or omit this line
}
});
// Enforced mode - requires all requests to be encrypted
const middleware = createE2EEMiddleware({
config: {
keys,
enforced: true
}
});- Development/Testing: Use non-enforced mode to test both encrypted and non-encrypted endpoints
- Gradual Migration: Start with non-enforced mode and gradually migrate clients
- Production: Use enforced mode to ensure all traffic is encrypted
- Mixed Environments: Use non-enforced mode when some clients cannot support encryption
The package provides several utility functions for key generation and encryption operations:
import {
generateKeyPair,
generateMultipleKeyPairs,
encrypt,
decrypt,
encryptAES,
decryptAES,
decryptAESKey
} from 'e2ee-adapter';
// Generate a single RSA key pair
const keyPair = await generateKeyPair(2048);
// Generate multiple key pairs for different domains
const keys = await generateMultipleKeyPairs(['domain1', 'domain2', 'domain3']);
// Encrypt data using hybrid encryption
const encrypted = await encrypt('sensitive data', publicKey);
// Decrypt data
const decrypted = await decrypt(encryptedData, encryptedKey, iv, privateKey);
// AES-only encryption/decryption
const aesEncrypted = encryptAES('data', aesKey, iv);
const aesDecrypted = decryptAES(encryptedData, aesKey, iv);
// Decrypt AES key from headers (for empty request bodies)
const { aesKey, iv } = await decryptAESKey(encryptedKey, iv, privateKey);One of the key features of this library is the ability to encrypt responses for GET requests, even when they have no request body. This is particularly useful for:
- Secure Data Retrieval: GET requests that return sensitive data
- API Endpoints: Public APIs that need to return encrypted responses
- Stateless Operations: Operations that don't require request data but need secure responses
- Client sends GET request with encryption headers (AES key encrypted with RSA)
- No request body needed - the encryption context is established via headers
- Server processes request and generates response data
- Response is automatically encrypted using the AES key from headers
- Client decrypts response using the same AES key
// Server endpoint
app.get('/api/users/:id', (req, res) => {
const user = { id: req.params.id, name: 'John Doe', email: 'john@example.com' };
res.send(user); // Automatically encrypted response
});
// Client request
const response = await client.request({
url: 'https://api.example.com/api/users/123',
method: 'GET',
keyId: 'domain1' // No data needed, but encryption headers required
});
console.log(response.data); // Automatically decrypted user dataThe library supports encrypted responses even for requests with empty bodies (like GET requests or POST requests without data). This is useful when you want to:
- GET requests with encrypted responses: Retrieve data securely without sending any request body
- POST requests without data: Submit forms or trigger actions that don't require request data
- API endpoints that only return data: Endpoints that don't accept input but should return encrypted responses
// Enable empty request body support
const middleware = createE2EEMiddleware({
config: {
keys,
allowEmptyRequestBody: true, // Enable this feature
enableRequestDecryption: true,
enableResponseEncryption: true
}
});- Client sends request with encryption headers but no request body
- Server processes request by decrypting the AES key from headers
- Server generates response and encrypts it using the decrypted AES key
- Client receives encrypted response and decrypts it
// GET request with encrypted response
app.get('/api/users', (req, res) => {
const users = [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }];
res.send(users); // Response will be automatically encrypted
});
// POST request without body but with encrypted response
app.post('/api/health-check', (req, res) => {
const status = { status: 'healthy', timestamp: Date.now() };
res.send(status); // Response will be automatically encrypted
});// GET request with encrypted response
const response = await client.request({
url: 'https://api.example.com/api/users',
method: 'GET',
keyId: 'domain1' // No data needed, but encryption headers required
});
console.log(response.data); // Automatically decrypted response
// POST request without body but with encrypted response
const healthResponse = await client.request({
url: 'https://api.example.com/api/health-check',
method: 'POST',
keyId: 'domain1' // No data needed, but encryption headers required
});
console.log(healthResponse.data); // Automatically decrypted response- Fork the repository
- Create a feature branch
- Make your changes
- Add tests
- Submit a pull request
This project is licensed under the MIT License - see the LICENSE file for details.