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)¶
SignerActiveGuardCorrection v2 (MAJ-01/MAJ-03) : Le guard NestJS HTTP
SignerActiveGuardest supprimé du plan. Le contrôle signer est effectué exclusivement dans le service layer (SignerRegistryService.assertSignerActive()) avecSELECT ... FOR UPDATEtransactionnel, 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()etbuildBatch()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 revokedBy → ERR-REVOKEDBY-SPOOFING ou ignoré | Faible |
| CA-05 | SignerRegistryService.assertSignerActive() appelé dans submitBatch() avec SELECT ... FOR UPDATE | C4, C6 | Signer REVOKED → ERR-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é parOidcJwtAuthGuard+@Roles('ADMIN', 'SIGNER_ADMIN'). - Le rôle
SIGNER_ADMINest 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¶
revokedByest toujours extrait derequest.user.sub(JWT claim).- Le DTO de revocation ne contient pas de champ
revokedBy. Si un champrevokedByest détecté dans le body (via validation explicite), la requête est rejetée avecERR-REVOKEDBY-SPOOFING. - Aucun header métier ne peut injecter
revokedBy.
7.3 Concurrence et intégrité¶
SELECT ... FOR UPDATEsursigner_registrysé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 -> FINALIZEDetFINALIZED -> * : 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)