koprogo_api/infrastructure/external/
signature_provider_common.rs1use 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
28pub 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#[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 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 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
133pub 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 pub fn with_retry_policy(mut self, retry_policy: RetryPolicy) -> Self {
161 self.retry_policy = retry_policy;
162 self
163 }
164
165 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 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}