Aller au contenu

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 charge au boot. Priorisation kek_id fourni, puis iteration. Profondeur configurable [1,10]. 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)

  1. 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.

  2. Metriques Prometheuscapture_kek_unwrap_total{kek_id, status} et capture_kek_keyring_depth seraient utiles pour monitoring. Non contractuel PD-103.

  3. Rotation automatique des cles — La generation et rotation automatique des paires RSA est hors perimetre. Le keyring consomme des cles fournies par Vault.

  4. HSM PKCS#11 pour unwrap — Si la politique de securite exige CKA_EXTRACTABLE=FALSE (cle privee jamais en memoire Node), le service devra deleguer le privateDecrypt au HSM via cloudhsm-pkcs11.provider.ts. Pattern existant dans hsm-key-manager.service.ts. Non requis pour PD-103 MVP.