npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@2londres/crypto-ecdh

v1.0.0

Published

End-to-end encryption library (P-256 ECDH + AES-256-GCM + HKDF)

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

  1. Frontend : génère une paire ECDH (clé privée + publique)
  2. Handshake : envoie sa clé publique au backend avec un sessionId
  3. 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
  4. Frontend : dérive la même clé AES avec sa clé privée + clé publique du backend
  5. 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-ecdh

Dé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 NestJS
  • HandshakeRequest, HandshakeResponse
  • EncryptedBody, EncryptedField, MaybeEncrypted
  • isEncryptedBody, isEncryptedField
  • CryptoSessionData
  • ICryptoAdapter, 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 / decryptBody
  • src/protocol/encrypt-fields.ts — encryptFields / decryptFields
  • src/types/handshake.ts — HandshakeRequest / HandshakeResponse