Aller au contenu

PD-275 — Plan d'implémentation

1. Découpage en composants

C1 — Migration DDL (migration-pd275)

Responsabilité : Ajouter la colonne confirmation_count sur anchor_batches et créer la table signer_registry dans le schéma vault_blockchain.

Fichiers : - src/database/migrations/XXXXXXXXX-pd275-finality-signer.ts

Détails : - anchor_batches.confirmation_count : INTEGER NOT NULL DEFAULT 0, CHECK >= 0 AND <= 2147483647 - Table vault_blockchain.signer_registry : - address VARCHAR(42) PRIMARY KEY (EIP-55) - status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE' CHECK IN ('ACTIVE', 'REVOKED') - revoked_at TIMESTAMPTZ NULL - revoked_by VARCHAR(255) NULL - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - Index : idx_signer_registry_status sur (status) - Migration down : DROP signer_registry, DROP COLUMN confirmation_count

C2 — Entity SignerRegistry (signer-registry-entity)

Responsabilité : Entité TypeORM pour le registre des signers autorisés.

Fichiers : - src/modules/anchor/entities/signer-registry.entity.ts

Détails : - Schéma : vault_blockchain - Table : signer_registry - Enum : SignerStatus (ACTIVE, REVOKED) dans src/modules/anchor/enums/signer-status.enum.ts - Colonnes mappées : address, status, revokedAt, revokedBy, createdAt, updatedAt

C3 — Extension Entity AnchorBatch (anchor-batch-extension)

Responsabilité : Ajouter la colonne confirmation_count à l'entité existante AnchorBatch.

Fichiers : - src/modules/anchor/entities/anchor-batch.entity.ts (modification)

Détails : - Nouveau champ : @Column({ type: 'int', name: 'confirmation_count', default: 0 }) confirmationCount!: number;

C4 — Service SignerRegistryService (signer-registry-service)

Responsabilité : CRUD et logique métier du registre des signers. Centralise les gardes signer (isActive, revoke) avec verrouillage pessimiste.

Fichiers : - src/modules/anchor/services/signer-registry.service.ts

Méthodes : - findByAddress(address: string, manager?: EntityManager): Promise<SignerRegistry | null> — lecture simple - findByAddressForUpdate(address: string, manager: EntityManager): Promise<SignerRegistry | null>SELECT ... FOR UPDATE - assertSignerActive(address: string, manager: EntityManager): Promise<void> — fail-closed, throws ERR-SIGNER-REVOKED ou ERR-SIGNER-NOT-FOUND - revokeSigner(address: string, actorIdentity: string, actorRoles: string[], manager: EntityManager): Promise<SignerRegistry> — transition atomique ACTIVE -> REVOKED + audit trail. Correction v2 (MAJ-02) : Le service vérifie que actorRoles contient ADMIN ou SIGNER_ADMIN — throws ERR-REVOKE-UNAUTHORIZED si non autorisé. Double protection : controller (@Roles) + service (assertion interne) pour couvrir les appels service-to-service.

C5 — Service FinalityGuardService (finality-guard-service)

Responsabilité : Centralise la logique de garde de finalité (confirmation_count >= FINALITY_DEPTH).

Fichiers : - src/modules/anchor/services/finality-guard.service.ts

Méthodes : - assertFinalityReached(batch: AnchorBatch): void — throws ERR-FINALITY-INSUFFICIENT si confirmation_count < FINALITY_DEPTH - updateConfirmationCount(batchId: string, count: number): Promise<void> — valide bornes [0, 2147483647], persiste

Configuration : - FINALITY_DEPTH lu depuis ConfigService (anchor.finalityDepth), valeur par défaut configurable par environnement

C6 — Modification AnchorBatchService (anchor-batch-service-extension)

Responsabilité : Intégrer les gardes de finalité et de signer dans les flux existants finalizeBatch() et submitBatch().

Fichiers : - src/modules/anchor/services/anchor-batch.service.ts (modification)

Modifications : - finalizeBatch() : avant la transition PENDING_FINALITY -> FINALIZED, appeler FinalityGuardService.assertFinalityReached(batch). Le confirmation_count est déjà persisté par C5. - submitBatch() : dans la même transaction, appeler SignerRegistryService.assertSignerActive(signerAddress, manager) avec SELECT ... FOR UPDATE avant d'autoriser la soumission. - Les deux méthodes conservent leur transaction existante et ajoutent les gardes dans le scope transactionnel.

C7 — Controller SignerController (signer-controller)

Responsabilité : Exposer l'endpoint REST POST /anchor/signers/:address/revoke pour la révocation.

Fichiers : - src/modules/anchor/controllers/signer.controller.ts - src/modules/anchor/dto/revoke-signer.dto.ts

Détails : - Guard : OidcJwtAuthGuard + @Roles('ADMIN', 'SIGNER_ADMIN') (au moins un des deux rôles) - Extraction actorIdentity depuis request.user.sub (JWT claim, jamais du payload) - Validation anti-spoofing : si le body contient un champ revokedBy, retourner ERR-REVOKEDBY-SPOOFING (400 Bad Request) - Appel transactionnel à SignerRegistryService.revokeSigner(address, actorIdentity, manager) - Audit event : SIGNER_REVOKED - Réponses : 200 (succès), 400 (spoofing), 403 (unauthorized), 404 (not found), 409 (already revoked)

C8 — Guard SignerActiveGuard (SUPPRIMÉ — correction v2)

Correction v2 (MAJ-01/MAJ-03) : Le guard NestJS HTTP SignerActiveGuard est supprimé du plan. Le contrôle signer est effectué exclusivement dans le service layer (SignerRegistryService.assertSignerActive()) avec SELECT ... FOR UPDATE transactionnel, conformément à INV-275-11. Le flux principal passe par un worker BullMQ (pas une route HTTP), rendant un guard HTTP non pertinent. Un guard HTTP serait un pré-contrôle sans transaction, créant un risque TOCTOU contradictoire avec la sérialisation contractuelle.

C9 — Audit Events Extension (audit-events-extension)

Responsabilité : Enrichir le registre d'audit avec les événements PD-275.

Fichiers : - src/modules/audit/types/audit-action.types.ts (modification)

Nouveaux événements : - SIGNER_REVOKED — après revocation réussie - SIGNER_REVOKE_DENIED — après refus de revocation (unauthorized, already revoked) - FINALITY_CHECK_FAILED — après refus de finalisation pour confirmation insuffisante - CONFIRMATION_COUNT_UPDATED — après mise à jour du compteur

C10 — Error Codes Extension (error-codes-extension)

Responsabilité : Ajouter les codes d'erreur PD-275 au registre existant.

Fichiers : - src/modules/anchor/constants/error-codes.ts (nouveau ou extension de src/modules/blockchain/constants/error-codes.ts)

Codes : - ERR-FINALITY-INSUFFICIENT - ERR-CONFIRMATION-COUNT-INVALID - ERR-SIGNER-NOT-FOUND - ERR-SIGNER-REVOKED - ERR-SIGNER-ALREADY-REVOKED - ERR-AUDIT-TRAIL-MISSING - ERR-REVOKE-UNAUTHORIZED - ERR-REVOKEDBY-SPOOFING

C11 — Module Registration (module-registration)

Responsabilité : Enregistrer les nouveaux services, entités, controllers et guards dans AnchorModule.

Fichiers : - src/modules/anchor/anchor.module.ts (modification)

Détails : - TypeOrmModule.forFeature([..., SignerRegistry]) - Providers : SignerRegistryService, FinalityGuardService - Controllers : SignerController


2. Flux techniques

FT1 — Mise à jour du compteur de confirmations

ConfirmationTracker.track(network, txHash)
  ├─ poll RPC → confirmations = N
  └─ FinalityGuardService.updateConfirmationCount(batchId, N)
       ├─ Valider N ∈ [0, 2147483647] → sinon throw ERR-CONFIRMATION-COUNT-INVALID
       ├─ UPDATE anchor_batches SET confirmation_count = N WHERE batch_id = :id
       └─ AuditService.logEvent('CONFIRMATION_COUNT_UPDATED', SYSTEM_USER_ID, batchId, { count: N })

FT2 — Finalisation avec garde de finalité

AnchorBatchService.finalizeBatch(batchId, txResult)
  ├─ queryRunner.startTransaction()
  ├─ SELECT * FROM anchor_batches WHERE batch_id = :id FOR UPDATE
  ├─ FinalityGuardService.assertFinalityReached(batch)
  │    └─ IF batch.confirmationCount < FINALITY_DEPTH
  │         → throw BlockchainError(ERR-FINALITY-INSUFFICIENT)
  │         → AuditService.logEvent('FINALITY_CHECK_FAILED')
  ├─ isValidAnchorStatusTransition(batch.status, FINALIZED) → true
  ├─ batch.status = FINALIZED, batch.finalizedAt = now()
  ├─ queryRunner.manager.save(batch)
  ├─ queryRunner.commitTransaction()
  └─ AuditService.logEvent('ANCHOR_BATCH_FINALIZED', ...)

FT3 — Révocation d'un signer

POST /anchor/signers/:address/revoke
  ├─ OidcJwtAuthGuard → vérifie JWT valide
  ├─ RolesGuard → vérifie role ∈ {ADMIN, SIGNER_ADMIN}
  │    └─ Si non → 403 ERR-REVOKE-UNAUTHORIZED
  ├─ Validation DTO : si body contient 'revokedBy' → 400 ERR-REVOKEDBY-SPOOFING
  ├─ actorIdentity = request.user.sub  (JWT claim)
  ├─ dataSource.transaction(async (manager) => {
  │     │
  │     ├─ SELECT * FROM signer_registry WHERE address = :addr FOR UPDATE
  │     │    └─ null → throw ERR-SIGNER-NOT-FOUND (404)
  │     │
  │     ├─ IF signer.status === 'REVOKED'
  │     │    └─ throw ERR-SIGNER-ALREADY-REVOKED (409)
  │     │
  │     ├─ signer.status = 'REVOKED'
  │     ├─ signer.revokedAt = now()
  │     ├─ signer.revokedBy = actorIdentity
  │     │
  │     ├─ manager.save(signer)
  │     │    └─ Si échec persistance revokedAt/revokedBy → ERR-AUDIT-TRAIL-MISSING (rollback)
  │     │
  │     └─ AuditService.logEvent('SIGNER_REVOKED', actorIdentity, address, { ... })
  │   })
  └─ 200 { address, status: 'REVOKED', revokedAt, revokedBy }

FT4 — Soumission avec garde signer actif

Correction v2 (BLK-01) : Le contrôle signer DOIT précéder createBatch() et buildBatch() pour garantir CA-05 ("aucun batch créé" si signer REVOKED/inconnu). Le contrôle est déplacé au début du processor, dans une transaction dédiée.

BlockchainAnchorProcessor.process(job)
  ├─ [AJOUTÉ] Résolution signerAddress (via WalletService.getAddress() — PD-177)
  ├─ [AJOUTÉ] SignerRegistryService.assertSignerActive(signerAddress, manager)
  │     │     avec SELECT ... FOR UPDATE dans transaction dédiée
  │     │     ├─ null → throw ERR-SIGNER-NOT-FOUND (fail-closed) — STOP, aucun batch créé
  │     │     └─ status === 'REVOKED' → throw ERR-SIGNER-REVOKED (fail-closed) — STOP, aucun batch créé
  ├─ AnchorBatchService.createBatch(...)  (existant, exécuté seulement si signer ACTIVE)
  ├─ AnchorBatchService.buildBatch(...)   (existant)
  ├─ AnchorBatchService.submitBatch(batch)  [MODIFIÉ]
  │     │
  │     ├─ queryRunner.startTransaction()
  │     │
  │     ├─ [AJOUTÉ] Re-vérification signer ACTIVE avec SELECT ... FOR UPDATE (double-check transactionnel)
  │     │    ├─ null → throw ERR-SIGNER-NOT-FOUND (fail-closed)
  │     │    └─ status === 'REVOKED' → throw ERR-SIGNER-REVOKED (fail-closed, révoqué entre createBatch et submit)
  │     │
  │     ├─ ... suite du flux submit existant dans la même transaction ...
  │     │
  │     └─ queryRunner.commitTransaction()
  ├─ BlockchainAdapterService.waitForConfirmation(...)  (existant)
  │     │
  │     └─ FinalityGuardService.updateConfirmationCount(batchId, N) [NOUVEAU]
  └─ AnchorBatchService.finalizeBatch(batch, txResult) [MODIFIÉ avec garde finalité]

2bis. Diagrammes Mermaid

Graphe de dépendances entre composants

graph TD
    C1["C1 — Migration DDL"]
    C2["C2 — Entity SignerRegistry"]
    C3["C3 — Extension Entity AnchorBatch"]
    C4["C4 — SignerRegistryService"]
    C5["C5 — FinalityGuardService"]
    C6["C6 — Modification AnchorBatchService"]
    C7["C7 — SignerController"]
    C9["C9 — Audit Events Extension"]
    C10["C10 — Error Codes Extension"]
    C11["C11 — Module Registration"]

    C2 -->|entity table| C1
    C3 -->|colonne confirmation_count| C1
    C4 -->|entity| C2
    C4 -->|codes erreur| C10
    C4 -->|audit events| C9
    C5 -->|entity| C3
    C5 -->|codes erreur| C10
    C5 -->|audit events| C9
    C6 -->|garde finalité| C5
    C6 -->|garde signer| C4
    C7 -->|service revocation| C4
    C7 -->|codes erreur| C10
    C11 -->|enregistre| C2
    C11 -->|enregistre| C4
    C11 -->|enregistre| C5
    C11 -->|enregistre| C7

    style C1 fill:#e8f4fd,stroke:#2196F3
    style C6 fill:#fff3e0,stroke:#FF9800
    style C7 fill:#fff3e0,stroke:#FF9800
    style C11 fill:#f3e5f5,stroke:#9C27B0

Diagramme de séquence — Soumission avec gardes signer + finalité (FT4)

sequenceDiagram
    participant BullMQ as BlockchainAnchorProcessor
    participant Wallet as WalletService (PD-177)
    participant Signer as SignerRegistryService
    participant Batch as AnchorBatchService
    participant Finality as FinalityGuardService
    participant Adapter as BlockchainAdapterService
    participant Audit as AuditService
    participant DB as PostgreSQL

    BullMQ->>Wallet: getAddress()
    Wallet-->>BullMQ: signerAddress

    Note over BullMQ,DB: Transaction dédiée — garde signer pré-création
    BullMQ->>Signer: assertSignerActive(signerAddress, manager)
    Signer->>DB: SELECT ... FOR UPDATE signer_registry
    alt Signer ACTIVE
        DB-->>Signer: signer (status=ACTIVE)
        Signer-->>BullMQ: OK
    else Signer REVOKED ou absent
        DB-->>Signer: signer (status=REVOKED) / null
        Signer-->>BullMQ: throw ERR-SIGNER-REVOKED / ERR-SIGNER-NOT-FOUND
        Note over BullMQ: STOP — aucun batch créé
    end

    BullMQ->>Batch: createBatch(...)
    BullMQ->>Batch: buildBatch(...)

    Note over BullMQ,DB: Transaction submit — double-check signer
    BullMQ->>Batch: submitBatch(batch)
    Batch->>Signer: assertSignerActive(signerAddress, manager)
    Signer->>DB: SELECT ... FOR UPDATE (re-vérification)
    alt Signer toujours ACTIVE
        Signer-->>Batch: OK
        Batch->>DB: UPDATE batch status → SUBMITTED
        Batch->>DB: COMMIT
    else Signer révoqué entre-temps
        Signer-->>Batch: throw ERR-SIGNER-REVOKED
        Batch->>DB: ROLLBACK
    end

    BullMQ->>Adapter: waitForConfirmation(txHash)
    Adapter-->>BullMQ: confirmations = N

    BullMQ->>Finality: updateConfirmationCount(batchId, N)
    Finality->>DB: UPDATE anchor_batches SET confirmation_count = N
    Finality->>Audit: logEvent(CONFIRMATION_COUNT_UPDATED)

    BullMQ->>Batch: finalizeBatch(batch, txResult)
    Batch->>DB: SELECT ... FOR UPDATE anchor_batches
    Batch->>Finality: assertFinalityReached(batch)
    alt confirmation_count >= FINALITY_DEPTH
        Finality-->>Batch: OK
        Batch->>DB: UPDATE status → FINALIZED
        Batch->>DB: COMMIT
        Batch->>Audit: logEvent(ANCHOR_BATCH_FINALIZED)
    else confirmation_count < FINALITY_DEPTH
        Finality->>Audit: logEvent(FINALITY_CHECK_FAILED)
        Finality-->>Batch: throw ERR-FINALITY-INSUFFICIENT
        Batch->>DB: ROLLBACK
    end

Diagramme de séquence — Révocation d'un signer (FT3)

sequenceDiagram
    participant Client as Client HTTP
    participant Guard as OidcJwtAuthGuard + RolesGuard
    participant Ctrl as SignerController (C7)
    participant Svc as SignerRegistryService (C4)
    participant Audit as AuditService
    participant DB as PostgreSQL

    Client->>Guard: POST /anchor/signers/:address/revoke
    Guard->>Guard: Vérifier JWT + rôle ∈ {ADMIN, SIGNER_ADMIN}
    alt Rôle non autorisé
        Guard-->>Client: 403 ERR-REVOKE-UNAUTHORIZED
    end

    Guard-->>Ctrl: request (user.sub = actorIdentity)
    Ctrl->>Ctrl: Vérifier body ne contient pas 'revokedBy'
    alt Body contient revokedBy
        Ctrl-->>Client: 400 ERR-REVOKEDBY-SPOOFING
    end

    Note over Ctrl,DB: Transaction atomique
    Ctrl->>Svc: revokeSigner(address, actorIdentity, actorRoles, manager)
    Svc->>DB: SELECT ... FOR UPDATE signer_registry WHERE address = :addr
    alt Signer absent
        DB-->>Svc: null
        Svc->>Audit: logEvent(SIGNER_REVOKE_DENIED)
        Svc-->>Ctrl: throw ERR-SIGNER-NOT-FOUND
        Ctrl-->>Client: 404
    else Signer déjà REVOKED
        DB-->>Svc: signer (status=REVOKED)
        Svc->>Audit: logEvent(SIGNER_REVOKE_DENIED)
        Svc-->>Ctrl: throw ERR-SIGNER-ALREADY-REVOKED
        Ctrl-->>Client: 409
    else Signer ACTIVE
        Svc->>DB: UPDATE status=REVOKED, revokedAt=now(), revokedBy=actorIdentity
        Svc->>Audit: logEvent(SIGNER_REVOKED)
        Svc-->>Ctrl: signer (revoked)
        Ctrl-->>Client: 200 { address, status, revokedAt, revokedBy }
    end

3. Mapping invariants → mécanismes

Invariant ID Exigence Mécanisme Composant Observable Risque
INV-275-01-fail-closed Toute décision de sécurité est DENY par défaut si préconditions non démontrées Les trois gardes (assertFinalityReached, assertSignerActive, contrôle rôle) lancent une exception avant toute mutation d'état. Pas de code-path alternatif qui autorise sans vérification. C4, C5, C6, C7 Exceptions métier explicites (ERR-*) + état DB inchangé Faible — pattern déjà utilisé dans PD-177 (fail-closed)
INV-275-02-finality-guard Un batch ne peut pas passer à FINALIZED si confirmation_count < FINALITY_DEPTH FinalityGuardService.assertFinalityReached(batch) appelé dans finalizeBatch() avant toute transition d'état. Comparaison stricte batch.confirmationCount < finalityDepth. C5, C6 ERR-FINALITY-INSUFFICIENT retourné + batch reste en PENDING_FINALITY Moyen — dépend de la configuration correcte de FINALITY_DEPTH (Q-01)
INV-275-03-confirmation-persistence confirmation_count est persistant, entier, initialisé à 0, mis à jour par le mécanisme de suivi Colonne INTEGER NOT NULL DEFAULT 0 avec CHECK >= 0 AND <= 2147483647. Mise à jour par FinalityGuardService.updateConfirmationCount() avec validation des bornes. C1, C3, C5 Valeur lisible en DB + rejet des valeurs hors bornes (ERR-CONFIRMATION-COUNT-INVALID) Faible — contrainte DB + validation applicative
INV-275-04-signer-status-authority signer_registry est la source de vérité du statut signer Table dédiée signer_registry avec colonne status contrainte (ACTIVE, REVOKED). Toute décision de soumission/révocation passe par SELECT ... FOR UPDATE sur cette table. C1, C2, C4 Statut lu depuis la table avec verrou pessimiste Faible — table unique, pas de cache
INV-275-05-revocation-atomic-audited Révocation atomique avec revokedAt et revokedBy obligatoires Transaction DB unique contenant : changement statut + écriture revokedAt/revokedBy + audit event. Rollback si une des écritures échoue (ERR-AUDIT-TRAIL-MISSING). C4, C7 revokedAt et revokedBy non nuls après revocation + audit event présent Faible — même pattern transactionnel que PD-177
INV-275-06-terminal-state-revoked REVOKED est un état terminal (pas de transition sortante) Enum SignerStatus sans transition REVOKED -> *. revokeSigner() vérifie status === ACTIVE avant mutation. Pas de méthode de réactivation dans le service. C2, C4 Tentative de transition depuis REVOKED → exception métier Faible — pas de code-path de réactivation
INV-275-07-state-machine-complete Toute transition est explicitement autorisée ou interdite SignerStatus : ACTIVE -> REVOKED autorisée, REVOKED -> * interdite. AnchorBatchStatus : transitions existantes inchangées (PD-55). Constante VALID_SIGNER_STATUS_TRANSITIONS explicite. C2 Constante de transitions vérifiable en code et en test Moyen — machine à états batch partiellement documentée (Q-02)
INV-275-08-envelope-encryption Tout artefact crypto temporaire chiffré au repos Aucun artefact crypto temporaire produit par PD-275. Les clés de signature sont gérées par le CustodyService (PD-52/PD-177) déjà conforme. N/A Invariant vacuement satisfait — aucun secret manipulé dans PD-275 Faible — pas de nouveau flux crypto
INV-275-09-revoke-authorization revokeSigner() autorisé uniquement pour ADMIN ou SIGNER_ADMIN @Roles('ADMIN', 'SIGNER_ADMIN') sur l'endpoint + RolesGuard (existant PD-26). Double garde : NestJS guard + vérification applicative. C7 ERR-REVOKE-UNAUTHORIZED (403) pour rôle non autorisé Faible — pattern @Roles déjà éprouvé
INV-275-10-revokedBy-auth-derived revokedBy dérivé du contexte auth, jamais du payload actorIdentity extrait de request.user.sub dans le controller. Validation DTO : rejet si champ revokedBy présent dans le body (ERR-REVOKEDBY-SPOOFING). Le service ne reçoit que actorIdentity en paramètre. C7 revokedBy stocké = JWT sub + body revokedBy → rejet/ignoré Faible — le DTO ne mappe pas revokedBy
INV-275-11-signer-concurrency-serialization Lecture/décision/écriture sur signer_registry sérialisée via SELECT ... FOR UPDATE findByAddressForUpdate() utilise lock: { mode: 'pessimistic_write' } TypeORM. Les deux chemins (revokeSigner, assertSignerActive pour submit) appellent cette méthode. C4, C6 Transactions sérialisées — le second appelant attend le commit du premier Moyen — dépend de la config timeout PostgreSQL (H-07)
INV-275-12-single-revocation-audit-event Une seule transition ACTIVE -> REVOKED et un seul audit event par signer Sous verrou FOR UPDATE, la vérification status === ACTIVE est atomique. Le second concurrent trouvera REVOKED après commit du premier et retournera ERR-SIGNER-ALREADY-REVOKED. C4 Un seul audit trail de revocation par adresse + erreur pour doublons Faible — garanti par le verrou pessimiste

4. Mapping critères d'acceptation → mécanismes

Critère ID Mécanisme(s) Composant Observable Risque
CA-01 Migration DDL : ALTER TABLE anchor_batches ADD COLUMN confirmation_count INTEGER NOT NULL DEFAULT 0 CHECK (confirmation_count >= 0 AND confirmation_count <= 2147483647) C1, C3 Schéma inspecté via \d anchor_batches — colonne présente avec défaut et contrainte Faible
CA-02 FinalityGuardService.assertFinalityReached(batch) dans finalizeBatch() — comparaison confirmationCount < FINALITY_DEPTH C5, C6 Appel finalizeBatch() retourne ERR-FINALITY-INSUFFICIENT, batch reste PENDING_FINALITY Faible
CA-02b Même garde — chemin nominal quand confirmationCount >= FINALITY_DEPTH C5, C6 finalizeBatch() retourne succès, batch passe à FINALIZED Faible
CA-03 Migration DDL : CREATE TABLE signer_registry (...) + Entity TypeORM SignerRegistry C1, C2 Schéma inspecté via \d signer_registry — colonnes address, status, revoked_at, revoked_by, timestamps Faible
CA-04 SignerRegistryService.revokeSigner(address, actorIdentity, manager) — transition atomique + audit trail C4, C7 Après appel : status = REVOKED, revokedAt non nul, revokedBy = actorIdentity Faible
CA-04b @Roles('ADMIN', 'SIGNER_ADMIN') + RolesGuard sur SignerController.revoke() C7 Appel non autorisé → ERR-REVOKE-UNAUTHORIZED (403), aucun changement d'état Faible
CA-04c DTO sans champ revokedBy + validation anti-spoofing + extraction depuis request.user.sub C7 revokedBy stocké = JWT sub ; body avec revokedByERR-REVOKEDBY-SPOOFING ou ignoré Faible
CA-05 SignerRegistryService.assertSignerActive() appelé dans submitBatch() avec SELECT ... FOR UPDATE C4, C6 Signer REVOKEDERR-SIGNER-REVOKED ; signer absent → ERR-SIGNER-NOT-FOUND Moyen — dépend PD-177 pour résolution signer (H-01)
CA-05b SELECT ... FOR UPDATE sur la ligne signer cible dans les deux chemins (revoke, submit) — sérialisation par le verrou C4, C6 Traces transactionnelles et état final déterministes Moyen — tests de concurrence requis
CA-06 Audit Prolog post-déploiement — résultat 32/32 OK Externe (Prolog) Rapport d'audit archivé Moyen — dépend de l'exactitude des faits Prolog
CA-07 Tests unitaires/intégration couvrant les 5 checks (28-32) + sécurité C1-C11 (tests) Rapport de tests prouvant les scénarios Faible
CA-08 Migration up/down réversible sans perte de contraintes C1 Exécution up, down, up → objets et contraintes restaurés Faible

5. Mapping tests (TC-*) → mécanismes + observables

Test ID Référence spec Mécanisme(s) Point(s) d'observation Niveau de test visé
TC-NOM-01 INV-275-03, CA-01 FinalityGuardService.updateConfirmationCount() + validation bornes confirmation_count en DB = valeur persistée Integration
TC-NOM-02 INV-275-01, INV-275-02, CA-02 FinalityGuardService.assertFinalityReached() avec count < FINALITY_DEPTH Exception ERR-FINALITY-INSUFFICIENT + batch état inchangé Unit + Integration
TC-NOM-03 INV-275-02, CA-02b FinalityGuardService.assertFinalityReached() avec count >= FINALITY_DEPTH Pas d'exception + batch passe à FINALIZED Unit + Integration
TC-NOM-04 INV-275-04, INV-275-05, CA-03, CA-04 SignerRegistryService.revokeSigner() dans transaction avec FOR UPDATE status = REVOKED, revokedAt non nul, revokedBy = actorIdentity Integration
TC-NOM-05 INV-275-05, CA-04 SignerRegistryService.revokeSigner() sur signer déjà REVOKED Exception ERR-SIGNER-ALREADY-REVOKED, champs inchangés Unit + Integration
TC-NOM-06 INV-275-01, INV-275-04, CA-05 SignerRegistryService.assertSignerActive() sur signer REVOKED Exception ERR-SIGNER-REVOKED, aucun batch créé Unit + Integration
TC-NOM-07 INV-275-01, INV-275-04, CA-05 SignerRegistryService.assertSignerActive() sur adresse absente Exception ERR-SIGNER-NOT-FOUND, aucun batch créé Unit + Integration
TC-NOM-08 CA-06 Exécution audit Prolog post-déploiement Rapport 32/32 OK, checks 28-32 OK E2E (compliance)
TC-NOM-09 CA-08 Migration up/down/up Objets et contraintes restaurés après cycle Integration (migration)
TC-NOM-10 INV-275-11, CA-05b Deux transactions concurrentes revoke + submit avec FOR UPDATE Sérialisation : si revoke commit first → submit reçoit ERR-SIGNER-REVOKED Integration (concurrence)
TC-NOM-11 INV-275-12, CA-05b Deux transactions concurrentes revoke + revoke avec FOR UPDATE Un seul succès + un ERR-SIGNER-ALREADY-REVOKED + un seul audit event Integration (concurrence)
TC-ERR-01 INV-275-03 FinalityGuardService.updateConfirmationCount() avec valeur hors bornes Exception ERR-CONFIRMATION-COUNT-INVALID, valeur DB inchangée Unit
TC-ERR-02 INV-275-02 Duplication de TC-NOM-02 (classé en erreur) Idem TC-NOM-02 Unit
TC-ERR-03 INV-275-01 SignerRegistryService.revokeSigner() sur adresse absente Exception ERR-SIGNER-NOT-FOUND Unit
TC-ERR-04 INV-275-01 Duplication de TC-NOM-07 Idem TC-NOM-07 Unit
TC-ERR-05 INV-275-04 Duplication de TC-NOM-06 Idem TC-NOM-06 Unit
TC-ERR-06 INV-275-05 revokeSigner() avec échec forcé de persistance revokedAt/revokedBy Exception ERR-AUDIT-TRAIL-MISSING, statut reste ACTIVE Integration
TC-ERR-07 CA-06 Gate de conformité avec score < 32/32 ERR-COMPLIANCE-NOT-MET E2E (compliance)
TC-ERR-08 INV-275-09, CA-04b POST /anchor/signers/:addr/revoke avec JWT sans rôle ADMIN/SIGNER_ADMIN 403 ERR-REVOKE-UNAUTHORIZED, signer inchangé Integration
TC-ERR-09 INV-275-10, CA-04c POST /anchor/signers/:addr/revoke avec body { revokedBy: 'attacker' } 400 ERR-REVOKEDBY-SPOOFING ou revokedBy = JWT sub (jamais body) Integration
TC-INV-06 INV-275-06 Tentative de transition REVOKED -> ACTIVE Transition refusée, état reste REVOKED Unit
TC-INV-07A INV-275-07 (signer) Vérification de la constante VALID_SIGNER_STATUS_TRANSITIONS ACTIVE -> REVOKED autorisée, REVOKED -> * interdite Unit
TC-INV-07B INV-275-07 (batch) Vérification de la constante VALID_ANCHOR_STATUS_TRANSITIONS (existante) PENDING_FINALITY -> FINALIZED autorisée, FINALIZED -> * interdite Unit
TC-INV-08 INV-275-08 Inspection des artefacts temporaires des flux PD-275 Aucun secret crypto en clair Sec (audit)
TC-NR-01 CA-07 Rapport de tests couvrant checks 28-32 Mapping TC → check Prolog documenté Integration
TC-NR-02 Suite Prolog existante post-changement Checks 1-27 restent OK E2E (regression)
TC-NR-03 submitBatch() avec signer ACTIVE valide Soumission réussit Integration
TC-NR-04 finalizeBatch() avec confirmation_count >= FINALITY_DEPTH Finalisation réussit Integration
TC-NR-05 Migration up/down/up Colonnes/contraintes présentes Integration
TC-NEG-01 updateConfirmationCount(-1) ERR-CONFIRMATION-COUNT-INVALID Unit
TC-NEG-02 updateConfirmationCount(2147483648) ERR-CONFIRMATION-COUNT-INVALID Unit
TC-NEG-03 submitBatch() adresse inconnue ERR-SIGNER-NOT-FOUND Unit
TC-NEG-04 submitBatch() signer REVOKED ERR-SIGNER-REVOKED Unit
TC-NEG-05 Double revocation ERR-SIGNER-ALREADY-REVOKED Unit
TC-NEG-06 REVOKED -> ACTIVE Transition refusée Unit
TC-NEG-07 finalizeBatch() avec confirmation_count = 0 et FINALITY_DEPTH >= 1 ERR-FINALITY-INSUFFICIENT Unit
TC-NEG-08 Échec persistance audit pendant revocation ERR-AUDIT-TRAIL-MISSING Integration

6. Gestion des erreurs

Code erreur HTTP Condition Effet Observable
ERR-FINALITY-INSUFFICIENT N/A (interne) confirmation_count < FINALITY_DEPTH lors de finalizeBatch() Transition refusée, batch reste PENDING_FINALITY, audit FINALITY_CHECK_FAILED État batch inchangé + log audit
ERR-CONFIRMATION-COUNT-INVALID N/A (interne) Valeur < 0 ou > 2147483647 dans updateConfirmationCount() Mise à jour rejetée, valeur précédente conservée Exception levée, pas de mutation DB
ERR-SIGNER-NOT-FOUND 404 Adresse absente de signer_registry lors de revoke ou submit Opération refusée immédiatement (fail-closed) Aucun batch créé, aucune revocation
ERR-SIGNER-REVOKED 409 Signer REVOKED lors de submitBatch() Soumission refusée immédiatement Aucun batch créé, aucun effet de bord
ERR-SIGNER-ALREADY-REVOKED 409 Signer déjà REVOKED lors de revokeSigner() Refus métier, pas de nouvel audit trail revokedAt/revokedBy inchangés
ERR-AUDIT-TRAIL-MISSING 500 Échec de persistance revokedAt/revokedBy pendant transaction Rollback complet, statut reste ACTIVE Aucun état partiellement révoqué
ERR-REVOKE-UNAUTHORIZED 403 Appelant sans rôle ADMIN ni SIGNER_ADMIN Requête rejetée, audit SIGNER_REVOKE_DENIED Aucun changement d'état signer
ERR-REVOKEDBY-SPOOFING 400 Body de requête contient un champ revokedBy Requête invalide rejetée Aucune mutation d'état
ERR-COMPLIANCE-NOT-MET N/A (gate) Score audit Prolog < 32/32 Livraison non conforme Rapport archivé, bloquant

Stratégie de propagation : Toutes les erreurs métier sont des exceptions NestJS typées (BadRequestException, ForbiddenException, NotFoundException, ConflictException) avec un payload structuré { code: 'ERR-*', message: string }. Les erreurs internes (worker BullMQ) sont loguées via Logger et le batch passe à FAILED via failBatch().


7. Impacts sécurité

7.1 Contrôle d'accès

  • Endpoint POST /anchor/signers/:address/revoke : protégé par OidcJwtAuthGuard + @Roles('ADMIN', 'SIGNER_ADMIN').
  • Le rôle SIGNER_ADMIN est un nouveau rôle à créer dans Keycloak. Si ce rôle n'existe pas encore, il doit être provisionné avant déploiement.
  • Les endpoints existants (GET /anchor/batches/:id/proof) ne sont pas modifiés.

7.2 Anti-usurpation audit trail

  • revokedBy est toujours extrait de request.user.sub (JWT claim).
  • Le DTO de revocation ne contient pas de champ revokedBy. Si un champ revokedBy est détecté dans le body (via validation explicite), la requête est rejetée avec ERR-REVOKEDBY-SPOOFING.
  • Aucun header métier ne peut injecter revokedBy.

7.3 Concurrence et intégrité

  • SELECT ... FOR UPDATE sur signer_registry sérialise les accès concurrents.
  • La politique de timeout transactionnel PostgreSQL standard s'applique (fail-closed en cas de deadlock).
  • Aucun double audit trail possible grâce au verrou pessimiste.

7.4 Journalisation

  • Tous les événements de sécurité (revocation, refus, spoofing) sont audités via AuditService.
  • Les traces incluent actorIdentity, address, timestamp, result (success/denied).

8. Hypothèses techniques

ID Hypothèse Impact si faux
HT-01 AnchorBatch.signerAddress (PD-177) est peuplé et correspond à une entrée signer_registry.address pour les batches soumis post-PD-275. submitBatch() ne peut pas résoudre le signer → garde fail-closed bloque toute soumission. Aligner avec PD-177 pour le contrat d'interface.
HT-02 Le rôle SIGNER_ADMIN sera provisionné dans Keycloak avant le déploiement PD-275. L'endpoint de revocation sera inaccessible (seul ADMIN pourra révoquer, pas de délégation).
HT-03 FINALITY_DEPTH est configurable par environnement via ConfigService (anchor.finalityDepth). La valeur par défaut sera alignée sur la politique réseau existante (Polygon: 12, Arbitrum: 30) ou une valeur unifiée. Si non configuré, la garde utilise une valeur par défaut potentiellement inadaptée au réseau cible.
HT-04 Le registre signer_registry sera seedé avec les adresses des signers actifs connus avant le déploiement (via migration SQL INSERT ou script de seed). Toute soumission sera bloquée fail-closed après déploiement si le seed n'est pas effectué.
HT-05 La politique de timeout/deadlock PostgreSQL standard (config lock_timeout, statement_timeout) est suffisante pour les transactions PD-275. Risque de contention non maîtrisée. Recommandation : lock_timeout = 5000 (5s) pour les transactions FOR UPDATE.
HT-06 L'identité JWT sub est un identifiant stable et unique (UUID utilisateur ou service account) exploitable comme revokedBy. Format de revokedBy hétérogène, difficulté d'audit/forensics.
HT-07 Les adresses signer sont normalisées en EIP-55 (checksum case) à l'insertion et à la requête. Risque de collision/ambiguïté (même adresse en casse différente = entrées distinctes). Recommandation : normalisation EIP-55 systématique dans SignerRegistryService.

9. Points de vigilance (risques, dette, pièges)

9.1 Seed initial du registre signer (HD-03 / Q-05)

Risque critique : Si le registre signer_registry est vide au déploiement, le guard fail-closed bloquera toute soumission de batch. L'ensemble du flux d'ancrage sera indisponible.

Mitigation : Inclure un script de seed (seed-signers.ts) ou une insertion SQL dans la migration up pour les adresses connues (wallet actif de production). Documenter la procédure de seed dans la documentation opérationnelle.

9.2 Valeur de FINALITY_DEPTH (Q-01)

Risque : Sans valeur validée par environnement, les tests TC-NOM-02/TC-NOM-03 sont exécutables en paramétrique mais la conformité numérique n'est pas garantie en production.

Mitigation : Utiliser une valeur configurable avec un défaut conservateur (ex: 12 pour Polygon, aligné sur NETWORK_CONFIRMATION_POLICY.polygon.confirmations). Documenter Q-01 comme décision à finaliser.

9.3 Interaction finalizeBatch() existant vs nouvelle garde

Piège : Le finalizeBatch() actuel (PD-55) fait déjà un lock: { mode: 'pessimistic_write' } et valide la transition d'état. La garde de finalité doit s'insérer après l'acquisition du verrou et avant la mutation d'état, dans la même transaction.

Vérification : Pas de code-path qui contourne la garde (pas de finalizeBatch alternatif sans vérification).

9.4 Normalisation des adresses signer (Q-03)

Risque : Si les adresses ne sont pas normalisées (EIP-55), une même adresse en minuscules et en checksum pourrait créer deux entrées distinctes dans signer_registry.

Mitigation : Normalisation EIP-55 systématique à l'insertion (getAddress() d'ethers.js) et à la requête dans SignerRegistryService. La clé primaire est l'adresse normalisée.

9.5 Tests de concurrence (TC-NOM-10, TC-NOM-11)

Piège : Les tests de concurrence nécessitent un vrai PostgreSQL (pas de mock). Utiliser DataSource.transaction() avec deux connexions parallèles pour prouver la sérialisation.

Pattern recommandé : Test d'intégration avec deux Promise.all([revokePromise, submitPromise]) sur un environnement de test PostgreSQL.

9.6 Aucune modification d'autres modules

Aucune modification d'autres modules (documents, crypto, etc.) n'est nécessaire pour PD-275. Les changements sont confinés au module anchor et à ses dépendances directes (audit, blockchain/constants).


10. Hors périmètre

  • Rotation automatique des signers — hors scope PD-275
  • Réactivation d'un signer REVOKED — hors scope (état terminal, INV-275-06)
  • Multi-signature — hors scope PD-275
  • UI/API d'administration complète des signers — seul l'endpoint de revocation est dans le scope
  • Modification des modèles TLA+ et Alloy — validés en amont
  • Toute dérogation fail-open — interdit par INV-275-01
  • Mécanisme de mise à jour de confirmation_count (interface exacte du polling) — le ConfirmationTracker existant (PD-52/PD-177) fournit déjà les confirmations ; PD-275 ajoute uniquement la persistance et la garde
  • Machine à états batch complète — PD-275 contractualise uniquement PENDING_FINALITY -> FINALIZED et FINALIZED -> * : INTERDITE ; le modèle complet est traité par Q-02
  • Gestion des deadlocks PostgreSQL avancée — la politique standard du serveur s'applique (H-07)