Skip to main content

actpub_httpsig/cavage/
sign.rs

1//! Cavage draft-12 request signer.
2
3use base64ct::{Base64, Encoding};
4use http::Request;
5use http::header::HeaderValue;
6
7use crate::cavage::canonical::{CavageHeaderSet, Timestamps, build_signature_base};
8use crate::cavage::header::{CavageHeaderParams, SIGNATURE_HEADER};
9use crate::error::Error;
10use crate::key::{Algorithm, SigningKey};
11
12/// The default header set signed on outbound POST requests.
13///
14/// Contains the five headers every mainstream Fediverse
15/// implementation (Mastodon, Pleroma, Lemmy, Mitra, Misskey)
16/// expects to see participate in the signature base:
17///
18/// - `(request-target)` -- method + path + query pseudo-header
19/// - `host` -- domain the request is being sent to
20/// - `date` -- HTTP-date used for replay-window enforcement
21/// - `digest` -- legacy body digest (RFC 3230 / 5843)
22/// - `content-type` -- defence against content-type confusion
23///   attacks; Mitra and strict Lemmy versions require it and
24///   Mastodon simply ignores additional signed headers
25///
26/// Callers typically construct a [`CavageSigner`] without
27/// specifying the header set, in which case this default applies.
28pub const DEFAULT_HEADER_SET: &[&str] =
29    &["(request-target)", "host", "date", "digest", "content-type"];
30
31/// A request signer that attaches a Cavage `Signature:` header to an
32/// `http::Request`.
33///
34/// Borrows the signing key so that multiple requests can share the same
35/// key without reallocating it.
36#[derive(Debug)]
37pub struct CavageSigner<'a> {
38    key: &'a SigningKey,
39    key_id: &'a str,
40    headers: CavageHeaderSet,
41    created: Option<i64>,
42    expires: Option<i64>,
43    emit_algorithm: bool,
44}
45
46impl<'a> CavageSigner<'a> {
47    /// Creates a signer using the [`DEFAULT_HEADER_SET`] and emitting the
48    /// `algorithm="…"` parameter for maximum compatibility with older
49    /// Fediverse implementations.
50    #[must_use]
51    pub fn new(key: &'a SigningKey, key_id: &'a str) -> Self {
52        Self {
53            key,
54            key_id,
55            headers: CavageHeaderSet::new(DEFAULT_HEADER_SET.iter().copied()),
56            created: None,
57            expires: None,
58            emit_algorithm: true,
59        }
60    }
61
62    /// Replaces the header set to sign.
63    #[must_use]
64    pub fn with_headers<I, S>(mut self, headers: I) -> Self
65    where
66        I: IntoIterator<Item = S>,
67        S: Into<String>,
68    {
69        self.headers = CavageHeaderSet::new(headers);
70        self
71    }
72
73    /// Replaces the header set directly.
74    #[must_use]
75    pub fn with_header_set(mut self, headers: CavageHeaderSet) -> Self {
76        self.headers = headers;
77        self
78    }
79
80    /// Attaches a `(created)` timestamp. Required if the header set
81    /// includes `(created)`.
82    #[must_use]
83    pub const fn with_created(mut self, seconds: i64) -> Self {
84        self.created = Some(seconds);
85        self
86    }
87
88    /// Attaches an `(expires)` timestamp.
89    #[must_use]
90    pub const fn with_expires(mut self, seconds: i64) -> Self {
91        self.expires = Some(seconds);
92        self
93    }
94
95    /// Controls whether the `algorithm="…"` parameter is emitted.
96    ///
97    /// Cavage draft-12 §2.1.1 recommends against emitting the algorithm,
98    /// but every Fediverse implementation today expects to see it, so
99    /// this defaults to `true`.
100    #[must_use]
101    pub const fn emit_algorithm(mut self, emit: bool) -> Self {
102        self.emit_algorithm = emit;
103        self
104    }
105
106    /// Computes the signature over `req` and inserts the resulting
107    /// `Signature:` header in place.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`Error::RequiredHeaderAbsent`] if the request does not
112    /// carry every header listed in the signer's header set, and any
113    /// error from [`SigningKey::sign`].
114    pub fn sign<B>(&self, req: &mut Request<B>) -> Result<(), Error> {
115        let base = build_signature_base(
116            req,
117            &self.headers,
118            Timestamps {
119                created: self.created,
120                expires: self.expires,
121            },
122        )?;
123        let sig_bytes = self.key.sign(base.as_bytes())?;
124        let sig_b64 = Base64::encode_string(&sig_bytes);
125
126        let params = CavageHeaderParams {
127            key_id: self.key_id.to_owned(),
128            algorithm: self.emit_algorithm.then(|| algorithm_name(self.key)),
129            headers: self.headers.clone(),
130            signature: sig_b64,
131            created: self.created,
132            expires: self.expires,
133        };
134
135        let value =
136            HeaderValue::from_str(&params.to_header_value()).map_err(|e| Error::InvalidHeader {
137                name: "signature",
138                reason: e.to_string(),
139            })?;
140        req.headers_mut().insert(SIGNATURE_HEADER, value);
141        Ok(())
142    }
143}
144
145fn algorithm_name(key: &SigningKey) -> String {
146    match key.algorithm() {
147        Algorithm::RsaSha256 => "rsa-sha256".to_owned(),
148        Algorithm::Ed25519 => "ed25519".to_owned(),
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use http::{Method, Request};
155    use pretty_assertions::assert_eq;
156
157    use super::*;
158    use crate::cavage::header::CavageHeaderParams;
159    use crate::digest::sha256_digest_header;
160    use crate::key::RsaBits;
161
162    fn sample_post(body: &[u8]) -> Request<Vec<u8>> {
163        Request::builder()
164            .method(Method::POST)
165            .uri("https://example.com/inbox")
166            .header("host", "example.com")
167            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
168            .header("digest", sha256_digest_header(body))
169            .header("content-type", "application/activity+json")
170            .body(body.to_vec())
171            .expect("valid request")
172    }
173
174    #[test]
175    fn ed25519_sign_inserts_signature_header_with_correct_shape() {
176        let key = SigningKey::generate_ed25519();
177        let mut req = sample_post(b"{}");
178        let signer = CavageSigner::new(&key, "https://example.com/actors/alice#main-key");
179        signer.sign(&mut req).expect("sign must succeed");
180
181        let raw = req
182            .headers()
183            .get(SIGNATURE_HEADER)
184            .expect("Signature header was inserted")
185            .to_str()
186            .expect("ASCII");
187
188        let params = CavageHeaderParams::parse(raw).expect("parseable");
189        assert_eq!(params.key_id, "https://example.com/actors/alice#main-key");
190        assert_eq!(params.algorithm.as_deref(), Some("ed25519"));
191        assert_eq!(params.headers.len(), DEFAULT_HEADER_SET.len());
192        assert!(!params.signature.is_empty());
193    }
194
195    #[test]
196    fn rsa_sha256_sign_emits_rsa_sha256_algorithm_name() {
197        let key = SigningKey::generate_rsa(RsaBits::Rsa2048).expect("rng");
198        let mut req = sample_post(b"{}");
199        let signer = CavageSigner::new(&key, "kid");
200        signer.sign(&mut req).expect("sign");
201        let params = CavageHeaderParams::parse(
202            req.headers()
203                .get(SIGNATURE_HEADER)
204                .unwrap()
205                .to_str()
206                .unwrap(),
207        )
208        .unwrap();
209        assert_eq!(params.algorithm.as_deref(), Some("rsa-sha256"));
210    }
211
212    #[test]
213    fn emit_algorithm_false_suppresses_algorithm_parameter() {
214        let key = SigningKey::generate_ed25519();
215        let mut req = sample_post(b"{}");
216        let signer = CavageSigner::new(&key, "kid").emit_algorithm(false);
217        signer.sign(&mut req).expect("sign");
218        let params = CavageHeaderParams::parse(
219            req.headers()
220                .get(SIGNATURE_HEADER)
221                .unwrap()
222                .to_str()
223                .unwrap(),
224        )
225        .unwrap();
226        assert_eq!(params.algorithm, None);
227    }
228
229    #[test]
230    fn missing_required_header_returns_required_header_absent() {
231        let key = SigningKey::generate_ed25519();
232        let mut req = Request::builder()
233            .method(Method::POST)
234            .uri("https://example.com/inbox")
235            .body(Vec::<u8>::new())
236            .unwrap();
237        let signer = CavageSigner::new(&key, "kid");
238        let err = signer.sign(&mut req).expect_err("missing host/date/digest");
239        assert!(matches!(err, Error::RequiredHeaderAbsent(_)));
240    }
241}