{"_id":"@2londres/crypto-ecdh","name":"@2londres/crypto-ecdh","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"name":"@2londres/crypto-ecdh","version":"1.0.0","description":"End-to-end encryption library (P-256 ECDH + AES-256-GCM + HKDF)","main":"dist/index.js","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.js"},"./webcrypto":{"types":"./dist/adapters/web-crypto.adapter.d.ts","import":"./dist/adapters/web-crypto.adapter.js","require":"./dist/adapters/web-crypto.adapter.js"},"./node":{"types":"./dist/adapters/node-crypto.adapter.d.ts","import":"./dist/adapters/node-crypto.adapter.js","require":"./dist/adapters/node-crypto.adapter.js"}},"scripts":{"build":"tsc","clean":"rm -rf dist","test":"vitest run","prepare":"tsc"},"keywords":["crypto","ecdh","aes-gcm","hkdf","e2e-encryption"],"author":{"name":"2Londres"},"license":"ISC","packageManager":"pnpm@10.15.0","devDependencies":{"@types/node":"^25.4.0","typescript":"^5.9.3","vitest":"^4.0.18"},"_id":"@2londres/crypto-ecdh@1.0.0","_nodeVersion":"25.8.0","_npmVersion":"11.11.0","dist":{"integrity":"sha512-1oV7XwUBpLle8wFtowIi4+70sycymBXOXj3p95bEcnkDndWzA+cGWqabSa97KUwH2gCX5CeuLsPpMX9UERuhNQ==","shasum":"5855cde57759ed9122a228c5c77ecc4dc76eff78","tarball":"https://registry.npmjs.org/@2londres/crypto-ecdh/-/crypto-ecdh-1.0.0.tgz","fileCount":70,"unpackedSize":64818,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQD1jANOJ9LFqN6mOgR29ZYOi/iQp6oRgxMj0jHqzsw23wIhAJZml7ubVEWW1iv15cNnWDRIl60BH1RAOfrQQZDq8AUT"}]},"_npmUser":{"name":"martio007","email":"managerboah@gmail.com"},"directories":{},"maintainers":[{"name":"martio007","email":"managerboah@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/crypto-ecdh_1.0.0_1773413118028_0.25955777919352685"},"_hasShrinkwrap":false}},"time":{"created":"2026-03-13T14:45:17.955Z","1.0.0":"2026-03-13T14:45:18.198Z","modified":"2026-03-13T14:45:18.364Z"},"maintainers":[{"name":"martio007","email":"managerboah@gmail.com"}],"description":"End-to-end encryption library (P-256 ECDH + AES-256-GCM + HKDF)","keywords":["crypto","ecdh","aes-gcm","hkdf","e2e-encryption"],"author":{"name":"2Londres"},"license":"ISC","readme":"# @2Londres/crypto-ecdh\n\nBibliothè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.\n\n## Protocole\n\n1. **Frontend** : génère une paire ECDH (clé privée + publique)\n2. **Handshake** : envoie sa clé publique au backend avec un `sessionId`\n3. **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\n4. **Frontend** : dérive la même clé AES avec sa clé privée + clé publique du backend\n5. Les deux côtés partagent la même clé → chiffrement/déchiffrement bidirectionnel\n\n## Installation\n\n```bash\npnpm add @2Londres/crypto-ecdh\n# ou\nnpm install @2Londres/crypto-ecdh\n# ou\nyarn add @2Londres/crypto-ecdh\n```\n\n**Dépendances** : aucune. Utilise WebCrypto API (navigateur) et le module `crypto` natif (Node.js).\n\n## Flow ECDH\n\n```mermaid\nsequenceDiagram\n    participant Client as React\n    participant Server as NestJS\n    participant Redis as Redis\n\n    Client->>Client: generateKeyPair()\n    Client->>Server: POST /crypto/handshake { publicKey, sessionId }\n    Server->>Server: generateKeyPair()\n    Server->>Server: deriveSharedKey(localPriv, remotePub)\n    Server->>Redis: save aesKeyBase64 + session\n    Server->>Client: { publicKey, established }\n\n    Client->>Client: deriveSharedKey(localPriv, remotePub)\n    Note over Client,Server: Les deux ont la même clé AES\n\n    Client->>Client: encrypt(data, aesKey)\n    Client->>Server: POST /api/xxx { encrypted, tag }\n    Server->>Redis: get aesKey by sessionId\n    Server->>Server: decrypt(encrypted, tag, aesKey)\n```\n\n---\n\n## Backend NestJS\n\n### Installation et imports\n\n```ts\nimport { NodeCryptoAdapter } from '@2Londres/crypto-ecdh/node';\nimport {\n  encryptBody,\n  decryptBody,\n  encryptFields,\n  decryptFields,\n  isEncryptedBody,\n  type HandshakeRequest,\n  type HandshakeResponse,\n  type CryptoSessionData,\n} from '@2Londres/crypto-ecdh';\n```\n\n### Module Crypto + Redis\n\nLe backend doit stocker `aesKeyBase64` par `sessionId` (Redis, Map en mémoire, etc.) :\n\n```ts\n// crypto-session.store.ts (exemple)\nconst sessions = new Map<string, CryptoSessionData>();\n\nexport function saveCryptoSession(data: CryptoSessionData): void {\n  sessions.set(data.sessionId, data);\n}\n\nexport function getCryptoSession(sessionId: string): CryptoSessionData | undefined {\n  return sessions.get(sessionId);\n}\n```\n\n### Configuration de l'endpoint handshake\n\nL'endpoint handshake est **configurable côté backend**. Le path est défini **au démarrage** de l'app (à l'import du controller) via :\n\n| Moment | Où définir |\n|--------|-------------|\n| **.env** | `CRYPTO_HANDSHAKE_PATH=/api/v2/crypto/handshake` |\n| **ConfigModule** | `configService.get<string>('CRYPTO_HANDSHAKE_PATH')` injecté au lieu de `process.env` |\n| **Module.forRoot()** | Si tu encapsules le CryptoModule, passe `handshakePath` en option |\n\nLe frontend doit utiliser le même chemin (via config, env, ou endpoint de découverte).\n\nLe package exporte `parseHandshakePath` pour dériver le path NestJS :\n\n```ts\nimport { parseHandshakePath } from '@2Londres/crypto-ecdh';\n\n// parseHandshakePath('/api/v2/crypto/handshake') → { controllerPath: 'api/v2/crypto', route: 'handshake' }\n```\n\n### Controller handshake\n\nValidation Zod optionnelle (avec `nestjs-zod` ou pipe custom) :\n\n```ts\n// handshake.dto.ts\nimport { z } from 'zod';\n\nexport const HandshakeRequestSchema = z.object({\n  publicKey: z.string().min(1),\n  sessionId: z.string().min(1),\n});\n```\n\n```ts\n// crypto.controller.ts\nimport { Body, Controller, Post } from '@nestjs/common';\nimport { NodeCryptoAdapter } from '@2Londres/crypto-ecdh/node';\nimport {\n  type HandshakeRequest,\n  type HandshakeResponse,\n  parseHandshakePath,\n  DEFAULT_HANDSHAKE_PATH,\n} from '@2Londres/crypto-ecdh';\n\n// Path lu au chargement du module — .env chargé par ConfigModule avant le bootstrap\nconst { controllerPath, route } = parseHandshakePath(\n  process.env.CRYPTO_HANDSHAKE_PATH ?? DEFAULT_HANDSHAKE_PATH,\n);\n\n@Controller(controllerPath)\nexport class CryptoController {\n  private readonly adapter = new NodeCryptoAdapter();\n\n  constructor(private readonly cryptoSession: CryptoSessionService) {}\n\n  @Post(route)\n  async handshake(@Body() body: HandshakeRequest): Promise<HandshakeResponse> {\n    const { publicKey: clientPublicKeyBase64, sessionId } = body;\n\n    const keyPair = await this.adapter.generateKeyPair();\n    const aesKey = await this.adapter.deriveSharedKey(\n      keyPair.privateKey as import('crypto').ECDH,\n      clientPublicKeyBase64,\n    );\n\n    const now = new Date();\n    const ttl = 24 * 60 * 60 * 1000; // 24h\n\n    this.cryptoSession.save(sessionId, {\n      aesKeyBase64: (aesKey as Buffer).toString('base64'),\n      sessionId,\n      establishedAt: now.toISOString(),\n      expiresAt: new Date(now.getTime() + ttl).toISOString(),\n    });\n\n    return {\n      publicKey: keyPair.publicKeyBase64,\n      established: true,\n    };\n  }\n}\n```\n\n### Interceptor pour déchiffrer les requêtes\n\n```ts\n// crypto-decrypt.interceptor.ts\nimport {\n  Injectable,\n  NestInterceptor,\n  ExecutionContext,\n  CallHandler,\n} from '@nestjs/common';\nimport { NodeCryptoAdapter } from '@2Londres/crypto-ecdh/node';\nimport { decryptBody, isEncryptedBody } from '@2Londres/crypto-ecdh';\n\n@Injectable()\nexport class CryptoDecryptInterceptor implements NestInterceptor {\n  private readonly adapter = new NodeCryptoAdapter();\n\n  constructor(private readonly cryptoSession: CryptoSessionService) {}\n\n  async intercept(\n    context: ExecutionContext,\n    next: CallHandler,\n  ): Promise<import('rxjs').Observable<unknown>> {\n    const req = context.switchToHttp().getRequest();\n    const body = req.body;\n    const sessionId = req.headers['x-session-id'] ?? req.cookies?.sessionId;\n\n    if (!sessionId || !body || !isEncryptedBody(body)) {\n      return next.handle();\n    }\n\n    const session = this.cryptoSession.get(sessionId);\n    if (!session) {\n      return next.handle(); // ou throw UnauthorizedException\n    }\n\n    const aesKey = Buffer.from(session.aesKeyBase64, 'base64');\n    const decrypted = await decryptBody(body, aesKey, this.adapter);\n    req.body = decrypted;\n\n    return next.handle();\n  }\n}\n```\n\n### Chiffrer les réponses\n\nSi 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 :\n\n```ts\n// crypto-encrypt.interceptor.ts (pour les réponses)\nconst encrypted = await encryptBody(data, aesKey, adapter);\nreturn response.send(encrypted);\n```\n\n---\n\n## Frontend React\n\n### Installation et imports\n\n```ts\nimport { WebCryptoAdapter } from '@2Londres/crypto-ecdh/webcrypto';\nimport {\n  encryptBody,\n  decryptBody,\n  encryptFields,\n  decryptFields,\n  isEncryptedBody,\n  type HandshakeResponse,\n} from '@2Londres/crypto-ecdh';\n```\n\n### Hook `useCryptoHandshake`\n\nL'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`.\n\n```ts\n// useCryptoHandshake.ts\nimport { useState, useEffect } from 'react';\nimport { WebCryptoAdapter } from '@2Londres/crypto-ecdh/webcrypto';\nimport {\n  type HandshakeResponse,\n  DEFAULT_HANDSHAKE_PATH,\n} from '@2Londres/crypto-ecdh';\n\ninterface UseCryptoHandshakeOptions {\n  handshakePath?: string;\n}\n\ninterface UseCryptoHandshakeResult {\n  aesKey: CryptoKey | null;\n  isReady: boolean;\n  error: Error | null;\n}\n\nexport function useCryptoHandshake(\n  sessionId: string | null,\n  baseUrl: string,\n  options?: UseCryptoHandshakeOptions,\n): UseCryptoHandshakeResult {\n  const handshakePath =\n    options?.handshakePath ?? import.meta.env?.VITE_CRYPTO_HANDSHAKE_PATH ?? DEFAULT_HANDSHAKE_PATH;\n\n  const [aesKey, setAesKey] = useState<CryptoKey | null>(null);\n  const [isReady, setIsReady] = useState(false);\n  const [error, setError] = useState<Error | null>(null);\n\n  useEffect(() => {\n    if (!sessionId) return;\n\n    const adapter = new WebCryptoAdapter();\n    const url = new URL(handshakePath, baseUrl).href;\n\n    (async () => {\n      try {\n        const keyPair = await adapter.generateKeyPair();\n\n        const res = await fetch(url, {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            publicKey: keyPair.publicKeyBase64,\n            sessionId,\n          }),\n        });\n\n        const data: HandshakeResponse = await res.json();\n        if (!data.established || !data.publicKey) {\n          throw new Error('Handshake failed');\n        }\n\n        const derivedKey = await adapter.deriveSharedKey(\n          keyPair.privateKey as CryptoKey,\n          data.publicKey,\n        );\n\n        setAesKey(derivedKey);\n        setIsReady(true);\n      } catch (e) {\n        setError(e instanceof Error ? e : new Error(String(e)));\n      }\n    })();\n  }, [sessionId, baseUrl, handshakePath]);\n\n  return { aesKey, isReady, error };\n}\n```\n\n### Client HTTP avec chiffrement automatique\n\n```ts\n// api-client.ts\nimport { WebCryptoAdapter } from '@2Londres/crypto-ecdh/webcrypto';\nimport { encryptBody, decryptBody, isEncryptedBody } from '@2Londres/crypto-ecdh';\n\nconst adapter = new WebCryptoAdapter();\n\nexport function createEncryptedFetch(\n  baseUrl: string,\n  sessionId: string,\n  aesKey: CryptoKey,\n) {\n  return async function encryptedFetch<T>(\n    path: string,\n    options: RequestInit & { body?: unknown } = {},\n  ): Promise<T> {\n    const { body, ...init } = options;\n\n    let finalBody: string | undefined;\n    if (body !== undefined && aesKey) {\n      const { encrypted, tag } = await encryptBody(body, aesKey, adapter);\n      finalBody = JSON.stringify({ encrypted, tag });\n    } else if (body !== undefined) {\n      finalBody = JSON.stringify(body);\n    }\n\n    const res = await fetch(`${baseUrl}${path}`, {\n      ...init,\n      headers: {\n        'Content-Type': 'application/json',\n        'X-Session-Id': sessionId,\n        ...init.headers,\n      },\n      body: finalBody,\n    });\n\n    const json = await res.json();\n\n    if (isEncryptedBody(json)) {\n      return decryptBody<T>(json, aesKey, adapter);\n    }\n\n    return json as T;\n  };\n}\n```\n\n### Exemple avec `encryptFields`\n\nPour chiffrer uniquement certains champs (ex. email, téléphone) :\n\n```ts\n// Côté React — avant envoi\nimport { WebCryptoAdapter } from '@2Londres/crypto-ecdh/webcrypto';\nimport { encryptFields } from '@2Londres/crypto-ecdh';\n\nconst adapter = new WebCryptoAdapter();\nconst payload = await encryptFields(\n  { name: 'Alice', email: 'alice@example.com', phone: '+33612345678' },\n  ['email', 'phone'],\n  aesKey,\n  adapter,\n);\n// → { name: 'Alice', email: { _enc: true, v: '...', t: '...' }, phone: { _enc: true, v: '...', t: '...' } }\n\nawait fetch('/api/users', {\n  method: 'POST',\n  body: JSON.stringify(payload),\n});\n```\n\n```ts\n// Côté NestJS — dans le controller\nimport { NodeCryptoAdapter } from '@2Londres/crypto-ecdh/node';\nimport { decryptFields } from '@2Londres/crypto-ecdh';\n\nconst adapter = new NodeCryptoAdapter();\nconst aesKey = Buffer.from(session.aesKeyBase64, 'base64');\nconst decrypted = await decryptFields(req.body, aesKey, adapter);\n// → { name: 'Alice', email: 'alice@example.com', phone: '+33612345678' }\n```\n\n---\n\n## Exemples complets\n\n### Backend NestJS minimal\n\n```ts\n// crypto.module.ts\nimport { Module } from '@nestjs/common';\nimport { CryptoController } from './crypto.controller';\nimport { CryptoSessionService } from './crypto-session.service';\n\n@Module({\n  controllers: [CryptoController],\n  providers: [CryptoSessionService],\n  exports: [CryptoSessionService],\n})\nexport class CryptoModule {}\n```\n\n```ts\n// crypto-session.service.ts\nimport { Injectable } from '@nestjs/common';\nimport type { CryptoSessionData } from '@2Londres/crypto-ecdh';\n\n@Injectable()\nexport class CryptoSessionService {\n  private readonly store = new Map<string, CryptoSessionData>();\n\n  save(sessionId: string, data: CryptoSessionData): void {\n    this.store.set(sessionId, data);\n  }\n\n  get(sessionId: string): CryptoSessionData | undefined {\n    return this.store.get(sessionId);\n  }\n}\n```\n\n### Frontend React minimal\n\n```tsx\n// App.tsx\nimport { useCryptoHandshake } from './useCryptoHandshake';\nimport { createEncryptedFetch } from './api-client';\n\nfunction App() {\n  const sessionId = 'xxx'; // depuis ton système de session\n  const { aesKey, isReady } = useCryptoHandshake(\n    sessionId,\n    'https://api.example.com',\n    { handshakePath: '/api/v2/crypto/handshake' }, // optionnel, si différent du défaut\n  );\n\n  const handleSubmit = async () => {\n    if (!aesKey || !isReady) return;\n\n    const api = createEncryptedFetch(\n      'https://api.example.com',\n      sessionId,\n      aesKey,\n    );\n\n    const user = await api<User>('/users', {\n      method: 'POST',\n      body: { email: 'user@example.com', name: 'John' },\n    });\n    console.log(user);\n  };\n\n  return (\n    <button onClick={handleSubmit} disabled={!isReady}>\n      Envoyer (chiffré)\n    </button>\n  );\n}\n```\n\n---\n\n## Considérations\n\n| Point | Détail |\n|-------|--------|\n| **Endpoint handshake** | Configurable via `CRYPTO_HANDSHAKE_PATH` (backend) et `handshakePath` ou env (frontend). Le frontend et backend doivent utiliser le même path |\n| **Session** | Associer une session (cookie, header `X-Session-Id`) au `sessionId` du handshake |\n| **Clé privée frontend** | WebCrypto génère des clés non-extractables — un XSS peut utiliser la clé mais pas l'exfiltrer |\n| **Compatibilité** | HKDF et salt sont identiques sur les deux adapters (pas de config nécessaire) |\n| **Rotation** | Refaire un handshake périodiquement ou à la déconnexion |\n\n## Types et utilitaires exportés\n\n- `parseHandshakePath`, `DEFAULT_HANDSHAKE_PATH` — configuration de l'endpoint NestJS\n- `HandshakeRequest`, `HandshakeResponse`\n- `EncryptedBody`, `EncryptedField`, `MaybeEncrypted`\n- `isEncryptedBody`, `isEncryptedField`\n- `CryptoSessionData`\n- `ICryptoAdapter`, `CryptoKeyPairExport`, `CryptoKeyOrBuffer`\n\n## Références\n\n- `src/adapters/web-crypto.adapter.ts` — WebCrypto (React)\n- `src/adapters/node-crypto.adapter.ts` — Node crypto (NestJS)\n- `src/protocol/encrypt-body.ts` — encryptBody / decryptBody\n- `src/protocol/encrypt-fields.ts` — encryptFields / decryptFields\n- `src/types/handshake.ts` — HandshakeRequest / HandshakeResponse\n","readmeFilename":"README.md","_rev":"1-f889b145f305b0066282ac529ba72662"}