Skip to main content

herolib_crypt/httpsig/
signer.rs

1//! HTTP request signing implementation.
2
3use crate::httpsig::components::{extract_authority, extract_method, extract_path};
4use crate::httpsig::digest::compute_content_digest;
5use crate::httpsig::error::HttpSigError;
6use crate::httpsig::signature_base::build_signature_base;
7use crate::keys::Ed25519Keypair;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// Output from signing an HTTP request.
11#[derive(Debug, Clone)]
12pub struct SignatureOutput {
13    /// Value for the Signature-Input header
14    pub signature_input: String,
15    /// Value for the Signature header
16    pub signature: String,
17    /// Value for the Content-Digest header
18    pub content_digest: String,
19}
20
21/// HTTP request signer using Ed25519 signatures.
22///
23/// # Example
24///
25/// ```
26/// use herolib_crypt::httpsig::HttpSigner;
27/// use herolib_crypt::keys::Ed25519Keypair;
28/// use http::Request;
29///
30/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
31/// let keypair = Ed25519Keypair::generate()?;
32/// let signer = HttpSigner::new(keypair, "user-123");
33///
34/// let body = b"{\"amount\": 100}";
35/// let mut request = Request::post("https://api.example.com/api/v1/payments")
36///     .header("content-type", "application/json")
37///     .body(body.to_vec())?;
38///
39/// signer.sign_request(&mut request, body)?;
40/// // Request now has signature headers
41/// # Ok(())
42/// # }
43/// ```
44#[cfg_attr(feature = "rhai", derive(Clone))]
45pub struct HttpSigner {
46    keypair: Ed25519Keypair,
47    key_id: String,
48    extra_headers: Vec<String>,
49    signature_label: String,
50}
51
52impl HttpSigner {
53    /// Create a new signer with a keypair and key identifier.
54    pub fn new(keypair: Ed25519Keypair, key_id: impl Into<String>) -> Self {
55        Self {
56            keypair,
57            key_id: key_id.into(),
58            extra_headers: Vec::new(),
59            signature_label: "sig1".to_string(),
60        }
61    }
62
63    /// Add additional headers to include in the signature.
64    ///
65    /// # Example
66    ///
67    /// ```
68    /// # use herolib_crypt::httpsig::HttpSigner;
69    /// # use herolib_crypt::keys::Ed25519Keypair;
70    /// # let keypair = Ed25519Keypair::generate().unwrap();
71    /// let signer = HttpSigner::new(keypair, "user-123")
72    ///     .with_headers(vec!["content-type".to_string(), "x-request-id".to_string()]);
73    /// ```
74    pub fn with_headers(mut self, headers: Vec<String>) -> Self {
75        self.extra_headers = headers;
76        self
77    }
78
79    /// Set the signature label (default: "sig1").
80    pub fn with_label(mut self, label: impl Into<String>) -> Self {
81        self.signature_label = label.into();
82        self
83    }
84
85    /// Sign an HTTP request.
86    ///
87    /// This method works with any HTTP library that uses `http::Request`.
88    /// The signature headers are automatically added to the request.
89    ///
90    /// # Arguments
91    ///
92    /// * `request` - Mutable reference to the HTTP request
93    /// * `body` - Request body bytes
94    ///
95    /// # Example
96    ///
97    /// ```
98    /// use herolib_crypt::httpsig::HttpSigner;
99    /// use herolib_crypt::keys::Ed25519Keypair;
100    /// use http::Request;
101    ///
102    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
103    /// let keypair = Ed25519Keypair::generate()?;
104    /// let signer = HttpSigner::new(keypair, "user-123");
105    ///
106    /// let body = b"{\"amount\": 100}";
107    /// let mut request = Request::post("https://api.example.com/payments")
108    ///     .header("content-type", "application/json")
109    ///     .body(body.to_vec())?;
110    ///
111    /// signer.sign_request(&mut request, body)?;
112    /// // Request now has Signature-Input, Signature, and Content-Digest headers
113    /// # Ok(())
114    /// # }
115    /// ```
116    pub fn sign_request<B>(
117        &self,
118        request: &mut http::Request<B>,
119        body: &[u8],
120    ) -> Result<(), HttpSigError> {
121        // Extract components from request
122        let method = request.method().as_str();
123        let uri = request.uri();
124        let path = uri.path();
125        let authority = uri
126            .authority()
127            .ok_or(HttpSigError::MissingAuthority)?
128            .as_str();
129
130        // Extract headers
131        let headers: Vec<(String, String)> = request
132            .headers()
133            .iter()
134            .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
135            .collect();
136
137        // Sign
138        let result = self.sign_components(method, path, authority, &headers, body)?;
139
140        // Add signature headers to request
141        request.headers_mut().insert(
142            "signature-input",
143            result
144                .signature_input
145                .parse()
146                .map_err(|_| HttpSigError::InvalidHeader("signature-input".to_string()))?,
147        );
148        request.headers_mut().insert(
149            "signature",
150            result
151                .signature
152                .parse()
153                .map_err(|_| HttpSigError::InvalidHeader("signature".to_string()))?,
154        );
155        request.headers_mut().insert(
156            "content-digest",
157            result
158                .content_digest
159                .parse()
160                .map_err(|_| HttpSigError::InvalidHeader("content-digest".to_string()))?,
161        );
162
163        Ok(())
164    }
165
166    /// Sign an HTTP response.
167    ///
168    /// This method works with any HTTP library that uses `http::Response`.
169    /// The signature headers are automatically added to the response.
170    ///
171    /// # Arguments
172    ///
173    /// * `response` - Mutable reference to the HTTP response
174    /// * `body` - Response body bytes
175    pub fn sign_response<B>(
176        &self,
177        response: &mut http::Response<B>,
178        body: &[u8],
179    ) -> Result<(), HttpSigError> {
180        // For responses, we use a fixed method and path
181        let method = "POST"; // Placeholder for response signing
182        let path = "/";
183        let authority = "response.local";
184
185        // Extract headers
186        let headers: Vec<(String, String)> = response
187            .headers()
188            .iter()
189            .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
190            .collect();
191
192        // Sign
193        let result = self.sign_components(method, path, authority, &headers, body)?;
194
195        // Add signature headers to response
196        response.headers_mut().insert(
197            "signature-input",
198            result
199                .signature_input
200                .parse()
201                .map_err(|_| HttpSigError::InvalidHeader("signature-input".to_string()))?,
202        );
203        response.headers_mut().insert(
204            "signature",
205            result
206                .signature
207                .parse()
208                .map_err(|_| HttpSigError::InvalidHeader("signature".to_string()))?,
209        );
210        response.headers_mut().insert(
211            "content-digest",
212            result
213                .content_digest
214                .parse()
215                .map_err(|_| HttpSigError::InvalidHeader("content-digest".to_string()))?,
216        );
217
218        Ok(())
219    }
220
221    /// Sign HTTP message components (internal helper).
222    ///
223    /// This is the low-level signing method used internally.
224    /// For most use cases, use `sign_request` or `sign_response` instead.
225    pub(crate) fn sign_components(
226        &self,
227        method: &str,
228        path: &str,
229        authority: &str,
230        headers: &[(String, String)],
231        body: &[u8],
232    ) -> Result<SignatureOutput, HttpSigError> {
233        // Normalize components
234        let method = extract_method(method);
235        let path = extract_path(path)?;
236        let authority = extract_authority(authority);
237
238        // Compute content digest
239        let content_digest = compute_content_digest(body);
240
241        // Get current timestamp
242        let created = SystemTime::now()
243            .duration_since(UNIX_EPOCH)
244            .map_err(|_| HttpSigError::ParseError("System time error".to_string()))?
245            .as_secs();
246
247        // Filter extra headers that are present
248        let extra_headers: Vec<(String, String)> = self
249            .extra_headers
250            .iter()
251            .filter_map(|name| {
252                headers
253                    .iter()
254                    .find(|(h, _)| h.eq_ignore_ascii_case(name))
255                    .map(|(_, v)| (name.clone(), v.clone()))
256            })
257            .collect();
258
259        // Build signature base
260        let sig_base = build_signature_base(
261            &method,
262            &path,
263            &authority,
264            &content_digest,
265            &extra_headers,
266            &self.key_id,
267            created,
268        )?;
269
270        // Get canonical string and sign it
271        let canonical = sig_base.to_canonical_string();
272        let signature = self.keypair.sign(canonical.as_bytes());
273
274        // Encode signature as base64
275        let sig_b64 = base64::Engine::encode(
276            &base64::engine::general_purpose::STANDARD,
277            signature.to_bytes(),
278        );
279
280        // Build component names list for Signature-Input
281        let component_names = sig_base.component_names();
282        let components_str = component_names
283            .iter()
284            .map(|name| format!("\"{}\"", name))
285            .collect::<Vec<_>>()
286            .join(" ");
287
288        // Build Signature-Input header value
289        let signature_input = format!(
290            "{}=({});keyid=\"{}\";alg=\"ed25519\";created={}",
291            self.signature_label, components_str, self.key_id, created
292        );
293
294        // Build Signature header value
295        let signature_header = format!("{}=:{}:", self.signature_label, sig_b64);
296
297        Ok(SignatureOutput {
298            signature_input,
299            signature: signature_header,
300            content_digest,
301        })
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use http::Request;
309
310    #[test]
311    fn test_sign_request() {
312        let keypair = Ed25519Keypair::generate().unwrap();
313        let signer = HttpSigner::new(keypair, "test-key");
314
315        let body = b"test body";
316        let mut request = Request::post("https://example.com/api/test")
317            .body(body.to_vec())
318            .unwrap();
319
320        signer.sign_request(&mut request, body).unwrap();
321
322        let sig_input = request
323            .headers()
324            .get("signature-input")
325            .unwrap()
326            .to_str()
327            .unwrap();
328        let signature = request
329            .headers()
330            .get("signature")
331            .unwrap()
332            .to_str()
333            .unwrap();
334        let digest = request
335            .headers()
336            .get("content-digest")
337            .unwrap()
338            .to_str()
339            .unwrap();
340
341        assert!(sig_input.contains("sig1="));
342        assert!(sig_input.contains("keyid=\"test-key\""));
343        assert!(sig_input.contains("alg=\"ed25519\""));
344        assert!(sig_input.contains("created="));
345
346        assert!(signature.starts_with("sig1=:"));
347        assert!(signature.ends_with(":"));
348
349        assert!(digest.starts_with("sha-256=:"));
350    }
351
352    #[test]
353    fn test_sign_with_extra_headers() {
354        let keypair = Ed25519Keypair::generate().unwrap();
355        let signer =
356            HttpSigner::new(keypair, "test-key").with_headers(vec!["content-type".to_string()]);
357
358        let body = b"{}";
359        let mut request = Request::post("https://example.com/api/test")
360            .header("content-type", "application/json")
361            .body(body.to_vec())
362            .unwrap();
363
364        signer.sign_request(&mut request, body).unwrap();
365
366        let sig_input = request
367            .headers()
368            .get("signature-input")
369            .unwrap()
370            .to_str()
371            .unwrap();
372        assert!(sig_input.contains("content-type"));
373    }
374
375    #[test]
376    fn test_custom_label() {
377        let keypair = Ed25519Keypair::generate().unwrap();
378        let signer = HttpSigner::new(keypair, "test-key").with_label("custom");
379
380        let body = b"";
381        let mut request = Request::get("https://example.com/")
382            .body(body.to_vec())
383            .unwrap();
384
385        signer.sign_request(&mut request, body).unwrap();
386
387        let sig_input = request
388            .headers()
389            .get("signature-input")
390            .unwrap()
391            .to_str()
392            .unwrap();
393        let signature = request
394            .headers()
395            .get("signature")
396            .unwrap()
397            .to_str()
398            .unwrap();
399
400        assert!(sig_input.starts_with("custom="));
401        assert!(signature.starts_with("custom=:"));
402    }
403
404    #[test]
405    fn test_empty_body() {
406        let keypair = Ed25519Keypair::generate().unwrap();
407        let signer = HttpSigner::new(keypair, "test-key");
408
409        let body = b"";
410        let mut request = Request::get("https://example.com/api/test")
411            .body(body.to_vec())
412            .unwrap();
413
414        signer.sign_request(&mut request, body).unwrap();
415
416        let digest = request
417            .headers()
418            .get("content-digest")
419            .unwrap()
420            .to_str()
421            .unwrap();
422        assert_eq!(
423            digest,
424            "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:"
425        );
426    }
427}