PD-103 — Agent Developer — Module M11 : capture-kek-keyring¶
1. Identite agent¶
- Agent : agent-developer (Agent B — Claude)
- Story : PD-103
- Module : M11 — capture-kek-keyring
- Wave : 2 (service dependant, pas de dependance inter-agents dans cette wave)
- Date : 2026-04-03
2. Resume¶
Module M11 implemente le service backend de gestion du keyring KEK pour le flux de capture probatoire. Il maintient un ensemble de cles RSA (KEK courante + historiques), unwrappe le DEK transmis par le mobile, et retourne les codes d'erreur contractuels (422 UNWRAP_DEK_FAILED, 503 KEY_SERVICE_UNAVAILABLE).
Fichier a creer : - src/modules/capture/services/kek-keyring.service.ts — Service keyring KEK + unwrap DEK
Fichiers existants reutilises : - src/modules/crypto/services/aes-kw.service.ts — Pattern zeroize(key) en finally - src/modules/crypto/services/key-envelope.service.ts — Pattern unwrap avec gestion erreur - src/modules/urgent-seal/services/seal-crypto.service.ts — Pattern DEK wrap/unwrap AES-GCM - src/modules/capture/entities/capture-event.entity.ts — Entite CaptureEvent (M13)
Dependance externe : - HashiCorp Vault (chemin kv/data/ci/capture-kek) pour stockage/recuperation des cles RSA privees - Ou HSM PKCS#11 via CryptoModule existant (pattern hsm-key-manager.service.ts)
3. Artefacts livres¶
| Fichier | Role | Lignes estimees |
|---|---|---|
src/modules/capture/services/kek-keyring.service.ts | Keyring KEK backend, unwrap DEK RSA-OAEP-SHA256 | ~220 |
src/modules/capture/__tests__/kek-keyring.service.spec.ts | Tests contractuels TC-INV-13, TC-ERR-16, TC-ERR-17, TC-NOM-16 + qualite | ~350 |
4. Architecture¶
4.1 Decisions architecturales¶
architectural_decisions:
- decision: "Service injectable NestJS avec keyring charge au demarrage (onModuleInit)"
rationale: "Les cles RSA privees sont chargees une fois depuis Vault/HSM au boot du service. Le keyring est un Map<KekId, RSA_PrivateKey> en memoire. Cela evite un appel Vault a chaque requete POST /documents/capture."
alternatives_considered:
- "Chargement a la demande (lazy) par kek_id"
- "Cache Redis pour les cles"
trade_offs: "Memoire occupee par N cles privees RSA (~2-3 KB chacune). Acceptable pour profondeur max 10 cles. Necessite un restart pour recharger apres rotation."
- decision: "Unwrap RSA-OAEP-SHA256 via node:crypto (createPrivateKey + privateDecrypt)"
rationale: "node:crypto est natif NestJS/Node, supporte RSA-OAEP avec SHA-256 nativement, pas de dependance tierce. Pattern coherent avec l'utilisation de node:crypto dans aes-kw.service.ts et key-envelope.service.ts."
alternatives_considered:
- "Unwrap via HSM PKCS#11 (cloudhsm-pkcs11.provider.ts)"
- "Unwrap via Vault Transit API"
trade_offs: "La cle privee est en memoire volatile du process Node. Si HSM PKCS#11 est requis (CKA_EXTRACTABLE=FALSE), le service doit etre adapte pour deleguer le decrypt au HSM. Pour PD-103 MVP, node:crypto est suffisant."
4.2 Pattern utilise¶
Pattern fail-closed avec zeroization du DEK unwrappe (CLAUDE.md : regles crypto PD-242, anti-catch-absorb PD-85) :
let dekClear: Buffer | null = null;
try {
dekClear = this.unwrapDekInternal(wrappedDek, kekId);
// ... utilisation du DEK en clair ...
return dekClear; // transfert de propriete a l'appelant (M9)
} catch (error) {
if (dekClear) {
dekClear.fill(0x00);
}
throw error; // propagation obligatoire (anti-catch-absorb)
}
Le DEK unwrappe est retourne a M9 (capture-ingest) qui est responsable de sa zeroization apres usage.
4.3 Dependances¶
| Dependance | Usage | Justification |
|---|---|---|
node:crypto | privateDecrypt RSA-OAEP-SHA256, createPrivateKey | Natif Node, pas de dep tierce |
@nestjs/common | Injectable, OnModuleInit, Logger, HttpException | Framework NestJS standard |
@nestjs/config | ConfigService pour profondeur keyring et chemin Vault | Configuration injectable |
| HashiCorp Vault client | Recuperation des cles privees RSA au boot | Stockage securise des cles |
4.4 Exports publics¶
// --- kek-keyring.service.ts ---
/** Erreur metier : unwrap DEK echoue (cle non trouvee ou echec crypto) */
export class UnwrapDekFailedError extends Error {
readonly code = 'UNWRAP_DEK_FAILED';
readonly httpStatus = 422;
}
/** Erreur metier : service de cle indisponible (Vault/HSM down) */
export class KeyServiceUnavailableError extends Error {
readonly code = 'KEY_SERVICE_UNAVAILABLE';
readonly httpStatus = 503;
}
/** Resultat de l'unwrap DEK */
export interface UnwrapDekResult {
/** DEK en clair (Buffer 32 bytes). L'appelant DOIT zeroizer apres usage. */
readonly dekClear: Buffer;
/** kek_id effectivement utilise pour l'unwrap */
readonly kekIdUsed: string;
}
@Injectable()
export class KekKeyringService implements OnModuleInit {
/** Charge le keyring depuis Vault/HSM au demarrage */
async onModuleInit(): Promise<void>;
/**
* Unwrappe le DEK via RSA-OAEP-SHA256 en utilisant le keyring.
* Priorise la cle correspondant a kek_id, puis itere les autres cles du keyring.
*
* @throws UnwrapDekFailedError (422) si aucune cle ne peut unwrapper
* @throws KeyServiceUnavailableError (503) si Vault/HSM est indisponible
*/
unwrapDek(dekWrappedB64: string, kekId: string): UnwrapDekResult;
/** Retourne true si le kek_id est present dans le keyring */
hasKekId(kekId: string): boolean;
/** Retourne le kek_id de la KEK courante */
getActiveKekId(): string;
/** Retourne la profondeur du keyring (nombre de cles chargees) */
getKeyringDepth(): number;
}
4.5 Diagramme unwrap¶
sequenceDiagram
participant M9 as CaptureIngestService
participant K as KekKeyringService
participant V as Vault/HSM
participant N as node:crypto
Note over K,V: Au demarrage (onModuleInit)
K->>V: GET kv/data/ci/capture-kek (cles RSA privees)
alt Vault disponible
V-->>K: {current: {kek_id, pem}, previous: [{kek_id, pem}, ...]}
K->>K: Charge keyring Map<kek_id, RSA_PrivateKey>
else Vault indisponible
V-->>K: timeout/erreur
K->>K: FATAL — service non demarre
end
Note over M9,N: Par requete POST /documents/capture
M9->>K: unwrapDek(dek_wrapped_b64, kek_id)
K->>K: Cherche kek_id dans keyring
alt kek_id trouve
K->>N: privateDecrypt(RSA_OAEP_SHA256, wrappedDek, privateKey)
alt Unwrap OK
N-->>K: dekClear (Buffer 32 bytes)
K-->>M9: UnwrapDekResult {dekClear, kekIdUsed}
else Echec crypto (DEK corrompu)
N-->>K: Error
K-->>M9: throw UnwrapDekFailedError (422)
end
else kek_id non trouve — itere keyring
loop chaque cle du keyring
K->>N: privateDecrypt(RSA_OAEP_SHA256, wrappedDek, candidateKey)
alt Unwrap OK
N-->>K: dekClear
K-->>M9: UnwrapDekResult {dekClear, kekIdUsed: candidateKekId}
else Echec
Note over K: continue avec la cle suivante
end
end
K-->>M9: throw UnwrapDekFailedError (422)
end 5. Implementation detaillee¶
5.1 src/modules/capture/services/kek-keyring.service.ts¶
Constantes et configuration¶
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
createPrivateKey,
privateDecrypt,
constants as cryptoConstants,
KeyObject,
} from 'node:crypto';
const DEFAULT_KEYRING_DEPTH = 3;
const MIN_KEYRING_DEPTH = 1;
const MAX_KEYRING_DEPTH = 10;
const DEK_LENGTH_BYTES = 32;
Structure du keyring¶
interface KekEntry {
readonly kekId: string;
readonly privateKey: KeyObject;
readonly isCurrent: boolean;
}
Le keyring est stocke dans une Map<string, KekEntry> avec le kek_id comme cle. La KEK courante est marquee isCurrent: true. Les anciennes KEK ont isCurrent: false.
onModuleInit()¶
async onModuleInit(): Promise<void> {
const maxDepth = this.getConfiguredDepth();
this.logger.log(`Initializing KEK keyring (max depth: ${maxDepth})`);
let vaultResponse: VaultKekResponse;
try {
vaultResponse = await this.loadKeysFromVault();
} catch (error) {
this.logger.error(`Failed to load KEK keyring from Vault: ${error.message}`);
throw new KeyServiceUnavailableError(
`Cannot initialize KEK keyring: ${error.message}`,
);
}
// Charger la cle courante
const currentKey = this.parsePrivateKey(vaultResponse.current.pem);
this.keyring.set(vaultResponse.current.kek_id, {
kekId: vaultResponse.current.kek_id,
privateKey: currentKey,
isCurrent: true,
});
this.activeKekId = vaultResponse.current.kek_id;
// Charger les cles historiques (max N-1 anciennes)
const previousKeys = (vaultResponse.previous || []).slice(0, maxDepth - 1);
for (const prev of previousKeys) {
this.keyring.set(prev.kek_id, {
kekId: prev.kek_id,
privateKey: this.parsePrivateKey(prev.pem),
isCurrent: false,
});
}
this.logger.log(
`KEK keyring loaded: ${this.keyring.size} keys (active: ${this.activeKekId})`,
);
}
Comportement au boot : - Si Vault est indisponible → le service NE DEMARRE PAS (fail-closed, INV-103-34). - Si la cle courante est absente → FATAL, le module ne se charge pas. - Si aucune ancienne cle n'est disponible → OK, keyring avec 1 seule cle.
unwrapDek(dekWrappedB64, kekId)¶
unwrapDek(dekWrappedB64: string, kekId: string): UnwrapDekResult {
if (this.keyring.size === 0) {
throw new KeyServiceUnavailableError('KEK keyring is empty — service not initialized');
}
const wrappedDekBuffer = Buffer.from(dekWrappedB64, 'base64');
// Strategie 1 : essayer la cle correspondant au kek_id fourni
const primaryEntry = this.keyring.get(kekId);
if (primaryEntry) {
const result = this.tryUnwrap(wrappedDekBuffer, primaryEntry);
if (result) {
return result;
}
}
// Strategie 2 : iterer les autres cles du keyring (courante en priorite)
const sortedEntries = this.getSortedEntries(kekId);
for (const entry of sortedEntries) {
const result = this.tryUnwrap(wrappedDekBuffer, entry);
if (result) {
this.logger.warn(
`DEK unwrapped with fallback kek_id=${entry.kekId} (requested: ${kekId})`,
);
return result;
}
}
// Aucune cle n'a pu unwrapper
throw new UnwrapDekFailedError(
`Cannot unwrap DEK: no compatible key found in keyring (requested kek_id=${kekId})`,
);
}
tryUnwrap (methode privee)¶
private tryUnwrap(wrappedDek: Buffer, entry: KekEntry): UnwrapDekResult | null {
try {
const dekClear = privateDecrypt(
{
key: entry.privateKey,
padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
},
wrappedDek,
);
if (dekClear.length !== DEK_LENGTH_BYTES) {
// DEK de taille incorrecte apres unwrap — cle incompatible
dekClear.fill(0x00);
return null;
}
return {
dekClear,
kekIdUsed: entry.kekId,
};
} catch {
// Echec cryptographique (padding invalide, cle incompatible) — essayer la suivante
return null;
}
}
Points critiques : - Si le DEK unwrappe a une taille != 32 bytes → zeroize et rejette (pas une erreur fatale, essaye la cle suivante). - L'erreur node:crypto sur privateDecrypt est absorbee silencieusement car elle signifie simplement que cette cle n'est pas la bonne (pas un bug). - Le log warn sur fallback est important pour detecter les rotations en cours.
getSortedEntries (methode privee)¶
private getSortedEntries(excludeKekId: string): KekEntry[] {
const entries: KekEntry[] = [];
for (const entry of this.keyring.values()) {
if (entry.kekId !== excludeKekId) {
entries.push(entry);
}
}
// KEK courante en premier (plus probable)
entries.sort((a, b) => (b.isCurrent ? 1 : 0) - (a.isCurrent ? 1 : 0));
return entries;
}
parsePrivateKey (methode privee)¶
private parsePrivateKey(pem: string): KeyObject {
try {
return createPrivateKey({
key: pem,
format: 'pem',
type: 'pkcs8',
});
} catch (error) {
throw new KeyServiceUnavailableError(
`Invalid RSA private key PEM: ${error.message}`,
);
}
}
loadKeysFromVault (methode privee)¶
private async loadKeysFromVault(): Promise<VaultKekResponse> {
const vaultUrl = this.configService.get<string>('VAULT_URL');
const vaultToken = this.configService.get<string>('VAULT_TOKEN');
const vaultPath = this.configService.get<string>(
'CAPTURE_KEK_VAULT_PATH',
'kv/data/ci/capture-kek',
);
const response = await fetch(`${vaultUrl}/v1/${vaultPath}`, {
headers: { 'X-Vault-Token': vaultToken },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`Vault returned ${response.status}: ${response.statusText}`);
}
const body = await response.json();
return body.data.data as VaultKekResponse;
}
Types Vault internes¶
interface VaultKekResponse {
current: { kek_id: string; pem: string };
previous: Array<{ kek_id: string; pem: string }>;
}
Methodes utilitaires¶
hasKekId(kekId: string): boolean {
return this.keyring.has(kekId);
}
getActiveKekId(): string {
if (!this.activeKekId) {
throw new KeyServiceUnavailableError('KEK keyring not initialized');
}
return this.activeKekId;
}
getKeyringDepth(): number {
return this.keyring.size;
}
private getConfiguredDepth(): number {
const depth = this.configService.get<number>(
'CAPTURE_KEK_KEYRING_DEPTH',
DEFAULT_KEYRING_DEPTH,
);
if (depth < MIN_KEYRING_DEPTH || depth > MAX_KEYRING_DEPTH) {
this.logger.warn(
`KEK keyring depth ${depth} out of bounds [${MIN_KEYRING_DEPTH},${MAX_KEYRING_DEPTH}], clamping to ${DEFAULT_KEYRING_DEPTH}`,
);
return DEFAULT_KEYRING_DEPTH;
}
return depth;
}
5.2 Interaction avec M9 (capture-ingest)¶
M9 appelle KekKeyringService.unwrapDek() et gere les exceptions :
// Dans CaptureIngestService (M9)
try {
const { dekClear, kekIdUsed } = this.kekKeyringService.unwrapDek(
dto.dekWrappedB64,
dto.kekId,
);
try {
// Utiliser dekClear pour dechiffrer/verifier si necessaire
// ... persistance capture_events ...
} finally {
// Zeroization obligatoire (CLAUDE.md regles crypto PD-242)
dekClear.fill(0x00);
}
} catch (error) {
if (error instanceof UnwrapDekFailedError) {
throw new HttpException(
{ code: 'UNWRAP_DEK_FAILED', message: error.message },
422,
);
}
if (error instanceof KeyServiceUnavailableError) {
throw new HttpException(
{ code: 'KEY_SERVICE_UNAVAILABLE', message: error.message },
503,
);
}
throw error;
}
5.3 Retablissement du keyring (rotation)¶
La spec exige que les anciennes KEK soient conservees pendant deferredUploadTtl + dedupWindow (24h + 24h = 48h minimum). Avec profondeur 3 et rotation mensuelle, la couverture est largement suffisante. Si la rotation est plus frequente (hebdomadaire), augmenter CAPTURE_KEK_KEYRING_DEPTH.
Pour ajouter une nouvelle KEK sans redemarrage : - Option recommandee : redemarrage du pod/service apres mise a jour du secret Vault. - Option future : endpoint admin /capture/kek/reload (hors scope PD-103).
6. Tests contractuels¶
6.1 Matrice de couverture¶
| Test ID | Ref spec | Invariant | Ce qui est teste |
|---|---|---|---|
| TC-INV-13 | INV-103-34 | Keyring courante + anciennes | Unwrap avec ancienne KEK reussit |
| TC-NOM-16 | INV-103-34, §5.11 | Rotation KEK | Capture differee avec ancien kek_id unwrappee via keyring |
| TC-ERR-16 | ER-103-16 | 422 UNWRAP_DEK_FAILED | DEK corrompu ou kek_id inconnu |
| TC-ERR-17 | ER-103-17 | 503 KEY_SERVICE_UNAVAILABLE | HSM/Vault indisponible |
6.2 Scenarios¶
TEST-ID: TC-INV-13
Reference spec: INV-103-34, §5.11
GIVEN
- Keyring charge avec KEK courante (K2) + ancienne KEK (K1)
- DEK wrappe avec K1 (kek_id="K1")
WHEN
- unwrapDek(dek_wrapped_b64, "K1") est appele
THEN
- L'unwrap reussit via K1
- kekIdUsed == "K1"
- dekClear.length == 32
AND
- Aucune erreur levee
TEST-ID: TC-NOM-16
Reference spec: INV-103-34, §5.11, CA-103-25
GIVEN
- Keyring charge : KEK courante K2 + ancienne K1
- Payload capture differee avec kek_id="K1" et dek_wrapped_b64 wrappe avec K1
WHEN
- unwrapDek est appele
THEN
- Unwrap reussit via keyring historique
- Le flux poursuit normalement
AND
- Log warn emis indiquant fallback (si K1 n'est plus la courante)
TEST-ID: TC-ERR-16
Reference spec: ER-103-16, INV-103-34, CA-103-28
GIVEN
- Keyring charge avec K1 et K2
- dek_wrapped_b64 corrompu (bytes aleatoires non valides RSA-OAEP)
WHEN
- unwrapDek(dek_wrapped_b64, "K1") est appele
THEN
- Toutes les cles du keyring sont essayees
- UnwrapDekFailedError est levee avec code "UNWRAP_DEK_FAILED"
AND
- Aucun DEK en clair ne fuit (zeroization dans tryUnwrap sur taille incorrecte)
TEST-ID: TC-ERR-16b
Reference spec: ER-103-16, INV-103-34
GIVEN
- Keyring charge avec K1 et K2
- kek_id="K99" (inconnu du keyring)
- dek_wrapped_b64 wrappe avec une cle K99 absente
WHEN
- unwrapDek(dek_wrapped_b64, "K99") est appele
THEN
- K99 n'est pas dans le keyring, iteration sur K1 et K2
- Aucune cle ne peut unwrapper
- UnwrapDekFailedError levee
TEST-ID: TC-ERR-17
Reference spec: ER-103-17, CA-103-28
GIVEN
- Vault/HSM indisponible au demarrage du service
WHEN
- onModuleInit() est appele
THEN
- KeyServiceUnavailableError est levee
- Le service NE DEMARRE PAS (fail-closed)
AND
- Le module NestJS refuse toute requete subsequente
TEST-ID: TC-ERR-17b
Reference spec: ER-103-17
GIVEN
- Keyring vide (keyring.size == 0, cas theorique post-clear)
WHEN
- unwrapDek est appele
THEN
- KeyServiceUnavailableError levee ("KEK keyring is empty")
6.3 Tests additionnels (qualite)¶
TEST-ID: TC-KEK-Q01 (NON-CONTRACTUAL)
Objet: Profondeur keyring respecte les bornes
GIVEN
- CAPTURE_KEK_KEYRING_DEPTH configure a 0 (hors bornes)
WHEN
- getConfiguredDepth() est appele
THEN
- La profondeur est clampee a DEFAULT_KEYRING_DEPTH (3)
- Un warn est logue
TEST-ID: TC-KEK-Q02 (NON-CONTRACTUAL)
Objet: Roundtrip wrap/unwrap RSA-OAEP-SHA256
GIVEN
- Paire RSA generee en test (2048 bits)
- DEK aleatoire 32 bytes
WHEN
- DEK wrappe avec la cle publique, puis unwrappe avec KekKeyringService
THEN
- dekClear == dek_original (egalite bit-a-bit)
- dekClear.length == 32
TEST-ID: TC-KEK-Q03 (NON-CONTRACTUAL)
Objet: Priorite kek_id dans le keyring
GIVEN
- Keyring avec K1 (ancienne), K2 (courante)
- DEK wrappe avec K1
WHEN
- unwrapDek(wrapped, "K1")
THEN
- K1 est essayee EN PREMIER (pas K2)
- kekIdUsed == "K1"
AND
- Si kek_id n'est pas dans le keyring, la KEK courante est essayee en premier parmi les fallbacks
TEST-ID: TC-KEK-Q04 (NON-CONTRACTUAL)
Objet: Zeroization DEK sur taille incorrecte
GIVEN
- tryUnwrap retourne un buffer de 16 bytes (taille invalide)
WHEN
- Le resultat est verifie
THEN
- Le buffer est zeroize (fill 0x00)
- tryUnwrap retourne null (pas d'erreur fatale)
6.4 Setup des tests¶
// Fixture de test : generation de paire RSA pour keyring mock
import { generateKeyPairSync, publicEncrypt, constants } from 'node:crypto';
function generateTestKekPair(kekId: string) {
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
return { kekId, publicKeyPem: publicKey, privateKeyPem: privateKey };
}
function wrapDekForTest(dek: Buffer, publicKeyPem: string): string {
const wrapped = publicEncrypt(
{
key: publicKeyPem,
padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
},
dek,
);
return wrapped.toString('base64');
}
Le service est instancie avec un mock Vault qui retourne les cles PEM generees en test. Pas de mock de node:crypto — les tests exercent le vrai unwrap RSA-OAEP (roundtrip obligatoire, CLAUDE.md).
7. Invariants couverts¶
| Invariant | Mecanisme dans M11 | Observable |
|---|---|---|
| INV-103-09 | DEK unwrappe en memoire volatile uniquement ; jamais persiste en clair. L'appelant (M9) est responsable du zeroize. | Audit code : aucun save/insert du dekClear |
| INV-103-34 | Keyring Map | getKeyringDepth(), logs d'unwrap avec kek_id utilise |
8. Hypotheses¶
| ID | Hypothese | Impact si faux |
|---|---|---|
| HT-M11-01 | Les cles RSA privees sont stockees dans Vault au chemin kv/data/ci/capture-kek au format {current: {kek_id, pem}, previous: [...]}. | Adapter le chemin Vault et le format de parsing. |
| HT-M11-02 | Les cles RSA sont en format PKCS#8 PEM, type RSA 2048+ bits. | Adapter createPrivateKey si format different (PKCS#1, DER, etc.). |
| HT-M11-03 | node:crypto.privateDecrypt avec RSA_PKCS1_OAEP_PADDING + oaepHash: 'sha256' est compatible avec le wrapping mobile (react-native-quick-crypto RSA-OAEP-SHA256). | Risque d'incompatibilite d'implementation RSA-OAEP entre mobile et backend. Test roundtrip obligatoire (TC-KEK-Q02). |
| HT-M11-04 | Le service est redemarrable apres rotation KEK dans Vault (mise a jour du secret puis restart pod). | Si hot-reload est requis, ajouter un endpoint admin (hors scope PD-103). |
9. Points hors perimetre (signales)¶
-
Endpoint admin
/capture/kek/reload— Permettrait le rechargement du keyring sans redemarrage. Non requis par la spec PD-103. A considerer si la rotation est frequente. -
Metriques Prometheus —
capture_kek_unwrap_total{kek_id, status}etcapture_kek_keyring_depthseraient utiles pour monitoring. Non contractuel PD-103. -
Rotation automatique des cles — La generation et rotation automatique des paires RSA est hors perimetre. Le keyring consomme des cles fournies par Vault.
-
HSM PKCS#11 pour unwrap — Si la politique de securite exige
CKA_EXTRACTABLE=FALSE(cle privee jamais en memoire Node), le service devra deleguer leprivateDecryptau HSM viacloudhsm-pkcs11.provider.ts. Pattern existant danshsm-key-manager.service.ts. Non requis pour PD-103 MVP.