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'(throwKEY_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
statuspour requêteSELECT * 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 DELETElevant exceptionRAISE 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(uniquementpayload_hash) - Audit logs : pseudonymisation
initiatorsi 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¶
- HSM disponible et certifié :
- Hypothèse : Un HSM conforme FIPS 140-⅔ niveau 3 (ou RGS v2 niveau 2+) est provisionné et accessible.
-
Impact si faux : Blocage complet. Mitigation : Vérification pré-déploiement.
-
Base de données supporte transactions ACID :
- Hypothèse : PostgreSQL (ou équivalent) avec support SERIALIZABLE.
-
Impact si faux : Violation INV-8 (atomicité). Mitigation : Tests transactionnels.
-
Clé publique exportable du HSM :
- Hypothèse : Le HSM permet export de la clé publique Ed25519 (pour vérification).
-
Impact si faux : Vérification externe impossible. Mitigation : Validation SDK HSM.
-
Canonicalisation RFC 8785 disponible :
- Hypothèse : Bibliothèque
canonicalize(npm) correcte. -
Impact si faux : Signatures invalides. Mitigation : Tests vecteurs RFC 8785.
-
Volume événements < N_max :
- Hypothèse : En production, |E| ≤ 100 000 dans 99% des cas.
-
Impact si faux : Rotations échouent fréquemment. Mitigation : Rotation proactive avant seuil.
-
Pas de rotation concurrente :
- Hypothèse : Lock distribué (Redis/DB) empêche deux rotations simultanées.
- 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é parfailure_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_totalrate > 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)¶
- Implémenter
HsmKeyManager(wrapper SDK HSM) - Implémenter
KeyRepository+ schéma DB - Implémenter
Event+EventRepository - Tests unitaires canonicalisation RFC 8785
Phase 2 : Sélection et re-signature¶
- Implémenter
EventSelector - Implémenter
ResigningService - Tests end-to-end signature HSM
Phase 3 : Orchestration et atomicité¶
- Implémenter
PromotionService(transaction) - Implémenter
RotationOrchestrator - Tests transactionnels (rollback)
Phase 4 : Audit et conformité¶
- Implémenter
RotationAuditLogger - Implémenter export NDJSON
- Tests rétention et export
Phase 5 : Validation¶
- Tests d'acceptation (CA1-CA7)
- Tests de charge (100 000 événements)
- 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 DELETElevantRAISE 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()avecRAISE 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
canonicalizepour canonicalisation déterministe - Branche conditionnelle
if (format === 'JSON_CANONICAL')utilisantcanonicalize(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_ERRORsi clé ARCHIVED/CANDIDATE/DISCARDED - Mise à jour INV-4 mapping : "vérifie
status == 'ACTIVE'avant signature. ThrowKEY_NOT_ACTIVE_ERRORsi 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