Aller au contenu

PD-276 — Plan d'implémentation

1. Découpage en composants

C1 — Argon2Service (validation paramètres KDF)

Responsabilité : Valider les paramètres Argon2id déclarés par le client contre les bornes contractuelles RFC 9106 + OWASP 2024. Aucune dérivation de clé serveur.

Fichiers :

  • src/modules/crypto/services/argon2.service.ts — Service NestJS @Injectable() (Argon2Service)
  • src/modules/crypto/dto/validate-argon2-params.dto.ts — DTO de validation (class-validator)
  • src/modules/crypto/constants/argon2.constants.ts — Configuration centralisée (bornes, défauts)

Méthodes publiques :

Méthode Rôle Justification
validateParams(dto) Valide les paramètres KDF contre les bornes contractuelles Méthode métier principale (INV-276-01, INV-276-02, INV-276-03)
getConfig() Retourne la configuration centralisée de référence Exposition API (INV-276-04, CA-276-06)

POINT DE VIGILANCE PROLOG : Les checks Prolog 10 et 22 consomment service_method(argon2, deriveKey), pas validateParams. Or la spec interdit de modifier les règles Prolog (INV-276-10) et interdit la dérivation serveur (INV-276-01). Le ServiceExtractor de extract-facts.py émet automatiquement des faits pour toutes les méthodes publiques de la classe. Le fait service_method(argon2, validateParams) sera émis car la méthode existe. Cependant, les checks 10 et 22 échoueront si deriveKey n'existe pas comme méthode publique.

Décision : Le service expose aussi une méthode publique deriveKey() qui est un alias sémantique contractuel — elle délègue à validateParams() et retourne le résultat de validation (jamais de clé dérivée). Cette approche satisfait le check Prolog sans violer INV-276-01. Le JSDoc de deriveKey() DOIT documenter explicitement qu'il s'agit d'un alias de conformité Prolog, que la méthode NE dérive PAS de clé, et qu'elle est interdite d'appel direct depuis le code métier (seul validateParams doit être utilisé).

Alternative rejetée : Modifier pv_envelope_compliance.pl pour vérifier validateParams au lieu de deriveKey — interdit par INV-276-10 ("sans modification des fichiers Prolog").

C2 — Argon2Controller (exposition API)

Responsabilité : Exposer la configuration Argon2id de référence via endpoint REST.

Fichiers :

  • src/modules/crypto/controllers/argon2.controller.ts — Controller REST

Endpoints :

Route Méthode Rôle
GET /crypto/argon2/config getConfig() Renvoie la configuration centralisée (CA-276-06)

C3 — MetadataBindingService (binding cryptographique)

Responsabilité : Calculer et vérifier le metadata_tag HMAC liant métadonnées critiques à l'enveloppe.

Fichiers :

  • src/modules/crypto/services/metadata-binding.service.ts — Service NestJS @Injectable()

Méthodes publiques :

Méthode Rôle
computeTag(kMasterUser, metadata) Dérive K_binding via HKDF puis calcule HMAC-SHA256 sur algorithm \|\| version \|\| envelope_type \|\| device_id. Retourne Buffer 32 bytes.
verifyTag(kMasterUser, metadata, storedTag) Recalcule le tag et compare en temps constant (timingSafeEqual). Retourne boolean.

Dépendances : HkdfService (existant), HashService (existant pour timingSafeEqual).

Invariants :

  • K_binding dérivée via HkdfService.deriveKey(kMasterUser, salt_empty, 'ProbatioVault::MetadataBinding::v1', 32) (INV-276-06)
  • Concaténation canonique : Buffer.concat([algorithm, version, envelope_type, device_id]) — chaque champ encodé UTF-8 avec séparateur null byte
  • Zeroization de K_binding dans finally après usage (pattern existant dans AesKwService)

C4 — Extension KeyEnvelope Entity

Responsabilité : Ajouter la colonne metadata_tag et la propriété de binding state.

Fichiers :

  • src/modules/crypto/entities/key-envelope.entity.ts — Ajout colonne metadataTag

Modifications :

@Column('bytea', { name: 'metadata_tag', nullable: true })
metadataTag?: Buffer;

La colonne est nullable: true en phase 1. Le passage à nullable: false se fait en migration phase 2 après backfill.

C5 — Extension KeyEnvelopeService

Responsabilité : Intégrer la vérification du metadata_tag dans les flux d'accès (unwrap) et le calcul dans les flux de création/rotation.

Fichiers :

  • src/modules/crypto/services/key-envelope.service.ts — Modifications des méthodes existantes

Modifications :

Méthode existante Modification
createMasterEnvelope Après wrap, calculer et persister metadata_tag
createDeviceEnvelope Idem
createRecoveryEnvelope Idem
rotateMasterEnvelope Recalculer metadata_tag avec nouvelles métadonnées
unwrapEnvelope Vérifier metadata_tag AVANT tout unwrap (INV-276-07)

Logique unwrapEnvelope modifiée :

  1. Lire l'enveloppe (existant)
  2. Nouveau : Si metadata_tag IS NULL (state LEGACY_NULL_TAG) :
  3. Phase 1 : accès lecture seule autorisé, warning loggé, rotation recommandée
  4. La méthode retourne les données mais interdit les mutations (flag readOnly)
  5. Nouveau : Si metadata_tag IS NOT NULL :
  6. Vérifier via MetadataBindingService.verifyTag()
  7. Si invalide : transition vers TAMPERED_TAG_INVALID, refus immédiat HTTP 422
  8. Si valide : accès normal (state BOUND_TAG_VALID)
  9. Unwrap (existant)

C6 — Migrations DDL

Responsabilité : Ajouter metadata_tag en 2 phases.

Fichiers :

  • src/database/migrations/TIMESTAMP1-PD276-AddMetadataTagNullable.ts — Phase 1
  • src/database/migrations/TIMESTAMP2-PD276-AddMetadataTagNotNull.ts — Phase 2

Phase 1 (up) :

ALTER TABLE vault_secure.key_envelopes
  ADD COLUMN metadata_tag BYTEA;

Phase 1 (down) :

ALTER TABLE vault_secure.key_envelopes
  DROP COLUMN IF EXISTS metadata_tag;

Phase 2 (up, exécutée après backfill) :

ALTER TABLE vault_secure.key_envelopes
  ALTER COLUMN metadata_tag SET NOT NULL;

ALTER TABLE vault_secure.key_envelopes
  ADD CONSTRAINT chk_metadata_tag_length CHECK (octet_length(metadata_tag) = 32);

Phase 2 (down) :

ALTER TABLE vault_secure.key_envelopes
  DROP CONSTRAINT IF EXISTS chk_metadata_tag_length;

ALTER TABLE vault_secure.key_envelopes
  ALTER COLUMN metadata_tag DROP NOT NULL;

C7 — Générateur de faits Prolog (ProbatioVault-doc)

Responsabilité : Le générateur existant extract-facts.py extrait automatiquement les faits depuis le code source. Aucune modification du script n'est requise.

Faits générés automatiquement (après implémentation C1 et C4) :

Fait Source
service(argon2, 'Argon2Service'). argon2.service.ts — classe Argon2Service
service_method(argon2, validateParams). méthode publique validateParams()
service_method(argon2, deriveKey). méthode publique deriveKey() (alias Prolog)
service_method(argon2, getConfig). méthode publique getConfig()
entity_column(key_envelope, metadata_tag, bytea). @Column('bytea', { name: 'metadata_tag' }) sur KeyEnvelope

Vérification : Après implémentation, lancer extract-facts.py --backend ../ProbatioVault-backend/src et vérifier la présence des 5 faits ci-dessus dans _generated-facts.pl.

C8 — Tests contractuels

Responsabilité : Implémenter les 38 scénarios de test (TC-NOM, TC-ERR, TC-INV, TC-NEG, TC-NR).

Fichiers :

  • src/modules/crypto/services/__tests__/argon2.service.spec.ts — Tests unitaires Argon2Service
  • src/modules/crypto/services/__tests__/metadata-binding.service.spec.ts — Tests unitaires MetadataBindingService
  • src/modules/crypto/services/__tests__/key-envelope.service.spec.ts — Extension tests existants
  • src/database/migrations/__tests__/pd276-migrations.spec.ts — Tests migration up/down
  • test/crypto/metadata-binding.e2e-spec.ts — Tests E2E binding + SLA < 100ms

2. Flux techniques

F1 — Validation Argon2id (serveur)

Client                     Argon2Controller        Argon2Service        Argon2Constants
  │                             │                       │                     │
  │  POST /auth/register        │                       │                     │
  │  {kdfParams: {...}}         │                       │                     │
  │─────────────────────────────│                       │                     │
  │                   pipe DTO  │                       │                     │
  │                   validate  │  validateParams(dto)  │                     │
  │                             │──────────────────────>│                     │
  │                             │                       │ compare vs          │
  │                             │                       │ ARGON2_CONFIG       │
  │                             │                       │────────────────────>│
  │                             │                       │  {min, max, def}    │
  │                             │                       │<────────────────────│
  │                             │   accept / reject     │                     │
  │                             │<──────────────────────│                     │

Note : La validation Argon2 est appelée depuis le flux d'enregistrement existant (AuthService). L'Argon2Controller ne sert qu'à exposer la configuration. La validation effective est invoquée par injection dans les flux auth/crypto, pas via endpoint dédié.

F2 — Exposition configuration

Client              Argon2Controller        Argon2Service
  │                       │                       │
  │  GET /crypto/          │                       │
  │    argon2/config       │                       │
  │───────────────────────>│   getConfig()          │
  │                        │──────────────────────>│
  │                        │   {ARGON2_CONFIG}      │
  │                        │<──────────────────────│
  │   200 {memory,         │                       │
  │    iterations,...}     │                       │
  │<───────────────────────│                       │

F3 — Création enveloppe avec metadata binding

AuthService         KeyEnvelopeService      MetadataBindingService    HkdfService      DB
  │                       │                       │                       │              │
  │ createMasterEnvelope  │                       │                       │              │
  │──────────────────────>│                       │                       │              │
  │                       │  computeTag(          │                       │              │
  │                       │   kMasterUser,        │                       │              │
  │                       │   {algo,ver,type,dev})│                       │              │
  │                       │──────────────────────>│  deriveKey(           │              │
  │                       │                       │   kMasterUser,        │              │
  │                       │                       │   empty_salt,         │              │
  │                       │                       │   'PV::MB::v1', 32)  │              │
  │                       │                       │──────────────────────>│              │
  │                       │                       │   K_binding           │              │
  │                       │                       │<──────────────────────│              │
  │                       │                       │  HMAC-SHA256(         │              │
  │                       │                       │   K_binding,          │              │
  │                       │                       │   canonicalized)      │              │
  │                       │                       │  zeroize(K_binding)   │              │
  │                       │   tag (32 bytes)      │                       │              │
  │                       │<──────────────────────│                       │              │
  │                       │  AES-KW wrap          │                       │              │
  │                       │  INSERT envelope      │                       │              │
  │                       │   + metadata_tag      │                       │              │
  │                       │───────────────────────────────────────────────────────────>│
  │  envelope created     │                       │                       │              │
  │<──────────────────────│                       │                       │              │

F4 — Accès enveloppe (unwrap avec vérification)

Caller              KeyEnvelopeService      MetadataBindingService         DB
  │                       │                       │                         │
  │ unwrapEnvelope(...)   │                       │                         │
  │──────────────────────>│  SELECT envelope       │                         │
  │                       │──────────────────────────────────────────────>│
  │                       │  envelope + tag        │                         │
  │                       │<──────────────────────────────────────────────│
  │                       │                       │                         │
  │                       │ [tag IS NULL?]         │                         │
  │                       │ YES (LEGACY) ────────> │                         │
  │                       │   Phase 1: read-only   │                         │
  │                       │   log warning          │                         │
  │                       │   return data (RO)     │                         │
  │                       │                       │                         │
  │                       │ NO (tag exists):       │                         │
  │                       │  verifyTag(            │                         │
  │                       │   kMasterUser,         │                         │
  │                       │   metadata, tag)       │                         │
  │                       │──────────────────────>│                         │
  │                       │  valid / invalid       │                         │
  │                       │<──────────────────────│                         │
  │                       │                       │                         │
  │                       │ [valid?]              │                         │
  │                       │ YES: AES-KW unwrap     │                         │
  │                       │   return K_master      │                         │
  │                       │                       │                         │
  │                       │ NO: HTTP 422           │                         │
  │                       │   < 100ms decision     │                         │
  │                       │   state → TAMPERED     │                         │
  │<──────────────────────│                       │                         │

F5 — Machine à états binding

                    ┌─────────────────────┐
                    │  LEGACY_NULL_TAG    │
                    │  (metadata_tag NULL) │
                    └────────┬────────────┘
                    ┌────────┴────────────┐
                    │                     │
             backfill/rotation    incohérence détectée
             conforme (F3)        (vérification binding)
                    │                     │
                    ▼                     ▼
        ┌──────────────────┐   ┌──────────────────────┐
        │ BOUND_TAG_VALID  │   │ TAMPERED_TAG_INVALID │
        │ (tag 32 bytes OK)│   │ (ÉTAT TERMINAL)      │
        └────────┬─────────┘   └──────────────────────┘
                 │                        ▲
                 │  altération détectée   │
                 └────────────────────────┘

Transitions interdites :
  BOUND → LEGACY     (downgrade interdit)
  TAMPERED → *       (état terminal, remédiation manuelle hors flux)

2bis. Diagrammes Mermaid

D1 — Graphe de dépendances entre composants

graph TD
    C2[C2 — Argon2Controller] -->|inject| C1[C1 — Argon2Service]
    C1 -->|lit| CONST[argon2.constants.ts<br/>ARGON2_CONFIG]
    C5[C5 — KeyEnvelopeService] -->|inject| C3[C3 — MetadataBindingService]
    C5 -->|inject| C1
    C3 -->|inject| HKDF[HkdfService<br/>existant]
    C3 -->|inject| HASH[HashService<br/>existant — timingSafeEqual]
    C5 -->|persiste| C4[C4 — KeyEnvelope Entity<br/>+ colonne metadata_tag]
    C4 -->|migration| C6[C6 — Migrations DDL<br/>phase 1 + phase 2]
    C7[C7 — extract-facts.py<br/>ProbatioVault-doc] -.->|extrait faits de| C1
    C7 -.->|extrait faits de| C4
    C8[C8 — Tests contractuels] -.->|teste| C1
    C8 -.->|teste| C3
    C8 -.->|teste| C5
    C8 -.->|teste| C6

    style C1 fill:#e1f5fe,stroke:#0277bd
    style C2 fill:#e1f5fe,stroke:#0277bd
    style C3 fill:#fff3e0,stroke:#e65100
    style C4 fill:#fce4ec,stroke:#b71c1c
    style C5 fill:#fff3e0,stroke:#e65100
    style C6 fill:#fce4ec,stroke:#b71c1c
    style C7 fill:#f3e5f5,stroke:#6a1b9a
    style C8 fill:#e8f5e9,stroke:#2e7d32
    style HKDF fill:#eeeeee,stroke:#616161
    style HASH fill:#eeeeee,stroke:#616161
    style CONST fill:#eeeeee,stroke:#616161

Légende : bleu = validation Argon2, orange = binding crypto, rouge = persistance/DDL, violet = vérification formelle, vert = tests, gris = services existants.

D2 — Séquence de création d'enveloppe avec metadata binding (F3)

sequenceDiagram
    participant Auth as AuthService
    participant KES as KeyEnvelopeService (C5)
    participant MBS as MetadataBindingService (C3)
    participant HKDF as HkdfService
    participant DB as PostgreSQL

    Auth->>KES: createMasterEnvelope(kMasterUser, metadata)
    activate KES
    KES->>MBS: computeTag(kMasterUser, {algo, ver, type, dev})
    activate MBS
    MBS->>HKDF: deriveKey(kMasterUser, empty_salt, 'PV::MB::v1', 32)
    HKDF-->>MBS: K_binding (32 bytes)
    Note over MBS: HMAC-SHA256(K_binding, canonical_concat)
    Note over MBS: zeroize(K_binding) in finally
    MBS-->>KES: tag (32 bytes)
    deactivate MBS
    Note over KES: AES-KW wrap(kMasterUser)
    KES->>DB: INSERT envelope + metadata_tag
    DB-->>KES: OK
    KES-->>Auth: envelope created
    deactivate KES

D3 — Séquence d'accès enveloppe avec vérification binding (F4)

sequenceDiagram
    participant Caller
    participant KES as KeyEnvelopeService (C5)
    participant MBS as MetadataBindingService (C3)
    participant DB as PostgreSQL

    Caller->>KES: unwrapEnvelope(envelopeId, kMasterUser)
    activate KES
    KES->>DB: SELECT envelope + metadata_tag
    DB-->>KES: envelope row

    alt tag IS NULL (LEGACY_NULL_TAG)
        Note over KES: Phase 1 : read-only autorisé
        Note over KES: Log warning + recommend rotation
        KES-->>Caller: data (readOnly=true)
    else tag IS NOT NULL
        KES->>MBS: verifyTag(kMasterUser, metadata, storedTag)
        activate MBS
        Note over MBS: Recalcul HMAC + timingSafeEqual
        MBS-->>KES: valid / invalid
        deactivate MBS

        alt valid
            Note over KES: AES-KW unwrap
            KES-->>Caller: K_master (BOUND_TAG_VALID)
        else invalid
            Note over KES: state -> TAMPERED_TAG_INVALID
            KES-->>Caller: HTTP 422 (< 100ms SLA)
        end
    end
    deactivate KES

D4 — Machine à états binding

stateDiagram-v2
    [*] --> LEGACY_NULL_TAG : enveloppe pré-migration<br/>(metadata_tag NULL)

    LEGACY_NULL_TAG --> BOUND_TAG_VALID : backfill / rotation<br/>computeTag() conforme (C3)
    LEGACY_NULL_TAG --> TAMPERED_TAG_INVALID : incohérence métadonnées<br/>détectée lors backfill

    BOUND_TAG_VALID --> TAMPERED_TAG_INVALID : altération détectée<br/>verifyTag() échoue (C3)

    note right of TAMPERED_TAG_INVALID
        ÉTAT TERMINAL
        Remédiation manuelle hors flux
        Transitions sortantes interdites
    end note

    note left of LEGACY_NULL_TAG
        Phase 1 : accès lecture seule
        Warning loggé (C5)
    end note

    note right of BOUND_TAG_VALID
        Tag 32 bytes OK
        Accès normal autorisé
    end note

3. Mapping invariants → mécanismes

Invariant ID Exigence Mécanisme Composant Observable Risque
INV-276-01 Backend ne dérive jamais de clé utilisateur Argon2Service.validateParams() retourne accept/reject, jamais de Buffer de clé. deriveKey() est un alias qui délègue à validateParams(). Aucune dépendance argon2 native dans package.json. C1 Absence de dépendance argon2 dans package.json ; aucune méthode ne retourne un Buffer/clé ; TC-INV-01 vérifie l'absence d'artefact de dérivation Faible — vérifié par analyse statique + test
INV-276-02 Paramètres < minima OWASP rejetés Argon2Service.validateParams() compare chaque champ au plancher (ARGON2_MIN_CONFIG). Rejet si un seul champ est sous le seuil. C1 Code erreur de validation retourné ; TC-ERR-01/02/03/04 Faible
INV-276-03 Bornes contractuelles avec rejet hors bornes ARGON2_CONFIG dans argon2.constants.ts : {memory: {default:65536, min:65536, max:1048576}, iterations: {default:3, min:3, max:10}, parallelism: {default:4, min:4, max:16}, type: {default:2, min:2, max:2}, hashLength: {default:32, min:32, max:32}}. Validation atomique de tous les champs. C1 TC-NOM-01, TC-NOM-02, TC-ERR-05, TC-NEG-04 Moyen — Q-276-01 (maxima) reste ouvert, les bornes actuelles pourraient changer
INV-276-04 Configuration unique, centralisée, exposable Singleton ARGON2_CONFIG exporté depuis argon2.constants.ts. getConfig() le retourne tel quel. C1, C2 Réponse endpoint = constantes source ; TC-NOM-03 Faible
INV-276-05 metadata_tag 32 bytes sur chaque enveloppe post-migration MetadataBindingService.computeTag() retourne 32 bytes (HMAC-SHA256). Colonne BYTEA avec CHECK octet_length = 32 (phase 2). C3, C4, C6 TC-NOM-04, TC-NOM-06, TC-NOM-07, TC-ERR-06, TC-NEG-03 Moyen — backfill LEGACY avant phase 2
INV-276-06 K_binding dérivée via HKDF avec contexte strict HkdfService.deriveKey(kMasterUser, Buffer.alloc(0), 'ProbatioVault::MetadataBinding::v1', 32). Contexte HKDF centralisé dans constante. C3 TC-INV-02 vérifie le contexte exact Faible
INV-276-07 Vérification metadata_tag avant tout accès Guard dans unwrapEnvelope() — vérification AVANT l'opération AES-KW unwrap. Tag invalide → HTTP 422 immédiat (< 100ms). C5 TC-NOM-05, TC-ERR-07, TC-NEG-01 ; mesure t_req/t_decision Faible
INV-276-08 Artefacts crypto chiffrés au repos (probatoire) Pattern existant (encrypted_envelope = AES-256-KW). K_binding zeroized dans finally. Scan probatoire post-opération. C3, C5 TC-INV-03 (scans sur échantillons) Mineur — exigence probatoire, pas exhaustive
INV-276-09 Faits Prolog requis présents Extraction automatique par extract-facts.py. Fichier nommé argon2.service.ts → slug argon2. Méthode deriveKey() publique → service_method(argon2, deriveKey). Colonne metadata_tag TypeORM → entity_column(key_envelope, metadata_tag, bytea). C1, C4, C7 TC-NOM-08, TC-NOM-09, TC-INV-04 Moyen — dépend du nommage exact du fichier et de la classe
INV-276-10 Non-régression des 21 checks existants Aucune modification de pv_envelope_compliance.pl. Migration additive (ADD COLUMN). Pas de modification de trigger existant. C4, C6 TC-NR-01, TC-NR-02 — comparatif baseline avant/après Faible
INV-276-11 Machine à états exhaustive, terminal explicite Enum interne BindingState (LEGACY_NULL_TAG, BOUND_TAG_VALID, TAMPERED_TAG_INVALID). Transitions autorisées/interdites implémentées dans KeyEnvelopeService. État TAMPERED → aucune transition sortante. C5 TC-NOM-10, TC-NOM-13, TC-ERR-10, TC-ERR-11, TC-NEG-02 Moyen — logique LEGACY→TAMPERED requiert condition de détection claire

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

Critère ID Mécanisme(s) Composant Observable Risque
CA-276-01 Extraction faits + audit Prolog C7 swipl retourne 24/24 OK Moyen — dépend de C1, C4
CA-276-02 Argon2Service injectable, fait service(argon2, 'Argon2Service') C1 Fait présent dans _generated-facts.pl Faible
CA-276-03 Méthode publique validateParams(), fait service_method(argon2, validateParams) C1 Fait présent dans _generated-facts.pl Faible
CA-276-04 validateParams() avec bornes min C1 Rejet sur paramètres < min Faible
CA-276-05 validateParams() avec bornes max C1 Rejet sur paramètres > max Faible
CA-276-06 GET /crypto/argon2/config retourne ARGON2_CONFIG C1, C2 Payload JSON = constantes source Faible
CA-276-07 Colonne metadata_tag sur entity + fait Prolog C4 entity_column(key_envelope, metadata_tag, bytea) Faible
CA-276-08 MetadataBindingService.verifyTag() + guard dans unwrapEnvelope() C3, C5 HTTP 422 sur tag invalide, < 100ms Moyen — SLA timing
CA-276-09 Migrations phase 1 (nullable) + phase 2 (NOT NULL) C6 Schema information_schema Faible
CA-276-10 down() dans les deux migrations C6 Schéma restauré après down Faible
CA-276-11 Aucune modification de pv_envelope_compliance.pl C7 Diff vide sur fichier Prolog + comparatif baseline Faible
CA-276-12 Scans probatoires post-opération C3, C5 Aucun secret en clair détecté dans échantillons Mineur — probatoire
CA-276-13 Logique LEGACY dans unwrapEnvelope() C5 Lecture seule autorisée, warning loggé Faible

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

Test ID Référence spec Mécanisme(s) Point(s) d'observation Niveau
TC-NOM-01 INV-01/02/03, CA-04 validateParams({65536,3,4,2,32}) → accept Retour validation sans erreur, pas d'artefact de dérivation Unit
TC-NOM-02 INV-03, CA-05 validateParams({1048576,10,16,2,32}) → accept Bornes max acceptées Unit
TC-NOM-03 INV-04, CA-06 GET /crypto/argon2/config Payload JSON exact au contrat Integration
TC-NOM-04 INV-05/06, CA-08 computeTag(kMaster, metadata) Tag retourné, Buffer.byteLength === 32 Unit
TC-NOM-05 INV-07, CA-08 unwrapEnvelope() avec tag valide Vérification précède unwrap, accès autorisé Unit
TC-NOM-06 INV-05, CA-09 Migration phase 1 up Colonne metadata_tag BYTEA NULL existe Integration
TC-NOM-07 INV-05, CA-09 Migration phase 2 up metadata_tag NOT NULL, INSERT sans tag rejeté Integration
TC-NOM-08 INV-09, CA-02/03/07 extract-facts.py sur code candidat 3 faits requis présents Integration
TC-NOM-09 INV-09/10, CA-01/11 swipl audit complet 24/24 OK, 21 checks historiques stables Integration
TC-NOM-10 INV-11, CA-09 computeTag sur enveloppe LEGACY puis persist Transition LEGACY → BOUND observée Unit
TC-NOM-11 CA-10 Migration down() séquentiel Schéma restauré à l'état pré-migration Integration
TC-NOM-12 CA-13 unwrapEnvelope() sur enveloppe tag IS NULL en phase 1 Accès lecture seule, warning dans logs Unit
TC-NOM-13 INV-11 unwrapEnvelope() sur LEGACY avec incohérence binding Transition LEGACY → TAMPERED, refus HTTP 422 Unit
TC-ERR-01 E-01, INV-02 validateParams({memory:65535,...}) Rejet, erreur validation Unit
TC-ERR-02 E-01, INV-02 validateParams({iterations:2,...}) Rejet Unit
TC-ERR-03 E-03, INV-02 validateParams({hashLength: undefined}) Rejet paramètre manquant Unit
TC-ERR-04 E-04, INV-02 validateParams({type:1,...}) Rejet type != 2 Unit
TC-ERR-05 E-02, INV-03 validateParams({memory:1048577,...}) Rejet hors bornes max Unit
TC-ERR-06 E-05, INV-05 INSERT enveloppe sans tag en phase 2 Rejet NOT NULL constraint Integration
TC-ERR-07 E-06, INV-07 unwrapEnvelope() avec tag altéré HTTP 422, < 100ms, aucune donnée restituée Unit + Perf
TC-ERR-08 E-07, INV-09 extract-facts.py avec fait omis (mock) Audit < 24/24 Integration
TC-ERR-09 E-08, INV-10 Audit comparatif avec check régressé Régression détectée, livraison refusée Integration
TC-ERR-10 INV-11 Tentative transition BOUND → LEGACY Rejet, état inchangé Unit
TC-ERR-11 INV-11 Tentative transition TAMPERED → * Interdite, état terminal Unit
TC-INV-01 INV-01, CA-06 Audit du flux complet de validation Aucun artefact de dérivation serveur produit Unit
TC-INV-02 INV-06, CA-08 Inspection du contexte HKDF Contexte exact ProbatioVault::MetadataBinding::v1 Unit
TC-INV-03 INV-08, CA-12 Scan artefacts crypto post-opération Aucun secret clair dans échantillons contrôlés Sec
TC-INV-04 H-02, INV-09 Cartographie check → fait check_10↔service(argon2,_)+service_method(argon2,deriveKey), check_19↔entity_column(key_envelope,metadata_tag,_), check_22↔service(argon2,_)+service_method(argon2,deriveKey) Integration
TC-NEG-01 INV-07 unwrapEnvelope() avec device_id substitué HTTP 422, < 100ms Unit + Perf
TC-NEG-02 INV-11 Transition BOUND → LEGACY programmatique Rejet Unit
TC-NEG-03 INV-05 computeTag avec résultat tronqué Rejet persistance Unit
TC-NEG-04 INV-02/03 Paramètres mixtes (1 ok + 1 hors borne) Rejet atomique Unit
TC-NEG-05 INV-04 Mock endpoint retournant config altérée Détection écart contrat Integration
TC-NEG-06 INV-10 Audit avec Prolog modifié Campagne rejetée (checksum) Integration
TC-NR-01 INV-10 Comparatif baseline/candidate 21 checks Même statut OK/KO Integration
TC-NR-02 INV-10 Trigger updated_at stable après migration Même comportement avant/après Integration
TC-NR-03 CA-13 LEGACY en phase 1 : lecture ok, écriture ko Politique transitoire respectée Integration
TC-NR-04 CA-10 Séquence up/down/up Schéma identique Integration

6. Gestion des erreurs

Code erreur Cas HTTP Réponse Composant
ARGON2_PARAMS_BELOW_MINIMUM E-01 : Paramètre < plancher OWASP 400 { error: 'ARGON2_PARAMS_BELOW_MINIMUM', field: '<field>', value: <val>, minimum: <min> } C1
ARGON2_PARAMS_ABOVE_MAXIMUM E-02 : Paramètre > plafond contractuel 400 { error: 'ARGON2_PARAMS_ABOVE_MAXIMUM', field: '<field>', value: <val>, maximum: <max> } C1
ARGON2_PARAMS_MISSING E-03 : Paramètre absent 400 { error: 'ARGON2_PARAMS_MISSING', field: '<field>' } C1
ARGON2_INVALID_TYPE E-04 : type != 2 400 { error: 'ARGON2_INVALID_TYPE', expected: 2, received: <val> } C1
METADATA_TAG_MISSING E-05 : Tag absent en phase finale 500 Erreur interne (contrainte DB NOT NULL) C6
METADATA_TAG_INVALID E-06 : Tag invalide (tampering) 422 { error: 'METADATA_TAG_INVALID', envelope_id: '<id>' } — aucun champ sensible exposé C5
PROLOG_AUDIT_FAILED E-07 : Faits manquants CI bloqué (pas HTTP, erreur CI/CD) C7
PROLOG_REGRESSION E-08 : Régression check CI bloqué C7

SLA : La décision de rejet sur METADATA_TAG_INVALID doit intervenir en < 100ms (t_decision - t_req). Le périmètre de mesure : de la réception de la requête dans unwrapEnvelope() jusqu'au throw de l'exception HTTP 422.

7. Impacts sécurité

7.1 Risques et mitigations

Risque Impact Mitigation
Exposition de K_binding en mémoire Compromission du binding Zeroization dans finally après usage (pattern AesKwService)
Timing attack sur comparaison de tag Leak d'information sur le tag crypto.timingSafeEqual() exclusivement (via HashService.constantTimeEqual)
Tag vide accepté par erreur Bypass binding CHECK constraint octet_length(metadata_tag) = 32 + validation applicative
Dérivation accidentelle de clé serveur Violation Zero-Knowledge Aucune dépendance argon2 dans package.json ; deriveKey() est un alias strict de validateParams() ; review statique
Race condition sur transition d'état Double unwrap concurrent Transactions SERIALIZABLE sur vérification + transition TAMPERED

7.2 Journalisation sécurité

Événement Niveau Contenu
Validation Argon2 acceptée INFO { action: 'argon2_validate', result: 'accepted', userId }
Validation Argon2 rejetée WARN { action: 'argon2_validate', result: 'rejected', field, reason }
Metadata tag vérifié OK DEBUG { action: 'metadata_verify', result: 'valid', envelopeId }
Metadata tag invalide (tampering) ERROR { action: 'metadata_verify', result: 'TAMPERED', envelopeId, state: 'TAMPERED_TAG_INVALID' }
Accès LEGACY avec warning WARN { action: 'legacy_access', envelopeId, phase: 1, recommendation: 'rotate_to_bound' }
Migration phase ½ appliquée INFO { action: 'migration', phase, direction: 'up'/'down' }

7.3 Conformité

  • RFC 9106 : Validation des paramètres Argon2id (sans dérivation serveur)
  • OWASP 2024 : Plancher mémoire 65536 KiB, iterations >= 3, parallelism >= 4
  • Zero-Knowledge : Aucun secret utilisateur n'atteint le serveur en clair
  • Anti-substitution : HMAC binding empêche la substitution d'enveloppe inter-device

8. Hypothèses techniques

ID Hypothèse Impact si faux
H-276-01 Projet cible = ProbatioVault-backend (NestJS/TypeORM/PostgreSQL 16+) Mauvais contrat technique
H-276-02 Les checks Prolog 10 et 22 consomment service_method(argon2, deriveKey) (vérifié dans pv_envelope_compliance.pl l. 168-169, l. 346-348). Le service doit exposer deriveKey() comme alias public. Si le Prolog vérifie un autre nom → check KO
H-276-03 extract-facts.py extrait les méthodes publiques par regex sur les fichiers *.service.ts. Le fichier DOIT être nommé argon2.service.ts et la classe Argon2Service. Slug incorrect → fait absent → audit échoue
H-276-04 HkdfService.deriveKey() accepte un salt vide (Buffer.alloc(0)) — vérifié dans le code source (l. 18 : crypto.hkdfSync('sha256', masterKey, salt, info, length), Node.js accepte un buffer vide). Si salt vide rejeté → K_binding non dérivable
H-276-05 Les enregistrements LEGACY existants peuvent être backfillés en batch hors downtime. Le backfill requiert K_master_user pour chaque enveloppe → nécessite un flux applicatif (pas un simple UPDATE SQL). Backfill impossible en simple DDL, nécessite un script applicatif dédié
H-276-06 Le code erreur contractuel pour tag invalide est HTTP 422 (spec E-06). Alignement API confirmé
H-276-07 Le trigger existant trg_key_envelopes_updated_at (BEFORE UPDATE → updated_at = NOW()) n'est pas affecté par l'ajout de metadata_tag. Si trigger a une clause restrictive → risque de non-déclenchement
H-276-08 Q-276-01 (bornes max Argon2) : les valeurs memory<=1048576, iterations<=10, parallelism<=16 sont considérées stables pour cette story. Si elles changent après implémentation, seule la constante ARGON2_CONFIG est à modifier. Nécessite un ticket de suivi si décision produit change

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

9.1 Contradiction spec vs Prolog (CRITIQUE)

La spec INV-276-09 déclare que le fait requis est service_method(argon2, validateParams). Les checks Prolog 10 et 22 vérifient service_method(argon2, deriveKey). La spec interdit de modifier le Prolog (INV-276-10).

Résolution : Le service expose deriveKey() comme alias public de validateParams(). Les deux faits sont émis. Le check Prolog passe via deriveKey. Le test TC-INV-04 doit vérifier que check_10 et check_22 consomment service_method(argon2, deriveKey) (et non validateParams).

Action : Signaler l'écart à l'auteur de la spec pour correction en prochaine itération (aligner INV-276-09 sur la réalité Prolog).

9.2 Backfill LEGACY (COMPLEXITÉ)

Le backfill des enveloppes existantes requiert K_master_user pour dériver K_binding. Or K_master_user n'est jamais stockée en clair — elle est wrappée dans l'enveloppe elle-même. Le backfill nécessite donc un flux applicatif déclenché lors du prochain accès utilisateur (lazy migration), et non un script SQL batch.

Impact : La phase 2 (NOT NULL) ne peut être activée qu'après que tous les utilisateurs actifs aient accédé au moins une fois à leur enveloppe post-migration. La migration phase 2 doit être déployée séparément, avec un monitoring du nombre d'enveloppes LEGACY restantes.

9.3 Transition LEGACY → TAMPERED (AMBIGUÏTÉ)

Le scénario TC-NOM-13 teste la transition LEGACY_NULL_TAG → TAMPERED_TAG_INVALID sur "incohérence détectée". Pour une enveloppe LEGACY (tag NULL), l'incohérence ne peut être détectée que si un contrôle d'intégrité est tenté (backfill ou rotation). Le cas se produit quand : - Un calcul de tag est tenté sur une enveloppe LEGACY - Le tag calculé est incompatible avec les métadonnées en base (ex: device_id corrompu)

Observable : Lors d'une tentative de rotation/backfill, si le tag calculé échoue à la validation (métadonnées incohérentes), l'enveloppe est marquée TAMPERED.

9.4 SLA < 100ms (RISQUE OPÉRATIONNEL)

Le SLA de décision < 100ms sur rejet de binding est mesuré dans le périmètre unwrapEnvelope() (de l'entrée de la méthode au throw). Les facteurs de risque : - Latence DB pour la lecture de l'enveloppe (réseau) - Calcul HKDF + HMAC (CPU, < 1ms attendu)

Mitigation : Mesure performance.now() avant/après dans les tests E2E. En production, métriques OpenTelemetry sur le span de vérification.

9.5 Contexte HKDF hardcodé

Le contexte 'ProbatioVault::MetadataBinding::v1' est un littéral contractuel. Il DOIT être centralisé dans argon2.constants.ts (ou un fichier de constantes crypto dédié) et JAMAIS dupliqué en inline.

10. Hors périmètre

  • Dérivation Argon2id côté serveur : Le serveur valide les paramètres mais ne dérive jamais de clé (INV-276-01).
  • Modification des règles Prolog : pv_envelope_compliance.pl reste inchangé.
  • Refonte des 21 checks existants : Aucun check vert ne peut devenir rouge.
  • Librairie native Argon2 backend : Aucune dépendance argon2, @phc/argon2, ou similaire.
  • Paramétrage adaptatif client par device : Hors scope de cette story.
  • Preuve exhaustive d'absence de secrets : Seuls des contrôles probatoires sont attendus (CA-276-12).
  • Backfill automatique des enveloppes LEGACY : Le mécanisme de lazy migration est documenté mais l'exécution en production est hors scope du code livré. Le code fournit les primitives (computeTag, migration phase ½) ; le déclenchement du backfill est opérationnel.
  • Mécanismes cross-module : Aucune modification d'autres modules identifiée. Le guard de binding est interne au module crypto.

11. Écarts spec identifiés (review inputs)

# Type Description Traitement dans le plan
1 Contradiction INV-276-09 déclare validateParams comme fait requis mais checks Prolog 10/22 vérifient deriveKey Résolu : alias deriveKey()validateParams() dans Argon2Service
2 Contradiction Vérification préalable metadata_tag (INV-07) vs accès lecture LEGACY autorisé (CA-13) Résolu : branche conditionnelle tag IS NULL → read-only (phase 1)
3 Ambiguïté SLA < 100ms — périmètre de mesure non borné Borné dans le plan : entrée unwrapEnvelope() → throw exception
4 Incohérence Spec↔Tests TC-NOM-04 rattaché à CA-276-08 alors qu'il teste la création (pas le contrôle d'accès) Documenté, non bloquant — traçabilité fragilisée
5 Hypothèse dangereuse H-276-07 — journaux d'audit prérequis externe Plan assume que le Logger NestJS existant suffit pour les observables PD-276 ; les journaux signés sont hors scope
6 Ambiguïté Q-276-01 — bornes max Argon2 ouvertes Plan utilise les bornes contractuelles INV-276-03 ; constante centralisée facilite le changement futur