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¶
-
Zeroization : JavaScript ne garantit pas l'effacement mémoire (GC). → Mitigation : best-effort
buffer.fill(0) -
Strings immutables : Les clés hex passent par des strings. → Mitigation : Utiliser Uint8Array autant que possible
-
Performance Argon2id : Le WASM peut être plus lent que natif. → Si > 1s sur device bas de gamme, réduire
memtemporairement
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
zeroizecrate