@2londres/crypto-ecdh
v1.0.0
Published
End-to-end encryption library (P-256 ECDH + AES-256-GCM + HKDF)
Maintainers
Readme
@2Londres/crypto-ecdh
Bibliothèque de chiffrement bout-en-bout basée sur ECDH P-256 + AES-256-GCM + HKDF. Permet d’établir une clé symétrique partagée entre un frontend (React) et un backend (NestJS) sans jamais transmettre la clé sur le réseau.
Protocole
- Frontend : génère une paire ECDH (clé privée + publique)
- Handshake : envoie sa clé publique au backend avec un
sessionId - Backend : génère sa paire, dérive la clé AES avec sa clé privée + clé publique du frontend, stocke la clé en session
- Frontend : dérive la même clé AES avec sa clé privée + clé publique du backend
- Les deux côtés partagent la même clé → chiffrement/déchiffrement bidirectionnel
Installation
pnpm add @2Londres/crypto-ecdh
# ou
npm install @2Londres/crypto-ecdh
# ou
yarn add @2Londres/crypto-ecdhDépendances : aucune. Utilise WebCrypto API (navigateur) et le module crypto natif (Node.js).
Flow ECDH
sequenceDiagram
participant Client as React
participant Server as NestJS
participant Redis as Redis
Client->>Client: generateKeyPair()
Client->>Server: POST /crypto/handshake { publicKey, sessionId }
Server->>Server: generateKeyPair()
Server->>Server: deriveSharedKey(localPriv, remotePub)
Server->>Redis: save aesKeyBase64 + session
Server->>Client: { publicKey, established }
Client->>Client: deriveSharedKey(localPriv, remotePub)
Note over Client,Server: Les deux ont la même clé AES
Client->>Client: encrypt(data, aesKey)
Client->>Server: POST /api/xxx { encrypted, tag }
Server->>Redis: get aesKey by sessionId
Server->>Server: decrypt(encrypted, tag, aesKey)Backend NestJS
Installation et imports
import { NodeCryptoAdapter } from '@2Londres/crypto-ecdh/node';
import {
encryptBody,
decryptBody,
encryptFields,
decryptFields,
isEncryptedBody,
type HandshakeRequest,
type HandshakeResponse,
type CryptoSessionData,
} from '@2Londres/crypto-ecdh';Module Crypto + Redis
Le backend doit stocker aesKeyBase64 par sessionId (Redis, Map en mémoire, etc.) :
// crypto-session.store.ts (exemple)
const sessions = new Map<string, CryptoSessionData>();
export function saveCryptoSession(data: CryptoSessionData): void {
sessions.set(data.sessionId, data);
}
export function getCryptoSession(sessionId: string): CryptoSessionData | undefined {
return sessions.get(sessionId);
}Configuration de l'endpoint handshake
L'endpoint handshake est configurable côté backend. Le path est défini au démarrage de l'app (à l'import du controller) via :
| Moment | Où définir |
|--------|-------------|
| .env | CRYPTO_HANDSHAKE_PATH=/api/v2/crypto/handshake |
| ConfigModule | configService.get<string>('CRYPTO_HANDSHAKE_PATH') injecté au lieu de process.env |
| Module.forRoot() | Si tu encapsules le CryptoModule, passe handshakePath en option |
Le frontend doit utiliser le même chemin (via config, env, ou endpoint de découverte).
Le package exporte parseHandshakePath pour dériver le path NestJS :
import { parseHandshakePath } from '@2Londres/crypto-ecdh';
// parseHandshakePath('/api/v2/crypto/handshake') → { controllerPath: 'api/v2/crypto', route: 'handshake' }Controller handshake
Validation Zod optionnelle (avec nestjs-zod ou pipe custom) :
// handshake.dto.ts
import { z } from 'zod';
export const HandshakeRequestSchema = z.object({
publicKey: z.string().min(1),
sessionId: z.string().min(1),
});// crypto.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { NodeCryptoAdapter } from '@2Londres/crypto-ecdh/node';
import {
type HandshakeRequest,
type HandshakeResponse,
parseHandshakePath,
DEFAULT_HANDSHAKE_PATH,
} from '@2Londres/crypto-ecdh';
// Path lu au chargement du module — .env chargé par ConfigModule avant le bootstrap
const { controllerPath, route } = parseHandshakePath(
process.env.CRYPTO_HANDSHAKE_PATH ?? DEFAULT_HANDSHAKE_PATH,
);
@Controller(controllerPath)
export class CryptoController {
private readonly adapter = new NodeCryptoAdapter();
constructor(private readonly cryptoSession: CryptoSessionService) {}
@Post(route)
async handshake(@Body() body: HandshakeRequest): Promise<HandshakeResponse> {
const { publicKey: clientPublicKeyBase64, sessionId } = body;
const keyPair = await this.adapter.generateKeyPair();
const aesKey = await this.adapter.deriveSharedKey(
keyPair.privateKey as import('crypto').ECDH,
clientPublicKeyBase64,
);
const now = new Date();
const ttl = 24 * 60 * 60 * 1000; // 24h
this.cryptoSession.save(sessionId, {
aesKeyBase64: (aesKey as Buffer).toString('base64'),
sessionId,
establishedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + ttl).toISOString(),
});
return {
publicKey: keyPair.publicKeyBase64,
established: true,
};
}
}Interceptor pour déchiffrer les requêtes
// crypto-decrypt.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { NodeCryptoAdapter } from '@2Londres/crypto-ecdh/node';
import { decryptBody, isEncryptedBody } from '@2Londres/crypto-ecdh';
@Injectable()
export class CryptoDecryptInterceptor implements NestInterceptor {
private readonly adapter = new NodeCryptoAdapter();
constructor(private readonly cryptoSession: CryptoSessionService) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<import('rxjs').Observable<unknown>> {
const req = context.switchToHttp().getRequest();
const body = req.body;
const sessionId = req.headers['x-session-id'] ?? req.cookies?.sessionId;
if (!sessionId || !body || !isEncryptedBody(body)) {
return next.handle();
}
const session = this.cryptoSession.get(sessionId);
if (!session) {
return next.handle(); // ou throw UnauthorizedException
}
const aesKey = Buffer.from(session.aesKeyBase64, 'base64');
const decrypted = await decryptBody(body, aesKey, this.adapter);
req.body = decrypted;
return next.handle();
}
}Chiffrer les réponses
Si la requête est chiffrée, le backend peut chiffrer la réponse de la même façon. Exemple dans un interceptor ou un décorateur :
// crypto-encrypt.interceptor.ts (pour les réponses)
const encrypted = await encryptBody(data, aesKey, adapter);
return response.send(encrypted);Frontend React
Installation et imports
import { WebCryptoAdapter } from '@2Londres/crypto-ecdh/webcrypto';
import {
encryptBody,
decryptBody,
encryptFields,
decryptFields,
isEncryptedBody,
type HandshakeResponse,
} from '@2Londres/crypto-ecdh';Hook useCryptoHandshake
L'endpoint handshake peut être configuré via handshakePath (doit correspondre au backend). Priorité : options.handshakePath > VITE_CRYPTO_HANDSHAKE_PATH (ou NEXT_PUBLIC_*) > défaut /crypto/handshake.
// useCryptoHandshake.ts
import { useState, useEffect } from 'react';
import { WebCryptoAdapter } from '@2Londres/crypto-ecdh/webcrypto';
import {
type HandshakeResponse,
DEFAULT_HANDSHAKE_PATH,
} from '@2Londres/crypto-ecdh';
interface UseCryptoHandshakeOptions {
handshakePath?: string;
}
interface UseCryptoHandshakeResult {
aesKey: CryptoKey | null;
isReady: boolean;
error: Error | null;
}
export function useCryptoHandshake(
sessionId: string | null,
baseUrl: string,
options?: UseCryptoHandshakeOptions,
): UseCryptoHandshakeResult {
const handshakePath =
options?.handshakePath ?? import.meta.env?.VITE_CRYPTO_HANDSHAKE_PATH ?? DEFAULT_HANDSHAKE_PATH;
const [aesKey, setAesKey] = useState<CryptoKey | null>(null);
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!sessionId) return;
const adapter = new WebCryptoAdapter();
const url = new URL(handshakePath, baseUrl).href;
(async () => {
try {
const keyPair = await adapter.generateKeyPair();
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
publicKey: keyPair.publicKeyBase64,
sessionId,
}),
});
const data: HandshakeResponse = await res.json();
if (!data.established || !data.publicKey) {
throw new Error('Handshake failed');
}
const derivedKey = await adapter.deriveSharedKey(
keyPair.privateKey as CryptoKey,
data.publicKey,
);
setAesKey(derivedKey);
setIsReady(true);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
}
})();
}, [sessionId, baseUrl, handshakePath]);
return { aesKey, isReady, error };
}Client HTTP avec chiffrement automatique
// api-client.ts
import { WebCryptoAdapter } from '@2Londres/crypto-ecdh/webcrypto';
import { encryptBody, decryptBody, isEncryptedBody } from '@2Londres/crypto-ecdh';
const adapter = new WebCryptoAdapter();
export function createEncryptedFetch(
baseUrl: string,
sessionId: string,
aesKey: CryptoKey,
) {
return async function encryptedFetch<T>(
path: string,
options: RequestInit & { body?: unknown } = {},
): Promise<T> {
const { body, ...init } = options;
let finalBody: string | undefined;
if (body !== undefined && aesKey) {
const { encrypted, tag } = await encryptBody(body, aesKey, adapter);
finalBody = JSON.stringify({ encrypted, tag });
} else if (body !== undefined) {
finalBody = JSON.stringify(body);
}
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
'X-Session-Id': sessionId,
...init.headers,
},
body: finalBody,
});
const json = await res.json();
if (isEncryptedBody(json)) {
return decryptBody<T>(json, aesKey, adapter);
}
return json as T;
};
}Exemple avec encryptFields
Pour chiffrer uniquement certains champs (ex. email, téléphone) :
// Côté React — avant envoi
import { WebCryptoAdapter } from '@2Londres/crypto-ecdh/webcrypto';
import { encryptFields } from '@2Londres/crypto-ecdh';
const adapter = new WebCryptoAdapter();
const payload = await encryptFields(
{ name: 'Alice', email: '[email protected]', phone: '+33612345678' },
['email', 'phone'],
aesKey,
adapter,
);
// → { name: 'Alice', email: { _enc: true, v: '...', t: '...' }, phone: { _enc: true, v: '...', t: '...' } }
await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(payload),
});// Côté NestJS — dans le controller
import { NodeCryptoAdapter } from '@2Londres/crypto-ecdh/node';
import { decryptFields } from '@2Londres/crypto-ecdh';
const adapter = new NodeCryptoAdapter();
const aesKey = Buffer.from(session.aesKeyBase64, 'base64');
const decrypted = await decryptFields(req.body, aesKey, adapter);
// → { name: 'Alice', email: '[email protected]', phone: '+33612345678' }Exemples complets
Backend NestJS minimal
// crypto.module.ts
import { Module } from '@nestjs/common';
import { CryptoController } from './crypto.controller';
import { CryptoSessionService } from './crypto-session.service';
@Module({
controllers: [CryptoController],
providers: [CryptoSessionService],
exports: [CryptoSessionService],
})
export class CryptoModule {}// crypto-session.service.ts
import { Injectable } from '@nestjs/common';
import type { CryptoSessionData } from '@2Londres/crypto-ecdh';
@Injectable()
export class CryptoSessionService {
private readonly store = new Map<string, CryptoSessionData>();
save(sessionId: string, data: CryptoSessionData): void {
this.store.set(sessionId, data);
}
get(sessionId: string): CryptoSessionData | undefined {
return this.store.get(sessionId);
}
}Frontend React minimal
// App.tsx
import { useCryptoHandshake } from './useCryptoHandshake';
import { createEncryptedFetch } from './api-client';
function App() {
const sessionId = 'xxx'; // depuis ton système de session
const { aesKey, isReady } = useCryptoHandshake(
sessionId,
'https://api.example.com',
{ handshakePath: '/api/v2/crypto/handshake' }, // optionnel, si différent du défaut
);
const handleSubmit = async () => {
if (!aesKey || !isReady) return;
const api = createEncryptedFetch(
'https://api.example.com',
sessionId,
aesKey,
);
const user = await api<User>('/users', {
method: 'POST',
body: { email: '[email protected]', name: 'John' },
});
console.log(user);
};
return (
<button onClick={handleSubmit} disabled={!isReady}>
Envoyer (chiffré)
</button>
);
}Considérations
| Point | Détail |
|-------|--------|
| Endpoint handshake | Configurable via CRYPTO_HANDSHAKE_PATH (backend) et handshakePath ou env (frontend). Le frontend et backend doivent utiliser le même path |
| Session | Associer une session (cookie, header X-Session-Id) au sessionId du handshake |
| Clé privée frontend | WebCrypto génère des clés non-extractables — un XSS peut utiliser la clé mais pas l'exfiltrer |
| Compatibilité | HKDF et salt sont identiques sur les deux adapters (pas de config nécessaire) |
| Rotation | Refaire un handshake périodiquement ou à la déconnexion |
Types et utilitaires exportés
parseHandshakePath,DEFAULT_HANDSHAKE_PATH— configuration de l'endpoint NestJSHandshakeRequest,HandshakeResponseEncryptedBody,EncryptedField,MaybeEncryptedisEncryptedBody,isEncryptedFieldCryptoSessionDataICryptoAdapter,CryptoKeyPairExport,CryptoKeyOrBuffer
Références
src/adapters/web-crypto.adapter.ts— WebCrypto (React)src/adapters/node-crypto.adapter.ts— Node crypto (NestJS)src/protocol/encrypt-body.ts— encryptBody / decryptBodysrc/protocol/encrypt-fields.ts— encryptFields / decryptFieldssrc/types/handshake.ts— HandshakeRequest / HandshakeResponse
