Aller au contenu

PD-40 — Plan d'Implémentation

User Story : PD-40 — Rotation de clés HSM et re-signature probatoire
Epic : PD-189 — CRYPTO
Date : 2025-12-31
Statut : DRAFT


1. Découpage en composants

1.1 Couche Infrastructure (HSM)

HsmKeyManager

Responsabilité : Interface avec le HSM pour génération et usage des clés.

Méthodes :

  • generateKeyPair(algorithm: 'Ed25519'): Promise<KeyMetadata>
  • Génère une paire Ed25519 dans le HSM
  • Retourne { keyId, algorithm, createdAt, status: 'CANDIDATE' }
  • sign(keyId: string, payload: Buffer): Promise<Buffer>
  • Signe via HSM (clé privée ne sort jamais)
  • Garde (INV-4) : Refuse si status != 'ACTIVE' (throw KEY_NOT_ACTIVE_ERROR)
  • Garantit qu'une clé ARCHIVED/CANDIDATE/DISCARDED ne peut jamais signer
  • getKeyMetadata(keyId: string): Promise<KeyMetadata>
  • updateKeyStatus(keyId: string, status: KeyStatus): Promise<void>
  • Statuts: CANDIDATE | ACTIVE | ARCHIVED | DISCARDED

Implémentation :

  • Utilisation du SDK HSM (AWS CloudHSM, Azure Managed HSM, ou Luna Network HSM)
  • Configuration PKCS#11 ou KMIP
  • Vérification certification FIPS 140-⅔ niveau 3 (ou RGS v2 niveau 2+)

KeyRepository

Responsabilité : Persistance des métadonnées de clés (hors secrets).

Schéma :

interface KeyRecord {
  keyId: string;           // UUID v4, immuable
  algorithm: 'Ed25519';
  createdAt: Date;
  status: 'CANDIDATE' | 'ACTIVE' | 'ARCHIVED' | 'DISCARDED';
  rotationId?: string;     // UUID associé à la rotation
  archivedAt?: Date;
}

Contraintes :

  • Index unique sur keyId
  • Index sur status pour requête SELECT * WHERE status='ACTIVE' (doit retourner exactement 1)

1.2 Couche Application (Rotation)

RotationOrchestrator

Responsabilité : Coordonne l'ensemble du processus de rotation.

Méthodes :

  • initiateRotation(trigger: RotationTrigger, initiator: Identity): Promise<RotationSession>
  • executeRotation(rotationId: string): Promise<RotationResult>
  • validateRotationPreConditions(): Promise<ValidationResult>

État interne :

interface RotationSession {
  rotationId: string;       // UUID v4
  rotationStartTime: Date;
  trigger: 'MANUAL' | 'SECURITY_INCIDENT' | 'COMPLIANCE';
  initiator: Identity;
  oldKeyId: string;
  newKeyId: string;
  status: 'INITIATED' | 'KEY_GENERATED' | 'EVENTS_SELECTED' | 
          'RESIGNING' | 'PROMOTION_READY' | 'SUCCESS' | 'FAILED';
  eligibleCount: number;
  processedCount: number;
  failureReason?: string;
}

EventSelector

Responsabilité : Construction déterministe de l'ensemble E (§5 spec).

Méthode :

  • selectEligibleEvents(rotationStartTime: Date): Promise<Event[]>

Algorithme :

async selectEligibleEvents(rotationStartTime: Date): Promise<Event[]> {
  const ΔT = 24 * 60 * 60 * 1000; // 24 heures en ms
  const windowStart = new Date(rotationStartTime.getTime() - ΔT);

  const eligibleTypes = [
    'CREATE',
    'UPDATE_METADATA',
    'ACCESS_LOG',
    'PRE_DELEGATION',
    'REKEY',
  ];

  const events = await eventRepository.find({
    where: {
      timestamp: Between(windowStart, rotationStartTime),
      type: In(eligibleTypes),
      status: 'FINALIZED',
    },
    order: {
      timestamp: 'ASC',
      event_id: 'ASC',
    },
    take: 100001, // N_max + 1 pour détecter dépassement
  });

  if (events.length > 100000) {
    throw new RotationError(
      'ELIGIBLE_SET_TOO_LARGE',
      `Eligible set size ${events.length} exceeds N_max=100000`
    );
  }

  return events;
}

ResigningService

Responsabilité : Re-signature en lot avec gestion d'état.

Méthode :

  • resignEvents(events: Event[], newKeyId: string, rotationId: string): Promise<ResigningResult>

Algorithme :

async resignEvents(
  events: Event[],
  newKeyId: string,
  rotationId: string
): Promise<ResigningResult> {
  const processedEventIds: string[] = [];
  const errors: ResigningError[] = [];

  for (const event of events) {
    try {
      // 1. Construire EventToSign selon §4.3
      const eventToSign: EventToSign = {
        event_id: event.event_id,
        timestamp: event.timestamp.toISOString(),
        type: event.type,
        payload_hash: event.payload_hash,
      };

      // 2. Canonicaliser selon RFC 8785
      const canonicalJson = canonicalize(eventToSign);
      const payloadBuffer = Buffer.from(canonicalJson, 'utf-8');

      // 3. Signer via HSM
      const signatureBytes = await hsmKeyManager.sign(newKeyId, payloadBuffer);
      const signatureBase64 = signatureBytes.toString('base64');

      // 4. Créer SignatureEntry avec state=CANDIDATE
      const signatureEntry: SignatureEntry = {
        keyId: newKeyId,
        algorithm: 'Ed25519',
        signature: signatureBase64,
        signed_at: new Date().toISOString(),
        rotation_id: rotationId,
        state: 'CANDIDATE',
      };

      // 5. Append à event.signatures[] (append-only)
      await eventRepository.appendSignature(event.event_id, signatureEntry);

      processedEventIds.push(event.event_id);
    } catch (error) {
      errors.push({
        event_id: event.event_id,
        error: error.message,
      });
      // Échec immédiat : arrêter la boucle
      break;
    }
  }

  return {
    success: errors.length === 0,
    processedCount: processedEventIds.length,
    processedEventIds,
    errors,
  };
}

PromotionService

Responsabilité : Promotion atomique des signatures CANDIDATE → ACTIVE.

Méthode :

  • promoteSignatures(rotationId: string): Promise<PromotionResult>

Algorithme (TRANSACTION ATOMIQUE) :

async promoteSignatures(rotationId: string): Promise<PromotionResult> {
  // Transaction de base de données
  await dataSource.transaction(async (transactionalEntityManager) => {
    // 1. Vérifier que TOUTES les signatures CANDIDATE existent
    const candidateSignatures = await transactionalEntityManager
      .createQueryBuilder()
      .select('COUNT(*)')
      .from('event_signatures', 'sig')
      .where('sig.rotation_id = :rotationId', { rotationId })
      .andWhere('sig.state = :state', { state: 'CANDIDATE' })
      .getCount();

    const expectedCount = await getExpectedSignatureCount(rotationId);

    if (candidateSignatures !== expectedCount) {
      throw new PromotionError(
        'INCOMPLETE_CANDIDATE_SET',
        `Expected ${expectedCount} signatures, found ${candidateSignatures}`
      );
    }

    // 2. Promouvoir ATOMIQUEMENT toutes les signatures
    await transactionalEntityManager
      .createQueryBuilder()
      .update('event_signatures')
      .set({ state: 'ACTIVE' })
      .where('rotation_id = :rotationId', { rotationId })
      .andWhere('state = :state', { state: 'CANDIDATE' })
      .execute();

    // 3. Récupérer oldKeyId et newKeyId
    const rotation = await rotationRepository.findOne(rotationId);
    const { oldKeyId, newKeyId } = rotation;

    // 4. Marquer nouvelle clé ACTIVE
    await transactionalEntityManager
      .getRepository(KeyRecord)
      .update({ keyId: newKeyId }, { status: 'ACTIVE' });

    // 5. Marquer ancienne clé ARCHIVED
    await transactionalEntityManager
      .getRepository(KeyRecord)
      .update({ keyId: oldKeyId }, { status: 'ARCHIVED', archivedAt: new Date() });

    // Transaction committed si pas d'exception
  });

  return { success: true };
}

1.3 Couche Domaine (Événements)

EventRepository

Responsabilité : Persistance append-only des événements et signatures.

Schéma TypeORM :

@Entity('events')
export class Event {
  @PrimaryColumn('uuid')
  event_id: string;

  @Column('timestamp')
  timestamp: Date;

  @Column('varchar')
  type: string;

  @Column('char', { length: 64 })
  payload_hash: string; // SHA3-256 hex

  @Column('varchar')
  status: 'PENDING' | 'FINALIZED' | 'FAILED' | 'CANCELLED';

  @Column('jsonb')
  signatures: SignatureEntry[];

  @CreateDateColumn()
  created_at: Date;

  @UpdateDateColumn()
  updated_at: Date;

  @Index()
  @Column('timestamp')
  @Check(`status != 'FINALIZED' OR (event_id IS NOT NULL AND payload_hash IS NOT NULL)`)
  finalized_timestamp?: Date;
}

Méthode critique :

async appendSignature(
  event_id: string,
  signature: SignatureEntry
): Promise<void> {
  // Vérification append-only
  const event = await this.findOne({ where: { event_id } });

  if (!event) {
    throw new Error(`Event ${event_id} not found`);
  }

  // Append sans modification des signatures existantes
  const updatedSignatures = [...event.signatures, signature];

  await this.update(
    { event_id },
    { signatures: updatedSignatures }
  );
}

EventSignature (composant de Event)

interface SignatureEntry {
  keyId: string;
  algorithm: 'Ed25519';
  signature: string;        // base64
  signed_at: string;        // ISO-8601
  rotation_id: string;
  state: 'CANDIDATE' | 'ACTIVE';
}

1.4 Couche Audit

RotationAuditLogger

Responsabilité : Journalisation append-only des rotations avec garanties probatoires.

Exigences (Spec §10) :

  • Immutabilité (OBLIGATOIRE) : Stockage append-only WORM garanti par trigger DB
  • Trigger PostgreSQL BEFORE UPDATE OR DELETE levant exception RAISE EXCEPTION 'Audit logs are immutable'
  • Trigger créé dans migration initiale (voir §1.4.1 Migration DB)
  • Interdiction stricte de modification/suppression après insertion
  • Rétention (OBLIGATOIRE) : Durée minimale 10 ans
  • Politique de rétention PostgreSQL ou archivage automatique vers stockage externe immuable (S3 Object Lock)
  • Export (OBLIGATOIRE) : Format NDJSON ou JSON canonique RFC 8785
  • Mode NDJSON : une ligne JSON par log (JSON.stringify() standard)
  • Mode JSON_CANONICAL : canonicalisation RFC 8785 via lib canonicalize (déterminisme clés triées)

Schéma :

@Entity('rotation_audit_logs')
export class RotationAuditLog {
  @PrimaryColumn('uuid')
  rotation_id: string;

  @Column('timestamp')
  rotation_start_time: Date;

  @Column('timestamp')
  rotation_end_time: Date;

  @Column('varchar')
  trigger: 'MANUAL' | 'SECURITY_INCIDENT' | 'COMPLIANCE';

  @Column('jsonb')
  initiator: Identity;

  @Column('uuid')
  old_keyId: string;

  @Column('uuid')
  new_keyId: string;

  @Column('varchar')
  algorithm: 'Ed25519';

  @Column('int')
  eligible_count: number;

  @Column('jsonb')
  event_ids: string[]; // ou hash si > 100k

  @Column('varchar')
  final_status: 'SUCCESS' | 'ROTATION_FAILED';

  @Column('text', { nullable: true })
  failure_reason?: string;

  @CreateDateColumn()
  created_at: Date;
}

Migration DB (§1.4.1)

Trigger WORM obligatoire : Création du trigger d'immutabilité lors de la migration initiale.

-- Migration: create_rotation_audit_logs_table.sql

CREATE TABLE rotation_audit_logs (
  rotation_id UUID PRIMARY KEY,
  rotation_start_time TIMESTAMP NOT NULL,
  rotation_end_time TIMESTAMP NOT NULL,
  trigger VARCHAR(50) NOT NULL CHECK (trigger IN ('MANUAL', 'SECURITY_INCIDENT', 'COMPLIANCE')),
  initiator JSONB NOT NULL,
  old_key_id UUID NOT NULL,
  new_key_id UUID NOT NULL,
  algorithm VARCHAR(20) NOT NULL DEFAULT 'Ed25519',
  eligible_count INTEGER NOT NULL,
  event_ids JSONB NOT NULL,
  final_status VARCHAR(50) NOT NULL CHECK (final_status IN ('SUCCESS', 'ROTATION_FAILED')),
  failure_reason TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Index pour recherche temporelle
CREATE INDEX idx_rotation_audit_logs_start_time
  ON rotation_audit_logs(rotation_start_time);

-- TRIGGER WORM : Interdiction UPDATE/DELETE (immutabilité §10.3)
CREATE OR REPLACE FUNCTION prevent_audit_log_modification()
RETURNS TRIGGER AS $$
BEGIN
  RAISE EXCEPTION 'Audit logs are immutable (PD-40 §10.3). Cannot UPDATE or DELETE rotation_audit_logs.';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER audit_logs_immutability_trigger
  BEFORE UPDATE OR DELETE ON rotation_audit_logs
  FOR EACH ROW
  EXECUTE FUNCTION prevent_audit_log_modification();

-- Test du trigger (doit échouer)
-- UPDATE rotation_audit_logs SET final_status = 'SUCCESS' WHERE rotation_id = 'xxx';
-- DELETE FROM rotation_audit_logs WHERE rotation_id = 'xxx';

Méthodes :

  • logRotation(session: RotationSession, status: FinalStatus, failureReason?: string): Promise<void>
  • Insère un enregistrement immuable dans rotation_audit_logs
  • Trigger DB empêche UPDATE/DELETE (immutabilité)
  • exportRotationLogs(startDate: Date, endDate: Date, format: 'NDJSON' | 'JSON_CANONICAL'): Promise<string>
  • Export des logs dans le format spécifié (RFC 8785 pour JSON canonique)
  • Retourne le contenu prêt pour archivage externe ou audit
  • validateRetentionPolicy(): Promise<ValidationResult>
  • Vérifie que les logs de plus de 10 ans sont toujours présents et accessibles

Méthode :

async logRotation(session: RotationSession, result: RotationResult): Promise<void> {
  const log = new RotationAuditLog();
  log.rotation_id = session.rotationId;
  log.rotation_start_time = session.rotationStartTime;
  log.rotation_end_time = new Date();
  log.trigger = session.trigger;
  log.initiator = session.initiator;
  log.old_keyId = session.oldKeyId;
  log.new_keyId = session.newKeyId;
  log.algorithm = 'Ed25519';
  log.eligible_count = session.eligibleCount;
  log.event_ids = result.processedEventIds;
  log.final_status = result.success ? 'SUCCESS' : 'ROTATION_FAILED';
  log.failure_reason = result.failureReason;

  await this.save(log);
}

Export conforme RFC 8785 :

import canonicalize from 'canonicalize'; // RFC 8785 deterministic JSON

async exportRotationLogs(
  startDate: Date,
  endDate: Date,
  format: 'NDJSON' | 'JSON_CANONICAL'
): Promise<string> {
  const logs = await this.find({
    where: {
      rotation_start_time: Between(startDate, endDate),
    },
    order: { rotation_start_time: 'ASC' },
  });

  if (format === 'NDJSON') {
    // NDJSON : une ligne JSON par log (stringify standard)
    return logs.map(log => JSON.stringify(log)).join('\n');
  }

  if (format === 'JSON_CANONICAL') {
    // RFC 8785 : canonicalisation déterministe (clés triées, pas d'espaces)
    // Garantit hash reproductible pour vérification d'intégrité
    return logs.map(log => {
      const plainObject = {
        rotation_id: log.rotation_id,
        rotation_start_time: log.rotation_start_time.toISOString(),
        rotation_end_time: log.rotation_end_time.toISOString(),
        trigger: log.trigger,
        initiator: log.initiator,
        old_keyId: log.old_keyId,
        new_keyId: log.new_keyId,
        algorithm: log.algorithm,
        eligible_count: log.eligible_count,
        event_ids: log.event_ids,
        final_status: log.final_status,
        failure_reason: log.failure_reason,
        created_at: log.created_at.toISOString(),
      };

      // canonicalize() applique RFC 8785 : tri des clés, encodage Unicode normalisé
      return canonicalize(plainObject);
    }).join('\n');
  }

  throw new Error(`Unsupported export format: ${format}`);
}

2. Flux techniques

2.1 Flux N1-N5 : Rotation réussie

┌─────────────────────────────────────────────────────────────────┐
│ 1. DÉCLENCHEMENT (N1)                                           │
│    RotationOrchestrator.initiateRotation()                      │
│    ├─ Créer rotation_id (UUID v4)                              │
│    ├─ Créer rotation_start_time = now()                        │
│    ├─ Enregistrer trigger + initiator                          │
│    └─ Retourner RotationSession                                │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. GÉNÉRATION DE CLÉ (N2)                                       │
│    HsmKeyManager.generateKeyPair('Ed25519')                     │
│    ├─ Appel SDK HSM : generateKey(algorithm='Ed25519')         │
│    ├─ Récupérer keyId (généré par HSM ou assigné)              │
│    ├─ Persister KeyRecord { keyId, status='CANDIDATE', ... }   │
│    └─ Mettre à jour session.newKeyId = keyId                   │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. CONSTRUCTION ENSEMBLE E (N3)                                 │
│    EventSelector.selectEligibleEvents(rotation_start_time)      │
│    ├─ Fenêtre: ]rotation_start_time - 24h ; rotation_start_time[│
│    ├─ Types: CREATE, UPDATE_METADATA, ACCESS_LOG, etc.         │
│    ├─ Status: FINALIZED uniquement                             │
│    ├─ Ordre: (timestamp ASC, event_id ASC)                     │
│    ├─ Vérifier |E| ≤ 100 000                                   │
│    └─ Retourner Event[]                                        │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 4. RE-SIGNATURE (N4)                                            │
│    ResigningService.resignEvents(E, newKeyId, rotationId)      │
│    FOR EACH event IN E:                                         │
│      ├─ Construire EventToSign { event_id, timestamp, type,    │
│      │                           payload_hash }                 │
│      ├─ Canonicaliser JSON (RFC 8785)                          │
│      ├─ HsmKeyManager.sign(newKeyId, canonicalPayload)         │
│      ├─ Créer SignatureEntry { state='CANDIDATE', ... }        │
│      └─ EventRepository.appendSignature(event_id, signature)   │
│    END FOR                                                      │
│    └─ Si AUCUNE erreur → continuer, sinon → N6                 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 5. PROMOTION ATOMIQUE (N5)                                      │
│    PromotionService.promoteSignatures(rotationId)               │
│    TRANSACTION:                                                 │
│      ├─ Vérifier count(CANDIDATE) = eligible_count             │
│      ├─ UPDATE signatures SET state='ACTIVE'                   │
│      │   WHERE rotation_id=X AND state='CANDIDATE'             │
│      ├─ UPDATE keys SET status='ACTIVE'                        │
│      │   WHERE keyId=newKeyId                                  │
│      ├─ UPDATE keys SET status='ARCHIVED'                      │
│      │   WHERE keyId=oldKeyId                                  │
│      └─ COMMIT                                                 │
│                                                                 │
│    RotationAuditLogger.logRotation(session, SUCCESS)            │
└─────────────────────────────────────────────────────────────────┘

2.2 Flux N6 : Rotation échouée

┌─────────────────────────────────────────────────────────────────┐
│ 4. RE-SIGNATURE (N4) - ÉCHEC                                    │
│    ResigningService.resignEvents(E, newKeyId, rotationId)      │
│    FOR EACH event IN E:                                         │
│      ├─ ...                                                     │
│      └─ ERREUR HSM / TIMEOUT / EXCEPTION                       │
│    BREAK                                                        │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 6. ROLLBACK (N6)                                                │
│    ├─ AUCUNE PROMOTION (pas de UPDATE signatures)              │
│    ├─ Les signatures CANDIDATE restent CANDIDATE (ignorées)    │
│    ├─ UPDATE keys SET status='DISCARDED' WHERE keyId=newKeyId  │
│    ├─ L'ancienne clé reste ACTIVE                              │
│    ├─ RotationAuditLogger.logRotation(session, FAILED,         │
│    │                                   failureReason)           │
│    └─ Retourner RotationResult { success: false, ... }         │
└─────────────────────────────────────────────────────────────────┘

3. Mapping invariants → mécanismes

# Invariant Mécanisme technique
INV-1 Clés privées ne sortent jamais du HSM Configuration HSM : exportable=false pour toutes les clés. SDK HSM garantit non-exportabilité.
INV-2 Signatures produites exclusivement par HSM Toutes les signatures via HsmKeyManager.sign() qui appelle l'API HSM. Aucune signature software.
INV-3 Unicité stricte de la clé active Contrainte DB : UNIQUE INDEX ON keys(status) WHERE status='ACTIVE'. Vérification avant promotion.
INV-4 Clé archivée ne peut produire signatures ACTIVE HsmKeyManager.sign() vérifie status == 'ACTIVE' avant signature. Throw KEY_NOT_ACTIVE_ERROR si ARCHIVED/CANDIDATE/DISCARDED.
INV-5 Modèle append-only (événements et signatures) EventRepository.appendSignature() : jamais de DELETE/UPDATE d'entrées existantes. JSONB append.
INV-6 Signature valide ssi state=ACTIVE Toute vérification ignore state='CANDIDATE'. Filtre SQL : WHERE state='ACTIVE'.
INV-7 Toute signature ACTIVE reste vérifiable Conservation des clés publiques archivées dans KeyRepository. Export possible pour vérification.
INV-8 Atomicité par promotion d'état Transaction DB unique dans PromotionService.promoteSignatures(). COMMIT ou ROLLBACK.
INV-9 Sélection E déterministe, bornée, testable EventSelector : requête SQL avec WHERE/ORDER/LIMIT stricts. Reproductible avec même rotation_start_time.
INV-10 Rotation authentifiée, autorisée, journalisée, exportable RotationAuditLogger : stockage WORM/append-only (trigger DB anti-UPDATE/DELETE), rétention 10 ans, export NDJSON/JSON RFC 8785. Vérification identité dans initiateRotation().

4. Gestion des erreurs

4.1 Erreurs techniques

Code erreur Cause Traitement
HSM_UNREACHABLE HSM indisponible / timeout Échec rotation (N6). Log détaillé. Alerte ops.
KEY_GENERATION_FAILED Échec génération Ed25519 Échec rotation (N6). Vérifier quota HSM.
SIGNING_FAILED Échec signature d'un événement Arrêt immédiat re-signature. Rollback (N6).
ELIGIBLE_SET_TOO_LARGE E
PROMOTION_INCOMPLETE Signatures CANDIDATE manquantes Exception transaction. Rollback DB.
DATABASE_TRANSACTION_FAILED Échec COMMIT transaction Rollback automatique. Retry possible si idempotent.

4.2 Erreurs métier

Code erreur Cause Traitement
UNAUTHORIZED_TRIGGER Initiateur non autorisé Rejet avant N1. HTTP 403. Log sécurité.
ROTATION_ALREADY_IN_PROGRESS Rotation concurrente Rejet. HTTP 409.
NO_ACTIVE_KEY Aucune clé active détectée État incohérent. Alerte critique. Blocage rotation.
MULTIPLE_ACTIVE_KEYS Violation INV-3 État incohérent. Alerte critique. Blocage rotation. Intervention manuelle.

4.3 Stratégie de retry

Opérations retryables :

  • Appels HSM (timeout réseau)
  • Requêtes DB (deadlock)

Opérations non-retryables :

  • Promotion atomique (déjà validée ou rollback)
  • Génération de clé (risque duplication keyId)

Configuration :

  • Max 3 tentatives
  • Backoff exponentiel : 1s, 2s, 4s

5. Impacts sécurité

5.1 Surface d'attaque

Vecteur Risque Mitigation
Accès non autorisé à HSM Signature frauduleuse Authentification forte HSM. Logs d'accès HSM.
Compromission credentials HSM Vol de clé privée (si exportable) Clés non-exportables (INV-1). Rotation credentials HSM.
Injection SQL dans sélection E Manipulation ensemble éligible Parameterized queries. ORM TypeORM.
Modification base de données Altération signatures ACTIVE Append-only enforced. DB audit trail. Backups immuables.
Rejeu de rotation Promotion multiple Vérification rotation_id unique. Index UNIQUE.

5.2 Conformité

RGPD :

  • Aucune donnée personnelle dans EventToSign (uniquement payload_hash)
  • Audit logs : pseudonymisation initiator si requis

eIDAS :

  • Ed25519 conforme eIDAS (signature électronique qualifiée si HSM qualifié)
  • Horodatage qualifié : intégration TSA si requis (hors périmètre PD-40)

FIPS 140-⅔ :

  • Vérification certification HSM à l'initialisation
  • Refus démarrage si certification invalide

6. Hypothèses techniques

6.1 Hypothèses critiques

  1. HSM disponible et certifié :
  2. Hypothèse : Un HSM conforme FIPS 140-⅔ niveau 3 (ou RGS v2 niveau 2+) est provisionné et accessible.
  3. Impact si faux : Blocage complet. Mitigation : Vérification pré-déploiement.

  4. Base de données supporte transactions ACID :

  5. Hypothèse : PostgreSQL (ou équivalent) avec support SERIALIZABLE.
  6. Impact si faux : Violation INV-8 (atomicité). Mitigation : Tests transactionnels.

  7. Clé publique exportable du HSM :

  8. Hypothèse : Le HSM permet export de la clé publique Ed25519 (pour vérification).
  9. Impact si faux : Vérification externe impossible. Mitigation : Validation SDK HSM.

  10. Canonicalisation RFC 8785 disponible :

  11. Hypothèse : Bibliothèque canonicalize (npm) correcte.
  12. Impact si faux : Signatures invalides. Mitigation : Tests vecteurs RFC 8785.

  13. Volume événements < N_max :

  14. Hypothèse : En production, |E| ≤ 100 000 dans 99% des cas.
  15. Impact si faux : Rotations échouent fréquemment. Mitigation : Rotation proactive avant seuil.

  16. Pas de rotation concurrente :

  17. Hypothèse : Lock distribué (Redis/DB) empêche deux rotations simultanées.
  18. Impact si faux : État incohérent. Mitigation : Mutex rotation_in_progress.

6.2 Hypothèses non-critiques

  • Performance HSM : > 100 signatures/s (sinon rotation lente mais fonctionnelle).
  • Réseau HSM : latence < 100ms (sinon timeout à ajuster).
  • Stockage audit : suffisant pour 10 ans (monitoring capacité).

7. Points de vigilance

7.1 Performance

Goulot d'étranglement : Signature HSM

  • Temps par signature : ~10-50ms (dépend du HSM)
  • Pour 100 000 événements : 16-83 minutes théoriques
  • Optimisation :
  • Parallélisation (si HSM multi-thread) : pool de connexions
  • Batch signing si HSM le supporte (rare pour Ed25519)

Recommandation : Monitoring temps de rotation. Alertes si > 2h.

7.2 Cohérence état

Risque : Échec partiel entre signature et promotion

  • Scénario : 50 000 événements signés (CANDIDATE), puis crash avant promotion.
  • État : 50 000 signatures CANDIDATE orphelines (ignorées).
  • Résolution : Cleanup automatique des CANDIDATE > 7 jours sans promotion.

Mécanisme cleanup :

async cleanupOrphanedCandidates(): Promise<void> {
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);

  // Identifier rotations CANDIDATE anciennes
  const orphanedRotations = await rotationRepository.find({
    where: {
      final_status: IsNull(),
      rotation_start_time: LessThan(sevenDaysAgo),
    },
  });

  for (const rotation of orphanedRotations) {
    // Marquer rotation comme FAILED
    await rotationRepository.update(rotation.rotation_id, {
      final_status: 'ROTATION_FAILED',
      failure_reason: 'Cleanup: orphaned CANDIDATE signatures',
    });

    // Les signatures CANDIDATE restent (append-only) mais sont ignorées
    // Optionnel : supprimer clé CANDIDATE du HSM
    await hsmKeyManager.deleteKey(rotation.new_keyId);
  }
}

7.3 Vérification post-rotation

Checklist automatique après promotion :

async verifyRotationSuccess(rotationId: string): Promise<VerificationResult> {
  const rotation = await rotationRepository.findOne(rotationId);
  const { newKeyId, eligibleCount } = rotation;

  // 1. Vérifier count signatures ACTIVE ajoutées
  const activeSignatures = await eventRepository
    .createQueryBuilder('e')
    .where(`signatures @> '[{"rotation_id": "${rotationId}", "state": "ACTIVE"}]'::jsonb`)
    .getCount();

  if (activeSignatures !== eligibleCount) {
    return {
      success: false,
      error: `Expected ${eligibleCount} ACTIVE signatures, found ${activeSignatures}`,
    };
  }

  // 2. Vérifier unicité clé ACTIVE
  const activeKeys = await keyRepository.count({ where: { status: 'ACTIVE' } });
  if (activeKeys !== 1) {
    return {
      success: false,
      error: `Expected 1 ACTIVE key, found ${activeKeys}`,
    };
  }

  // 3. Vérifier ancienne clé ARCHIVED
  const oldKey = await keyRepository.findOne(rotation.oldKeyId);
  if (oldKey.status !== 'ARCHIVED') {
    return {
      success: false,
      error: `Old key ${oldKey.keyId} not ARCHIVED (status: ${oldKey.status})`,
    };
  }

  return { success: true };
}

7.4 Monitoring et alertes

Métriques critiques :

  • rotation_duration_seconds (histogram)
  • rotation_success_total (counter)
  • rotation_failure_total (counter, labelisé par failure_reason)
  • active_keys_count (gauge, DOIT = 1)
  • candidate_signatures_orphaned_total (gauge)

Alertes :

  • active_keys_count != 1 → CRITIQUE (violation INV-3)
  • rotation_duration_seconds > 7200 → WARNING (> 2h)
  • rotation_failure_total rate > 10% → WARNING

7.5 Tests critiques

Tests d'acceptation (CA1-CA7) :

describe('PD-40 Acceptance Criteria', () => {
  it('CA1: Unicité clé active', async () => {
    const activeKeys = await keyRepository.find({ status: 'ACTIVE' });
    expect(activeKeys).toHaveLength(1);
  });

  it('CA5: Tous événements E ont signature ACTIVE supplémentaire', async () => {
    const rotationId = await executeRotation();
    const eligibleEvents = await selectEligibleEvents(rotation.start_time);

    for (const event of eligibleEvents) {
      const activeSignaturesForNewKey = event.signatures.filter(
        sig => sig.rotation_id === rotationId && sig.state === 'ACTIVE'
      );
      expect(activeSignaturesForNewKey).toHaveLength(1);
    }
  });

  it('CA6: Échec = aucune signature ACTIVE supplémentaire', async () => {
    // Simuler échec HSM
    jest.spyOn(hsmKeyManager, 'sign').mockRejectedValue(new Error('HSM timeout'));

    const result = await executeRotation();
    expect(result.success).toBe(false);

    // Vérifier aucune signature ACTIVE ajoutée
    const activeSignaturesAdded = await countActiveSignaturesForRotation(result.rotationId);
    expect(activeSignaturesAdded).toBe(0);
  });

  it('CA8: Atomicité promotion', async () => {
    // Vérifier que promotion est tout-ou-rien
    const rotationId = await startRotation();
    await resignAllEvents(rotationId);

    // Simuler échec au milieu de la promotion
    jest.spyOn(keyRepository, 'update').mockImplementationOnce(() => {
      throw new Error('DB error');
    });

    await expect(promoteSignatures(rotationId)).rejects.toThrow();

    // Vérifier rollback : aucune signature promue
    const promotedCount = await countPromotedSignatures(rotationId);
    expect(promotedCount).toBe(0);
  });
});

8. Séquence d'implémentation recommandée

Phase 1 : Fondations (HSM + Domaine)

  1. Implémenter HsmKeyManager (wrapper SDK HSM)
  2. Implémenter KeyRepository + schéma DB
  3. Implémenter Event + EventRepository
  4. Tests unitaires canonicalisation RFC 8785

Phase 2 : Sélection et re-signature

  1. Implémenter EventSelector
  2. Implémenter ResigningService
  3. Tests end-to-end signature HSM

Phase 3 : Orchestration et atomicité

  1. Implémenter PromotionService (transaction)
  2. Implémenter RotationOrchestrator
  3. Tests transactionnels (rollback)

Phase 4 : Audit et conformité

  1. Implémenter RotationAuditLogger
  2. Implémenter export NDJSON
  3. Tests rétention et export

Phase 5 : Validation

  1. Tests d'acceptation (CA1-CA7)
  2. Tests de charge (100 000 événements)
  3. Vérification certification HSM

9. Dépendances externes

Composant Version Usage Criticité
PostgreSQL ≥ 13 Base de données ACID CRITIQUE
TypeORM ≥ 0.3 ORM + transactions CRITIQUE
AWS CloudHSM SDK latest Interface HSM (si AWS) CRITIQUE
canonicalize (npm) ≥ 1.0 RFC 8785 CRITIQUE
tweetnacl ≥ 1.0 Vérification Ed25519 (tests) MOYENNE
jest ≥ 29 Tests MOYENNE

Note : Le choix du SDK HSM dépend du provider (AWS CloudHSM, Azure Managed HSM, Thales Luna, etc.).


10. Livrables attendus

  • Code production : src/modules/crypto/rotation/
  • Tests unitaires : couverture > 90%
  • Tests d'acceptation : CA1-CA7 validés
  • Migration DB : schémas events, keys, rotation_audit_logs
  • Configuration HSM : paramètres + vérification certification
  • Documentation opérationnelle : procédure rotation manuelle
  • Monitoring : dashboards Grafana + alertes
  • Export audit : script NDJSON

11. Notes de révision

Révision 2026-01-01 : Corrections E-05 et E-06

Écarts corrigés :

E-05 : Journal WORM non garanti (MAJEUR) — ✅ CORRIGÉ

Problème identifié : Plan v1 proposait "Option 1 trigger" OU "Option 2 export WORM", laissant l'immutabilité optionnelle au lieu de garantie stricte requise par spec §10.3.

Corrections apportées :

  • Trigger DB OBLIGATOIRE (§1.4 Exigences) :
  • Suppression formulation "Option 1/Option 2"
  • Exigence obligatoire : "Immutabilité (OBLIGATOIRE) : Stockage append-only WORM garanti par trigger DB"
  • Trigger PostgreSQL BEFORE UPDATE OR DELETE levant RAISE EXCEPTION 'Audit logs are immutable'
  • Migration SQL complète (§1.4.1 Migration DB) :
  • Ajout script SQL complet de création table + trigger immutabilité
  • Fonction prevent_audit_log_modification() avec RAISE EXCEPTION
  • Tests d'échec commentés pour validation

Conformité : Spec §10.3 "Stockage append-only (WORM ou équivalent)" → trigger DB garantit WORM.

E-06 : Export JSON canonique non conforme RFC 8785 (MAJEUR) — ✅ CORRIGÉ

Problème identifié : Méthode exportAuditLogs() utilisait simple JSON.stringify() pour TOUS les formats, ne satisfaisant pas l'exigence RFC 8785 pour mode JSON_CANONICAL (spec §10.2/10.5).

Corrections apportées :

  • Implémentation RFC 8785 explicite (§1.4 Export conforme RFC 8785) :
  • Import lib canonicalize pour canonicalisation déterministe
  • Branche conditionnelle if (format === 'JSON_CANONICAL') utilisant canonicalize(plainObject)
  • Conversion dates en ISO-8601 string pour canonicalisation
  • Commentaire : "canonicalize() applique RFC 8785 : tri des clés, encodage Unicode normalisé"
  • Mode NDJSON préservé : JSON.stringify() standard pour NDJSON (conforme spec)

Conformité : Spec §10.2 "NDJSON ou JSON canonique (RFC 8785)" → implémentation différenciée garantit conformité.


Révision 2025-12-31 : Corrections E-03 et E-04

Écarts corrigés :

E-03 : Audit probatoire non conforme (MAJEUR) — ⚠️ PARTIELLEMENT CORRIGÉ

Problème identifié : Plan initial ne spécifiait pas les garanties d'immutabilité (WORM), de rétention (10 ans) et d'export (NDJSON/RFC 8785) requises par la spec §10.

Corrections apportées :

  • Ajout exigences explicites dans RotationAuditLogger (§1.4) :
  • Immutabilité : trigger DB BEFORE UPDATE/DELETE RAISE EXCEPTION ou export WORM (S3 Object Lock)
  • Rétention : politique 10 ans minimum
  • Export : méthode exportRotationLogs() format NDJSON/JSON RFC 8785
  • Mise à jour INV-10 mapping : "stockage WORM/append-only (trigger DB anti-UPDATE/DELETE), rétention 10 ans, export NDJSON/JSON RFC 8785"

Note : Correction complétée le 2026-01-01 via E-05 (trigger obligatoire) et E-06 (RFC 8785 explicite).

E-04 : Inhibition d'une clé archivée côté HSM non démontrée (MAJEUR) — ✅ CORRIGÉ

Problème identifié : Plan initial ne documentait pas le contrôle d'état status == 'ACTIVE' avant signature, permettant potentiellement l'usage d'une clé ARCHIVED (violation INV-4).

Corrections apportées :

  • Ajout garde explicite dans HsmKeyManager.sign() (§1.1) :
  • Vérification status == 'ACTIVE' avant appel HSM
  • Throw KEY_NOT_ACTIVE_ERROR si clé ARCHIVED/CANDIDATE/DISCARDED
  • Mise à jour INV-4 mapping : "vérifie status == 'ACTIVE' avant signature. Throw KEY_NOT_ACTIVE_ERROR si ARCHIVED/CANDIDATE/DISCARDED"

Écarts rejetés (fausses alertes) :

E-01 : Couverture des événements insuffisante — ❌ FAUSSE ALERTE

Rejet : La spec PD-40 §5.1 impose explicitement "ΔT = 24 heures (valeur normative)" et §5.4 "N_max = 100 000 événements par rotation". Le plan est strictement conforme à la spec.

E-02 : Atomicité « tout ou rien » non respectée en échec — ❌ FAUSSE ALERTE

Rejet : La spec PD-40 §9 clarifie : "le terme « rollback » signifie absence de promotion (pas de suppression)". Les signatures CANDIDATE restant en base après échec sont ignorées (INV-6 : "Signature valide ssi state=ACTIVE"). Le modèle append-only interdit la suppression (INV-5). Le plan est conforme.


Fin du plan d'implémentation PD-40