Skip to main content

koprogo_api/infrastructure/external/
signature_provider_common.rs

1//! Abstraction commune aux trois adaptateurs `ElectronicSignatureProvider`
2//! (eID belge, itsme, Universign) — Story 4.4, ADR-0014.
3//!
4//! Les trois prestataires exposent — en v0.1.0, en l'absence de contrat API
5//! signé avec chacun — le même contrat REST minimal : `POST
6//! {base_url}/signatures` pour déposer un document, `GET
7//! {base_url}/signatures/{reference}` pour en connaître le statut. Ce qui les
8//! distingue est `SignatureProviderKind` (traçabilité / config), pas le
9//! protocole. Le jour où un prestataire réel s'écarte de ce contrat, son
10//! fichier `signature_provider_<x>.rs` cesse de déléguer à
11//! `RestSignatureProvider` et implémente le trait directement — cette
12//! abstraction n'est pas un mur.
13
14use crate::application::ports::electronic_signature_provider::{
15    ElectronicSignatureProvider, SignatureProviderError, SignatureRequest, SignatureRequestAck,
16};
17use crate::domain::plateforme::{QualifiedSignature, SignatureProviderKind};
18use async_trait::async_trait;
19use chrono::{DateTime, Utc};
20use hmac::{Hmac, Mac};
21use serde::{Deserialize, Serialize};
22use sha2::Sha256;
23use std::time::Duration;
24use uuid::Uuid;
25
26type HmacSha256 = Hmac<Sha256>;
27
28/// HMAC-SHA256 hex (64 caractères) du document, calculé avec la clé
29/// d'adaptateur — jamais envoyée au prestataire (AC @security).
30pub fn compute_document_hmac(secret: &[u8], document_bytes: &[u8]) -> String {
31    let mut mac =
32        HmacSha256::new_from_slice(secret).expect("HMAC-SHA256 accepts a key of any length");
33    mac.update(document_bytes);
34    hex::encode(mac.finalize().into_bytes())
35}
36
37/// Politique de réessai : 3 tentatives par défaut, délai exponentiel.
38/// Configurable pour que les tests n'attendent pas des délais réels.
39#[derive(Debug, Clone, Copy)]
40pub struct RetryPolicy {
41    pub max_attempts: u32,
42    pub base_delay: Duration,
43}
44
45impl Default for RetryPolicy {
46    fn default() -> Self {
47        Self {
48            max_attempts: 3,
49            base_delay: Duration::from_millis(200),
50        }
51    }
52}
53
54impl RetryPolicy {
55    /// Exécute `f` jusqu'à `max_attempts` fois, avec un délai exponentiel
56    /// (`base_delay * 2^tentative`) entre chaque essai. Seules les erreurs
57    /// `is_retryable()` sont rejouées — une erreur HTTP 4xx ou un
58    /// `HashMismatch` échouent tout de suite : réessayer ne changerait rien
59    /// (AC @negative).
60    pub async fn run<T, F, Fut>(&self, mut f: F) -> Result<T, SignatureProviderError>
61    where
62        F: FnMut() -> Fut,
63        Fut: std::future::Future<Output = Result<T, SignatureProviderError>>,
64    {
65        let mut attempt: u32 = 0;
66        loop {
67            match f().await {
68                Ok(v) => return Ok(v),
69                Err(e) if e.is_retryable() && attempt + 1 < self.max_attempts => {
70                    tokio::time::sleep(self.base_delay * 2u32.pow(attempt)).await;
71                    attempt += 1;
72                }
73                Err(e) => return Err(e),
74            }
75        }
76    }
77}
78
79#[derive(Debug, Serialize)]
80struct SignatureRequestBody<'a> {
81    document_id: Uuid,
82    document_hash: &'a str,
83    subject_user_id: Uuid,
84}
85
86#[derive(Debug, Deserialize)]
87struct SignatureRequestResponseBody {
88    reference: String,
89}
90
91#[derive(Debug, Deserialize)]
92struct SignatureStatusResponseBody {
93    status: String,
94    document_id: Uuid,
95    subject_user_id: Uuid,
96    document_hash: Option<String>,
97    signed_at: Option<DateTime<Utc>>,
98}
99
100fn classify_transport_error(
101    kind: SignatureProviderKind,
102    e: &reqwest::Error,
103) -> SignatureProviderError {
104    if e.is_timeout() {
105        SignatureProviderError::Timeout(kind.to_string())
106    } else if e.is_connect() {
107        SignatureProviderError::Unavailable(kind.to_string())
108    } else {
109        SignatureProviderError::Http(kind.to_string(), e.to_string())
110    }
111}
112
113fn map_status_errors(
114    kind: SignatureProviderKind,
115    status: reqwest::StatusCode,
116) -> Result<(), SignatureProviderError> {
117    if status.is_server_error() {
118        // 5xx est un signal d'indisponibilité transitoire, pas une réponse
119        // définitive : il vaut la peine de rejouer (AC @negative).
120        return Err(SignatureProviderError::Unavailable(format!(
121            "{kind} (HTTP {status})"
122        )));
123    }
124    if !status.is_success() {
125        return Err(SignatureProviderError::Http(
126            kind.to_string(),
127            format!("HTTP {status}"),
128        ));
129    }
130    Ok(())
131}
132
133/// Adaptateur REST générique — les trois prestataires y délèguent (cf. docs
134/// de module). `kind` distingue la traçabilité / config, pas le protocole.
135pub struct RestSignatureProvider {
136    kind: SignatureProviderKind,
137    client: reqwest::Client,
138    base_url: String,
139    hmac_secret: Vec<u8>,
140    retry_policy: RetryPolicy,
141}
142
143impl RestSignatureProvider {
144    pub fn new(kind: SignatureProviderKind, base_url: String, hmac_secret: Vec<u8>) -> Self {
145        let client = reqwest::Client::builder()
146            .timeout(Duration::from_secs(30))
147            .build()
148            .expect("Failed to build HTTP client");
149        Self {
150            kind,
151            client,
152            base_url,
153            hmac_secret,
154            retry_policy: RetryPolicy::default(),
155        }
156    }
157
158    /// Permet aux tests d'utiliser un délai de réessai quasi nul plutôt que
159    /// d'attendre des centaines de millisecondes réelles.
160    pub fn with_retry_policy(mut self, retry_policy: RetryPolicy) -> Self {
161        self.retry_policy = retry_policy;
162        self
163    }
164
165    /// Permet aux tests de déclencher un vrai `Timeout` (via un stub HTTP
166    /// qui répond après ce délai) sans attendre les 30s par défaut.
167    pub fn with_http_timeout(mut self, timeout: Duration) -> Self {
168        self.client = reqwest::Client::builder()
169            .timeout(timeout)
170            .build()
171            .expect("Failed to build HTTP client");
172        self
173    }
174}
175
176#[async_trait]
177impl ElectronicSignatureProvider for RestSignatureProvider {
178    fn kind(&self) -> SignatureProviderKind {
179        self.kind
180    }
181
182    async fn request_signature(
183        &self,
184        request: SignatureRequest,
185    ) -> Result<SignatureRequestAck, SignatureProviderError> {
186        let document_hash = compute_document_hmac(&self.hmac_secret, &request.document_bytes);
187        let url = format!("{}/signatures", self.base_url);
188        let kind = self.kind;
189
190        let response_body = self
191            .retry_policy
192            .run(|| async {
193                let response = self
194                    .client
195                    .post(&url)
196                    .json(&SignatureRequestBody {
197                        document_id: request.document_id,
198                        document_hash: &document_hash,
199                        subject_user_id: request.subject_user_id,
200                    })
201                    .send()
202                    .await
203                    .map_err(|e| classify_transport_error(kind, &e))?;
204
205                map_status_errors(kind, response.status())?;
206
207                response
208                    .json::<SignatureRequestResponseBody>()
209                    .await
210                    .map_err(|e| {
211                        SignatureProviderError::InvalidResponse(kind.to_string(), e.to_string())
212                    })
213            })
214            .await?;
215
216        Ok(SignatureRequestAck {
217            provider_reference: response_body.reference,
218            document_hash,
219        })
220    }
221
222    async fn fetch_signature(
223        &self,
224        provider_reference: &str,
225        expected_document_hash: &str,
226    ) -> Result<QualifiedSignature, SignatureProviderError> {
227        let url = format!("{}/signatures/{}", self.base_url, provider_reference);
228        let kind = self.kind;
229
230        let status = self
231            .retry_policy
232            .run(|| async {
233                let response = self
234                    .client
235                    .get(&url)
236                    .send()
237                    .await
238                    .map_err(|e| classify_transport_error(kind, &e))?;
239
240                if response.status() == reqwest::StatusCode::NOT_FOUND {
241                    return Err(SignatureProviderError::NotFound(
242                        provider_reference.to_string(),
243                    ));
244                }
245                map_status_errors(kind, response.status())?;
246
247                response
248                    .json::<SignatureStatusResponseBody>()
249                    .await
250                    .map_err(|e| {
251                        SignatureProviderError::InvalidResponse(kind.to_string(), e.to_string())
252                    })
253            })
254            .await?;
255
256        if status.status != "completed" {
257            return Err(SignatureProviderError::InvalidResponse(
258                kind.to_string(),
259                format!("statut '{}' — signature pas encore complète", status.status),
260            ));
261        }
262
263        let document_hash = status.document_hash.ok_or_else(|| {
264            SignatureProviderError::InvalidResponse(
265                kind.to_string(),
266                "signature complète sans document_hash".to_string(),
267            )
268        })?;
269        // Vérification à la réception (AC @security) : le prestataire doit
270        // confirmer le MÊME condensat que celui calculé avant l'envoi.
271        if document_hash != expected_document_hash {
272            return Err(SignatureProviderError::HashMismatch {
273                expected: expected_document_hash.to_string(),
274                actual: document_hash,
275            });
276        }
277        let signed_at = status.signed_at.ok_or_else(|| {
278            SignatureProviderError::InvalidResponse(
279                kind.to_string(),
280                "signature complète sans signed_at".to_string(),
281            )
282        })?;
283
284        QualifiedSignature::new(
285            kind,
286            status.subject_user_id,
287            status.document_id,
288            document_hash,
289            provider_reference.to_string(),
290            signed_at,
291        )
292        .map_err(|e| SignatureProviderError::InvalidResponse(kind.to_string(), e.to_string()))
293    }
294}