Skip to main content

koprogo_api/domain/plateforme/
qualified_signature.rs

1//! `QualifiedSignature` — signature électronique qualifiée (Story 4.4, ADR-0014).
2//!
3//! Trois prestataires (eID belge/FAS, itsme, Universign) parlent chacun leur
4//! protocole ; ce que le domaine connaît, c'est le résultat : un document,
5//! un signataire, un prestataire, un instant, un condensat vérifié.
6//!
7//! Le condensat (`document_hash`) est un HMAC-SHA256 hex (64 caractères) —
8//! calculé avant l'envoi au prestataire et vérifié à la réception
9//! (`application::ports::electronic_signature_provider`, AC @security). Le
10//! domaine ne calcule pas le HMAC lui-même : la clé est un secret
11//! d'adaptateur (cf. `infrastructure::external::signature_provider_common`),
12//! pas une donnée métier. Il valide seulement la FORME du condensat qu'on
13//! lui présente.
14
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18use uuid::Uuid;
19
20/// Le prestataire de signature électronique sollicité.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SignatureProviderKind {
24    /// eID belge — carte d'identité électronique (FAS, Federal Authentication
25    /// Service). Prestataire par défaut v0.1.0, gratuit (ADR-0014 §4).
26    Eid,
27    /// itsme — signature via l'application mobile belge.
28    Itsme,
29    /// Universign — prestataire tiers, repli pour les signataires non-BE.
30    Universign,
31}
32
33impl SignatureProviderKind {
34    /// Encodage stable (config, logs, DB future).
35    pub fn as_db_str(&self) -> &'static str {
36        match self {
37            Self::Eid => "eid",
38            Self::Itsme => "itsme",
39            Self::Universign => "universign",
40        }
41    }
42
43    pub fn from_db_str(s: &str) -> Option<Self> {
44        match s {
45            "eid" => Some(Self::Eid),
46            "itsme" => Some(Self::Itsme),
47            "universign" => Some(Self::Universign),
48            _ => None,
49        }
50    }
51}
52
53impl std::fmt::Display for SignatureProviderKind {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        write!(f, "{}", self.as_db_str())
56    }
57}
58
59/// Sélectionne le prestataire à solliciter : la préférence du cabinet, sauf
60/// pour un signataire non-belge — l'eID et itsme supposent une identité
61/// belge (FAS/RRN), Universign ne le suppose pas (ADR-0014 §4).
62///
63/// La préférence est celle du cabinet, la contrainte est celle du
64/// signataire : c'est leur rencontre qu'il faut évaluer, pas l'une ou
65/// l'autre isolément.
66pub fn select_signature_provider(
67    cabinet_preference: SignatureProviderKind,
68    subject_is_belgian: bool,
69) -> SignatureProviderKind {
70    if subject_is_belgian {
71        cabinet_preference
72    } else {
73        SignatureProviderKind::Universign
74    }
75}
76
77/// Erreurs de construction d'une `QualifiedSignature`.
78#[derive(Debug, Error, Clone, PartialEq, Eq)]
79pub enum QualifiedSignatureError {
80    #[error(
81        "Le condensat du document doit être un HMAC-SHA256 hexadécimal (64 caractères), \
82         reçu {0} caractère(s)"
83    )]
84    DocumentHashInvalidLength(usize),
85
86    #[error("Le condensat du document contient des caractères non hexadécimaux")]
87    DocumentHashNotHex,
88
89    #[error("La référence prestataire ne peut pas être vide")]
90    ProviderReferenceEmpty,
91}
92
93/// Une signature électronique qualifiée, reçue d'un prestataire et prête à
94/// être auditée par le use-case appelant.
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct QualifiedSignature {
97    pub id: Uuid,
98    pub provider: SignatureProviderKind,
99    pub subject_user_id: Uuid,
100    pub document_id: Uuid,
101    /// HMAC-SHA256 hex (64 caractères) — vérifié contre le condensat calculé
102    /// avant l'envoi (AC @security, Story 4.4).
103    pub document_hash: String,
104    /// Identifiant attribué par le prestataire (utilisé pour `fetch_signature`).
105    pub provider_reference: String,
106    pub signed_at: DateTime<Utc>,
107    pub created_at: DateTime<Utc>,
108}
109
110impl QualifiedSignature {
111    pub fn new(
112        provider: SignatureProviderKind,
113        subject_user_id: Uuid,
114        document_id: Uuid,
115        document_hash: String,
116        provider_reference: String,
117        signed_at: DateTime<Utc>,
118    ) -> Result<Self, QualifiedSignatureError> {
119        if document_hash.len() != 64 {
120            return Err(QualifiedSignatureError::DocumentHashInvalidLength(
121                document_hash.len(),
122            ));
123        }
124        if !document_hash.chars().all(|c| c.is_ascii_hexdigit()) {
125            return Err(QualifiedSignatureError::DocumentHashNotHex);
126        }
127        let provider_reference = provider_reference.trim().to_string();
128        if provider_reference.is_empty() {
129            return Err(QualifiedSignatureError::ProviderReferenceEmpty);
130        }
131
132        Ok(Self {
133            id: Uuid::new_v4(),
134            provider,
135            subject_user_id,
136            document_id,
137            document_hash,
138            provider_reference,
139            signed_at,
140            created_at: Utc::now(),
141        })
142    }
143}
144
145// ============================================================================
146// Tests — taxonomie 4 catégories obligatoire (CRITICAL.md #3)
147// ============================================================================
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn valid_hash() -> String {
154        "a".repeat(64)
155    }
156
157    // ------------------------------------------------------------------
158    // @happy
159    // ------------------------------------------------------------------
160
161    #[test]
162    fn happy_new_builds_a_valid_qualified_signature() {
163        let sig = QualifiedSignature::new(
164            SignatureProviderKind::Eid,
165            Uuid::new_v4(),
166            Uuid::new_v4(),
167            valid_hash(),
168            "ref-123".to_string(),
169            Utc::now(),
170        )
171        .unwrap();
172        assert_eq!(sig.provider, SignatureProviderKind::Eid);
173        assert_eq!(sig.provider_reference, "ref-123");
174    }
175
176    #[test]
177    fn happy_belgian_subject_gets_cabinet_preference() {
178        let selected = select_signature_provider(SignatureProviderKind::Itsme, true);
179        assert_eq!(selected, SignatureProviderKind::Itsme);
180    }
181
182    #[test]
183    fn happy_universign_preference_is_never_overridden() {
184        let selected = select_signature_provider(SignatureProviderKind::Universign, true);
185        assert_eq!(selected, SignatureProviderKind::Universign);
186    }
187
188    // ------------------------------------------------------------------
189    // @edge — la préférence du cabinet rencontre la contrainte du signataire
190    // ------------------------------------------------------------------
191
192    #[test]
193    fn edge_non_belgian_subject_falls_back_to_universign_even_if_itsme_preferred() {
194        let selected = select_signature_provider(SignatureProviderKind::Itsme, false);
195        assert_eq!(selected, SignatureProviderKind::Universign);
196    }
197
198    #[test]
199    fn edge_non_belgian_subject_falls_back_to_universign_even_if_eid_preferred() {
200        // eID suppose aussi une identité belge (FAS/RRN) : le repli s'applique
201        // pareillement, pas seulement pour itsme.
202        let selected = select_signature_provider(SignatureProviderKind::Eid, false);
203        assert_eq!(selected, SignatureProviderKind::Universign);
204    }
205
206    #[test]
207    fn edge_document_hash_must_be_exactly_64_chars() {
208        let err = QualifiedSignature::new(
209            SignatureProviderKind::Eid,
210            Uuid::new_v4(),
211            Uuid::new_v4(),
212            "a".repeat(63),
213            "ref".to_string(),
214            Utc::now(),
215        )
216        .unwrap_err();
217        assert_eq!(err, QualifiedSignatureError::DocumentHashInvalidLength(63));
218    }
219
220    // ------------------------------------------------------------------
221    // @security
222    // ------------------------------------------------------------------
223
224    #[test]
225    fn security_document_hash_must_be_hex_only() {
226        // Un condensat qui contient des caractères non hexadécimaux ne peut
227        // pas être un HMAC-SHA256 valide — le rejeter ici évite de persister
228        // une "preuve" d'intégrité qui n'en est pas une.
229        let err = QualifiedSignature::new(
230            SignatureProviderKind::Itsme,
231            Uuid::new_v4(),
232            Uuid::new_v4(),
233            "z".repeat(64),
234            "ref".to_string(),
235            Utc::now(),
236        )
237        .unwrap_err();
238        assert_eq!(err, QualifiedSignatureError::DocumentHashNotHex);
239    }
240
241    // ------------------------------------------------------------------
242    // @negative — défaillance correcte (erreur typée, pas de panic)
243    // ------------------------------------------------------------------
244
245    #[test]
246    fn negative_empty_provider_reference_is_rejected() {
247        let err = QualifiedSignature::new(
248            SignatureProviderKind::Universign,
249            Uuid::new_v4(),
250            Uuid::new_v4(),
251            valid_hash(),
252            "   ".to_string(),
253            Utc::now(),
254        )
255        .unwrap_err();
256        assert_eq!(err, QualifiedSignatureError::ProviderReferenceEmpty);
257    }
258
259    #[test]
260    fn negative_from_db_str_unknown_value_returns_none_not_panic() {
261        assert_eq!(SignatureProviderKind::from_db_str("docusign"), None);
262    }
263}