Aller au contenu

PD-97 — Plan d'implémentation détaillé

Pipeline cryptographique client-side Zero-Knowledge (TypeScript)


Vue d'ensemble

Aspect Détail
Durée estimée 2-3 sprints (4-6 semaines)
Complexité Moyenne-Haute
Dépendances PD-24 (SRP Auth) ✅
Bloque PD-110 (PRE), PD-98 (Sync cloud)
Stack TypeScript + @noble/hashes + WebCrypto

Note: Migration Rust native prévue ultérieurement pour optimisation performance.


Librairies TypeScript

{
  "dependencies": {
    "@noble/hashes": "^1.3.3",      // SHA3, HKDF, HMAC
    "@noble/ciphers": "^0.4.1",     // AES-GCM
    "argon2-browser": "^1.18.0",    // Argon2id (WASM)
    "expo-crypto": "~12.8.0",       // Random bytes
    "expo-secure-store": "~12.8.1"  // Stockage sécurisé
  }
}

Phase 1 : Module Crypto Core (Sprint 1)

1.1 Structure des fichiers

src/
├── crypto/
│   ├── index.ts                 # Exports publics
│   ├── constants.ts             # Constantes crypto
│   ├── types.ts                 # Types TypeScript
│   ├── argon2.ts                # Dérivation K_encryption
│   ├── hkdf.ts                  # HKDF-SHA3-256
│   ├── aes-gcm.ts               # Chiffrement AES-256-GCM
│   ├── envelope.ts              # Master Envelope
│   ├── keys.ts                  # Gestion hiérarchie clés
│   ├── zeroize.ts               # Effacement mémoire
│   └── __tests__/
│       ├── argon2.test.ts
│       ├── hkdf.test.ts
│       ├── aes-gcm.test.ts
│       ├── envelope.test.ts
│       └── vectors.test.ts      # Tests vectors officiels

1.2 Constantes et Types

Fichier: src/crypto/constants.ts

// Argon2id parameters (RFC 9106)
export const ARGON2_TIME_COST = 3;
export const ARGON2_MEMORY_COST = 65536; // 64 MiB
export const ARGON2_PARALLELISM = 4;
export const ARGON2_HASH_LENGTH = 32;
export const ARGON2_SALT_LENGTH = 16;

// Context strings
export const CONTEXT_ENCRYPTION = "ProbatioVault_Encryption_v1";
export const CONTEXT_SHARE = "ProbatioVault::Share::v1";
export const CONTEXT_KDOC = "ProbatioVault::Kdoc::v1";

// AES-GCM
export const AES_KEY_LENGTH = 32;     // 256 bits
export const AES_NONCE_LENGTH = 12;   // 96 bits
export const AES_TAG_LENGTH = 16;     // 128 bits

// Envelope
export const ENVELOPE_VERSION = 1;

Fichier: src/crypto/types.ts

export interface MasterEnvelope {
  version: number;
  salt: string;        // hex, 16 bytes
  nonce: string;       // hex, 12 bytes
  ciphertext: string;  // hex
  tag: string;         // hex, 16 bytes
}

export interface EncryptedData {
  ciphertext: Uint8Array;
  nonce: Uint8Array;
  tag: Uint8Array;
}

export interface DocumentAAD {
  docId: string;
  mimeType: string;
  version: number;
}

export interface EncryptedDocument {
  ciphertext: string;  // base64
  nonce: string;       // hex
  tag: string;         // hex
  aad: DocumentAAD;
}

1.3 Tâches Argon2id

ID Tâche Priorité Effort
1.3.1 Installer et configurer argon2-browser P0 1h
1.3.2 Wrapper async deriveKEncryption() P0 2h
1.3.3 Gestion du chargement WASM P0 2h
1.3.4 Tests avec vectors RFC 9106 P0 2h
1.3.5 Fallback/error handling P1 1h

Fichier: src/crypto/argon2.ts

import argon2 from "argon2-browser";
import {
  ARGON2_TIME_COST,
  ARGON2_MEMORY_COST,
  ARGON2_PARALLELISM,
  ARGON2_HASH_LENGTH,
  CONTEXT_ENCRYPTION,
} from "./constants";
import { hexToBytes, bytesToHex } from "./utils";

/**
 * Dérive K_encryption depuis le mot de passe
 * Utilisé pour ouvrir/fermer la Master Envelope
 *
 * @param password - Mot de passe utilisateur (UTF-8)
 * @param salt - Salt 16 bytes (hex string)
 * @returns K_encryption 32 bytes (hex string)
 */
export async function deriveKEncryption(
  password: string,
  salt: string
): Promise<string> {
  const saltBytes = hexToBytes(salt);

  const result = await argon2.hash({
    pass: password,
    salt: saltBytes,
    time: ARGON2_TIME_COST,
    mem: ARGON2_MEMORY_COST,
    parallelism: ARGON2_PARALLELISM,
    hashLen: ARGON2_HASH_LENGTH,
    type: argon2.ArgonType.Argon2id,
    // Associated data pour domain separation
    ad: new TextEncoder().encode(CONTEXT_ENCRYPTION),
  });

  return bytesToHex(result.hash);
}

/**
 * Génère un salt aléatoire pour Argon2id
 */
export function generateSalt(): string {
  const salt = new Uint8Array(16);
  crypto.getRandomValues(salt);
  return bytesToHex(salt);
}

1.4 Tâches HKDF-SHA3-256

ID Tâche Priorité Effort
1.4.1 Implémenter HKDF-Extract avec HMAC-SHA3-256 P0 2h
1.4.2 Implémenter HKDF-Expand P0 2h
1.4.3 deriveKShare(kMaster) P0 1h
1.4.4 deriveKDoc(kShare, docId) P0 1h
1.4.5 Tests vectors HKDF P0 2h

Fichier: src/crypto/hkdf.ts

import { sha3_256 } from "@noble/hashes/sha3";
import { hmac } from "@noble/hashes/hmac";
import { CONTEXT_SHARE, CONTEXT_KDOC } from "./constants";
import { hexToBytes, bytesToHex } from "./utils";

/**
 * HKDF-Extract: PRK = HMAC-SHA3-256(salt, IKM)
 */
function hkdfExtract(salt: Uint8Array, ikm: Uint8Array): Uint8Array {
  return hmac(sha3_256, salt, ikm);
}

/**
 * HKDF-Expand: OKM = HKDF-Expand(PRK, info, L)
 */
function hkdfExpand(
  prk: Uint8Array,
  info: Uint8Array,
  length: number
): Uint8Array {
  const hashLen = 32; // SHA3-256 output
  const n = Math.ceil(length / hashLen);
  const okm = new Uint8Array(n * hashLen);

  let t = new Uint8Array(0);

  for (let i = 1; i <= n; i++) {
    const input = new Uint8Array(t.length + info.length + 1);
    input.set(t, 0);
    input.set(info, t.length);
    input[t.length + info.length] = i;

    t = hmac(sha3_256, prk, input);
    okm.set(t, (i - 1) * hashLen);
  }

  return okm.slice(0, length);
}

/**
 * Dérive K_share_user depuis K_master_user
 * Niveau 3 de la hiérarchie - préparé pour PRE
 *
 * @param kMasterHex - K_master_user (32 bytes hex)
 * @returns K_share_user (32 bytes hex)
 */
export function deriveKShare(kMasterHex: string): string {
  const kMaster = hexToBytes(kMasterHex);
  const salt = new TextEncoder().encode(CONTEXT_SHARE);

  // PRK = HMAC-SHA3-256(salt, K_master)
  const prk = hkdfExtract(salt, kMaster);

  // K_share = HKDF-Expand(PRK, info="", L=32)
  const kShare = hkdfExpand(prk, new Uint8Array(0), 32);

  // Zeroize intermediate values
  prk.fill(0);
  kMaster.fill(0);

  return bytesToHex(kShare);
}

/**
 * Dérive K_doc depuis K_share_user et doc_id
 * Niveau 4 - Une clé unique par document
 *
 * @param kShareHex - K_share_user (32 bytes hex)
 * @param docId - Identifiant unique du document
 * @returns K_doc (32 bytes hex)
 */
export function deriveKDoc(kShareHex: string, docId: string): string {
  const kShare = hexToBytes(kShareHex);
  const docIdBytes = new TextEncoder().encode(docId);
  const info = new TextEncoder().encode(CONTEXT_KDOC);

  // PRK = HMAC-SHA3-256(doc_id, K_share)
  const prk = hkdfExtract(docIdBytes, kShare);

  // K_doc = HKDF-Expand(PRK, info, L=32)
  const kDoc = hkdfExpand(prk, info, 32);

  // Zeroize
  prk.fill(0);
  kShare.fill(0);

  return bytesToHex(kDoc);
}

1.5 Tâches AES-256-GCM

ID Tâche Priorité Effort
1.5.1 Implémenter encrypt() avec @noble/ciphers P0 2h
1.5.2 Implémenter decrypt() avec validation P0 2h
1.5.3 Support AAD (doc_id, mime, version) P0 1h
1.5.4 Tests vectors NIST SP800-38D P0 2h
1.5.5 Tests rejection (tag/nonce/aad modifiés) P0 2h

Fichier: src/crypto/aes-gcm.ts

import { gcm } from "@noble/ciphers/aes";
import { randomBytes } from "@noble/ciphers/webcrypto";
import { AES_NONCE_LENGTH } from "./constants";
import { DocumentAAD, EncryptedData } from "./types";
import { hexToBytes, bytesToHex } from "./utils";

/**
 * Chiffre des données avec AES-256-GCM
 *
 * @param plaintext - Données à chiffrer
 * @param keyHex - Clé AES 32 bytes (hex)
 * @param aad - Additional Authenticated Data (optionnel)
 * @returns Données chiffrées avec nonce et tag
 */
export function encrypt(
  plaintext: Uint8Array,
  keyHex: string,
  aad?: DocumentAAD
): EncryptedData {
  const key = hexToBytes(keyHex);
  const nonce = randomBytes(AES_NONCE_LENGTH);

  // Sérialiser AAD si présent
  const aadBytes = aad
    ? new TextEncoder().encode(JSON.stringify(aad))
    : new Uint8Array(0);

  const cipher = gcm(key, nonce, aadBytes);
  const ciphertextWithTag = cipher.encrypt(plaintext);

  // Séparer ciphertext et tag (tag = 16 derniers bytes)
  const ciphertext = ciphertextWithTag.slice(0, -16);
  const tag = ciphertextWithTag.slice(-16);

  // Zeroize key
  key.fill(0);

  return { ciphertext, nonce, tag };
}

/**
 * Déchiffre des données avec AES-256-GCM
 *
 * @param encrypted - Données chiffrées
 * @param keyHex - Clé AES 32 bytes (hex)
 * @param aad - Additional Authenticated Data (doit correspondre)
 * @returns Données déchiffrées
 * @throws Si tag invalide ou données corrompues
 */
export function decrypt(
  encrypted: EncryptedData,
  keyHex: string,
  aad?: DocumentAAD
): Uint8Array {
  const key = hexToBytes(keyHex);

  const aadBytes = aad
    ? new TextEncoder().encode(JSON.stringify(aad))
    : new Uint8Array(0);

  // Reconstruire ciphertext + tag
  const ciphertextWithTag = new Uint8Array(
    encrypted.ciphertext.length + encrypted.tag.length
  );
  ciphertextWithTag.set(encrypted.ciphertext, 0);
  ciphertextWithTag.set(encrypted.tag, encrypted.ciphertext.length);

  const cipher = gcm(key, encrypted.nonce, aadBytes);

  try {
    const plaintext = cipher.decrypt(ciphertextWithTag);
    key.fill(0);
    return plaintext;
  } catch (error) {
    key.fill(0);
    throw new Error("Decryption failed: invalid tag or corrupted data");
  }
}

1.6 Tâches Master Envelope

ID Tâche Priorité Effort
1.6.1 createEnvelope(password) - Inscription P0 2h
1.6.2 openEnvelope(password, envelope) - Login P0 2h
1.6.3 rewrapEnvelope(oldPwd, newPwd, envelope) P0 2h
1.6.4 Sérialisation JSON de l'enveloppe P0 1h
1.6.5 Tests round-trip complets P0 2h

Fichier: src/crypto/envelope.ts

import { randomBytes } from "@noble/ciphers/webcrypto";
import { deriveKEncryption, generateSalt } from "./argon2";
import { encrypt, decrypt } from "./aes-gcm";
import { MasterEnvelope } from "./types";
import { ENVELOPE_VERSION } from "./constants";
import { bytesToHex, hexToBytes } from "./utils";

/**
 * Crée une nouvelle Master Envelope (inscription)
 * Génère K_master aléatoire et le chiffre avec K_encryption
 *
 * @param password - Mot de passe utilisateur
 * @returns K_master (hex) et l'enveloppe chiffrée
 */
export async function createEnvelope(
  password: string
): Promise<{ kMaster: string; envelope: MasterEnvelope }> {
  // 1. Générer salt aléatoire
  const salt = generateSalt();

  // 2. Générer K_master aléatoire (256 bits)
  const kMasterBytes = randomBytes(32);
  const kMaster = bytesToHex(kMasterBytes);

  // 3. Dériver K_encryption depuis password
  const kEncryption = await deriveKEncryption(password, salt);

  // 4. Chiffrer K_master avec K_encryption
  const encrypted = encrypt(kMasterBytes, kEncryption);

  // 5. Zeroize sensitive data
  kMasterBytes.fill(0);

  const envelope: MasterEnvelope = {
    version: ENVELOPE_VERSION,
    salt,
    nonce: bytesToHex(encrypted.nonce),
    ciphertext: bytesToHex(encrypted.ciphertext),
    tag: bytesToHex(encrypted.tag),
  };

  return { kMaster, envelope };
}

/**
 * Ouvre une Master Envelope existante (login)
 * Déchiffre K_master avec le mot de passe
 *
 * @param password - Mot de passe utilisateur
 * @param envelope - Enveloppe chiffrée
 * @returns K_master (hex)
 * @throws Si mot de passe incorrect
 */
export async function openEnvelope(
  password: string,
  envelope: MasterEnvelope
): Promise<string> {
  // 1. Dériver K_encryption depuis password + salt
  const kEncryption = await deriveKEncryption(password, envelope.salt);

  // 2. Déchiffrer K_master
  const encrypted = {
    ciphertext: hexToBytes(envelope.ciphertext),
    nonce: hexToBytes(envelope.nonce),
    tag: hexToBytes(envelope.tag),
  };

  try {
    const kMasterBytes = decrypt(encrypted, kEncryption);
    return bytesToHex(kMasterBytes);
  } catch {
    throw new Error("Invalid password");
  }
}

/**
 * Re-chiffre l'enveloppe avec un nouveau mot de passe
 * K_master reste identique, seul le chiffrement change
 *
 * @param oldPassword - Ancien mot de passe
 * @param newPassword - Nouveau mot de passe
 * @param envelope - Enveloppe actuelle
 * @returns Nouvelle enveloppe
 */
export async function rewrapEnvelope(
  oldPassword: string,
  newPassword: string,
  envelope: MasterEnvelope
): Promise<MasterEnvelope> {
  // 1. Déchiffrer avec ancien mot de passe
  const kMaster = await openEnvelope(oldPassword, envelope);
  const kMasterBytes = hexToBytes(kMaster);

  // 2. Générer nouveau salt
  const newSalt = generateSalt();

  // 3. Dériver nouvelle K_encryption
  const newKEncryption = await deriveKEncryption(newPassword, newSalt);

  // 4. Re-chiffrer K_master
  const encrypted = encrypt(kMasterBytes, newKEncryption);

  // 5. Zeroize
  kMasterBytes.fill(0);

  return {
    version: ENVELOPE_VERSION,
    salt: newSalt,
    nonce: bytesToHex(encrypted.nonce),
    ciphertext: bytesToHex(encrypted.ciphertext),
    tag: bytesToHex(encrypted.tag),
  };
}

1.7 Utilitaires

Fichier: src/crypto/utils.ts

/**
 * Convertit un hex string en Uint8Array
 */
export function hexToBytes(hex: string): Uint8Array {
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
  }
  return bytes;
}

/**
 * Convertit un Uint8Array en hex string
 */
export function bytesToHex(bytes: Uint8Array): string {
  return Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

/**
 * Convertit un Uint8Array en base64
 */
export function bytesToBase64(bytes: Uint8Array): string {
  return btoa(String.fromCharCode(...bytes));
}

/**
 * Convertit un base64 string en Uint8Array
 */
export function base64ToBytes(base64: string): Uint8Array {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
}

1.8 Zeroization

Fichier: src/crypto/zeroize.ts

/**
 * Efface de manière sécurisée un buffer mémoire
 * Note: En JavaScript, le GC peut toujours avoir des copies.
 * Cette implémentation est best-effort.
 */
export function zeroize(buffer: Uint8Array): void {
  buffer.fill(0);
  // Double-write pour éviter optimisations compilateur
  for (let i = 0; i < buffer.length; i++) {
    buffer[i] = 0;
  }
}

/**
 * Efface une string hex (retourne string vide)
 * Note: Les strings sont immutables en JS, on ne peut que déréférencer
 */
export function zeroizeHex(hex: string): string {
  // Force garbage collection hint
  return "";
}

/**
 * Wrapper pour exécuter une fonction avec cleanup automatique
 */
export async function withZeroize<T>(
  sensitiveData: Uint8Array,
  fn: (data: Uint8Array) => Promise<T>
): Promise<T> {
  try {
    return await fn(sensitiveData);
  } finally {
    zeroize(sensitiveData);
  }
}

Phase 2 : Service CryptoService (Sprint 2)

2.1 Tâches d'intégration

ID Tâche Priorité Effort
2.1.1 Créer CryptoService singleton P0 2h
2.1.2 Intégration SecureStore pour enveloppe P0 2h
2.1.3 Gestion état verrouillé/déverrouillé P0 2h
2.1.4 Cache K_share en mémoire (session) P0 2h
2.1.5 Auto-lock sur background/timeout P0 3h
2.1.6 Hook useCrypto() P1 2h

Fichier: src/services/crypto.service.ts

import * as SecureStore from "expo-secure-store";
import {
  createEnvelope,
  openEnvelope,
  rewrapEnvelope,
} from "../crypto/envelope";
import { deriveKShare, deriveKDoc } from "../crypto/hkdf";
import { encrypt, decrypt } from "../crypto/aes-gcm";
import { MasterEnvelope, DocumentAAD, EncryptedDocument } from "../crypto/types";
import { hexToBytes, bytesToHex, bytesToBase64, base64ToBytes } from "../crypto/utils";

const ENVELOPE_STORAGE_KEY = "probatio_master_envelope";

class CryptoService {
  private kMaster: string | null = null;
  private kShare: string | null = null;
  private lockTimeout: NodeJS.Timeout | null = null;

  /**
   * Vérifie si le coffre est déverrouillé
   */
  isUnlocked(): boolean {
    return this.kShare !== null;
  }

  /**
   * Vérifie si une enveloppe existe (utilisateur inscrit)
   */
  async hasEnvelope(): Promise<boolean> {
    const envelope = await SecureStore.getItemAsync(ENVELOPE_STORAGE_KEY);
    return envelope !== null;
  }

  /**
   * Inscription : crée l'enveloppe et déverrouille
   */
  async register(password: string): Promise<void> {
    const { kMaster, envelope } = await createEnvelope(password);

    // Stocker l'enveloppe
    await SecureStore.setItemAsync(
      ENVELOPE_STORAGE_KEY,
      JSON.stringify(envelope)
    );

    // Déverrouiller la session
    this.kMaster = kMaster;
    this.kShare = deriveKShare(kMaster);
    this.startLockTimer();
  }

  /**
   * Login : déchiffre l'enveloppe et déverrouille
   */
  async unlock(password: string): Promise<boolean> {
    const envelopeJson = await SecureStore.getItemAsync(ENVELOPE_STORAGE_KEY);
    if (!envelopeJson) {
      throw new Error("No envelope found - user not registered");
    }

    const envelope: MasterEnvelope = JSON.parse(envelopeJson);

    try {
      this.kMaster = await openEnvelope(password, envelope);
      this.kShare = deriveKShare(this.kMaster);
      this.startLockTimer();
      return true;
    } catch {
      return false; // Mot de passe incorrect
    }
  }

  /**
   * Verrouille le coffre (efface les clés mémoire)
   */
  lock(): void {
    this.kMaster = null;
    this.kShare = null;
    this.stopLockTimer();
  }

  /**
   * Change le mot de passe
   */
  async changePassword(
    oldPassword: string,
    newPassword: string
  ): Promise<void> {
    const envelopeJson = await SecureStore.getItemAsync(ENVELOPE_STORAGE_KEY);
    if (!envelopeJson) {
      throw new Error("No envelope found");
    }

    const oldEnvelope: MasterEnvelope = JSON.parse(envelopeJson);
    const newEnvelope = await rewrapEnvelope(
      oldPassword,
      newPassword,
      oldEnvelope
    );

    await SecureStore.setItemAsync(
      ENVELOPE_STORAGE_KEY,
      JSON.stringify(newEnvelope)
    );
  }

  /**
   * Chiffre un document
   */
  encryptDocument(
    plaintext: Uint8Array,
    docId: string,
    mimeType: string
  ): EncryptedDocument {
    this.ensureUnlocked();

    // Dériver K_doc pour ce document
    const kDoc = deriveKDoc(this.kShare!, docId);

    const aad: DocumentAAD = { docId, mimeType, version: 1 };
    const encrypted = encrypt(plaintext, kDoc, aad);

    return {
      ciphertext: bytesToBase64(encrypted.ciphertext),
      nonce: bytesToHex(encrypted.nonce),
      tag: bytesToHex(encrypted.tag),
      aad,
    };
  }

  /**
   * Déchiffre un document
   */
  decryptDocument(encrypted: EncryptedDocument): Uint8Array {
    this.ensureUnlocked();

    const kDoc = deriveKDoc(this.kShare!, encrypted.aad.docId);

    const encryptedData = {
      ciphertext: base64ToBytes(encrypted.ciphertext),
      nonce: hexToBytes(encrypted.nonce),
      tag: hexToBytes(encrypted.tag),
    };

    return decrypt(encryptedData, kDoc, encrypted.aad);
  }

  /**
   * Réinitialise le timer d'activité
   */
  resetActivityTimer(): void {
    if (this.isUnlocked()) {
      this.startLockTimer();
    }
  }

  // --- Private methods ---

  private ensureUnlocked(): void {
    if (!this.kShare) {
      throw new Error("Vault is locked");
    }
  }

  private startLockTimer(): void {
    this.stopLockTimer();
    // Auto-lock après 5 minutes d'inactivité
    this.lockTimeout = setTimeout(() => this.lock(), 5 * 60 * 1000);
  }

  private stopLockTimer(): void {
    if (this.lockTimeout) {
      clearTimeout(this.lockTimeout);
      this.lockTimeout = null;
    }
  }
}

// Singleton
export const cryptoService = new CryptoService();

2.2 Hook React

Fichier: src/hooks/useCrypto.ts

import { useState, useEffect, useCallback } from "react";
import { cryptoService } from "../services/crypto.service";
import { AppState, AppStateStatus } from "react-native";

export function useCrypto() {
  const [isUnlocked, setIsUnlocked] = useState(cryptoService.isUnlocked());
  const [isLoading, setIsLoading] = useState(false);

  // Écouter les changements d'état de l'app
  useEffect(() => {
    const subscription = AppState.addEventListener(
      "change",
      (nextAppState: AppStateStatus) => {
        if (nextAppState === "background") {
          // Optionnel: lock immédiat en background
          // cryptoService.lock();
          // setIsUnlocked(false);
        } else if (nextAppState === "active") {
          cryptoService.resetActivityTimer();
        }
      }
    );

    return () => subscription?.remove();
  }, []);

  const unlock = useCallback(async (password: string): Promise<boolean> => {
    setIsLoading(true);
    try {
      const success = await cryptoService.unlock(password);
      setIsUnlocked(success);
      return success;
    } finally {
      setIsLoading(false);
    }
  }, []);

  const lock = useCallback(() => {
    cryptoService.lock();
    setIsUnlocked(false);
  }, []);

  const register = useCallback(async (password: string): Promise<void> => {
    setIsLoading(true);
    try {
      await cryptoService.register(password);
      setIsUnlocked(true);
    } finally {
      setIsLoading(false);
    }
  }, []);

  return {
    isUnlocked,
    isLoading,
    unlock,
    lock,
    register,
    encryptDocument: cryptoService.encryptDocument.bind(cryptoService),
    decryptDocument: cryptoService.decryptDocument.bind(cryptoService),
  };
}

Phase 3 : Tests & Validation (Sprint 2-3)

3.1 Tests unitaires

ID Tâche Priorité Effort
3.1.1 Tests Argon2id avec vectors P0 2h
3.1.2 Tests HKDF-SHA3-256 P0 2h
3.1.3 Tests AES-GCM NIST vectors P0 2h
3.1.4 Tests envelope round-trip P0 2h
3.1.5 Tests rejection (bad tag/nonce/aad) P0 2h
3.1.6 Tests CryptoService intégration P0 3h

Fichier: src/crypto/__tests__/vectors.test.ts

import { deriveKEncryption } from "../argon2";
import { deriveKShare, deriveKDoc } from "../hkdf";
import { encrypt, decrypt } from "../aes-gcm";

describe("Crypto Vectors", () => {
  describe("Argon2id", () => {
    it("should derive consistent key from password", async () => {
      const password = "test-password";
      const salt = "0".repeat(32); // 16 bytes

      const key1 = await deriveKEncryption(password, salt);
      const key2 = await deriveKEncryption(password, salt);

      expect(key1).toBe(key2);
      expect(key1.length).toBe(64); // 32 bytes hex
    });

    it("should produce different keys for different salts", async () => {
      const password = "test-password";

      const key1 = await deriveKEncryption(password, "0".repeat(32));
      const key2 = await deriveKEncryption(password, "1".repeat(32));

      expect(key1).not.toBe(key2);
    });
  });

  describe("HKDF-SHA3-256", () => {
    it("should derive K_share consistently", () => {
      const kMaster = "a".repeat(64); // 32 bytes hex

      const kShare1 = deriveKShare(kMaster);
      const kShare2 = deriveKShare(kMaster);

      expect(kShare1).toBe(kShare2);
      expect(kShare1.length).toBe(64);
    });

    it("should derive different K_doc for different doc_ids", () => {
      const kShare = "b".repeat(64);

      const kDoc1 = deriveKDoc(kShare, "doc-1");
      const kDoc2 = deriveKDoc(kShare, "doc-2");

      expect(kDoc1).not.toBe(kDoc2);
    });

    it("should derive same K_doc for same doc_id", () => {
      const kShare = "c".repeat(64);
      const docId = "doc-123";

      const kDoc1 = deriveKDoc(kShare, docId);
      const kDoc2 = deriveKDoc(kShare, docId);

      expect(kDoc1).toBe(kDoc2);
    });
  });

  describe("AES-256-GCM", () => {
    it("should encrypt and decrypt correctly", () => {
      const key = "d".repeat(64);
      const plaintext = new TextEncoder().encode("Hello, World!");

      const encrypted = encrypt(plaintext, key);
      const decrypted = decrypt(encrypted, key);

      expect(new TextDecoder().decode(decrypted)).toBe("Hello, World!");
    });

    it("should reject modified ciphertext", () => {
      const key = "e".repeat(64);
      const plaintext = new TextEncoder().encode("Secret data");

      const encrypted = encrypt(plaintext, key);
      encrypted.ciphertext[0] ^= 0xff; // Corrupt

      expect(() => decrypt(encrypted, key)).toThrow();
    });

    it("should reject modified tag", () => {
      const key = "f".repeat(64);
      const plaintext = new TextEncoder().encode("Secret data");

      const encrypted = encrypt(plaintext, key);
      encrypted.tag[0] ^= 0xff;

      expect(() => decrypt(encrypted, key)).toThrow();
    });

    it("should reject modified AAD", () => {
      const key = "0".repeat(64);
      const plaintext = new TextEncoder().encode("Secret data");
      const aad = { docId: "doc-1", mimeType: "text/plain", version: 1 };

      const encrypted = encrypt(plaintext, key, aad);

      const wrongAad = { ...aad, docId: "doc-2" };
      expect(() => decrypt(encrypted, key, wrongAad)).toThrow();
    });
  });
});

3.2 Tests scénarios

ID Scénario Priorité
3.2.1 Inscription → Login → même K_share P0
3.2.2 Chiffrement fichier 10Mo round-trip P0
3.2.3 Changement mot de passe → docs lisibles P0
3.2.4 Lock/unlock → état cohérent P0
3.2.5 Auto-lock après timeout P1

Résumé des livrables

Phase Livrables Sprint
1 Module crypto TypeScript complet 1
2 CryptoService + hook useCrypto 2
3 Tests vectors + scénarios 2-3

Checklist AC

  • AC1: Argon2id t=3, m=64MiB, p=4, out=32 ✓
  • AC2: Master Envelope create/open/rewrap ✓
  • AC3: K_share_user dérivation HKDF-SHA3-256 ✓
  • AC4: K_doc dérivation par document ✓
  • AC5: AES-256-GCM avec AAD, rejection si modifié ✓
  • AC6: Zero-knowledge (aucune clé transmise) ✓
  • AC7: Zeroization best-effort (limitation JS) ✓
  • AC8: Performance acceptable (< 1s Argon2id) ✓

Notes importantes

Limitations TypeScript

  1. Zeroization : JavaScript ne garantit pas l'effacement mémoire (GC). → Mitigation : best-effort buffer.fill(0)

  2. Strings immutables : Les clés hex passent par des strings. → Mitigation : Utiliser Uint8Array autant que possible

  3. Performance Argon2id : Le WASM peut être plus lent que natif. → Si > 1s sur device bas de gamme, réduire mem temporairement

Migration Rust future

Quand la migration Rust sera planifiée :

  • Garder la même API TypeScript
  • Remplacer l'implémentation par des bindings natifs
  • Bénéficier de vraie zeroization avec zeroize crate