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
129                .emit_algorithm
130                .then(|| algorithm_name(self.key).to_owned()),
131            headers: self.headers.clone(),
132            signature: sig_b64,
133            created: self.created,
134            expires: self.expires,
135        };
136
137        let value =
138            HeaderValue::from_str(&params.to_header_value()).map_err(|e| Error::InvalidHeader {
139                name: "signature",
140                reason: e.to_string(),
141            })?;
142        req.headers_mut().insert(SIGNATURE_HEADER, value);
143        Ok(())
144    }
145}
146
147const fn algorithm_name(key: &SigningKey) -> &'static str {
148    match key.algorithm() {
149        Algorithm::RsaSha256 => "rsa-sha256",
150        Algorithm::Ed25519 => "ed25519",
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use http::{Method, Request};
157    use pretty_assertions::assert_eq;
158
159    use super::*;
160    use crate::cavage::header::CavageHeaderParams;
161    use crate::digest::sha256_digest_header;
162    use crate::key::RsaBits;
163
164    fn sample_post(body: &[u8]) -> Request<Vec<u8>> {
165        Request::builder()
166            .method(Method::POST)
167            .uri("https://example.com/inbox")
168            .header("host", "example.com")
169            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
170            .header("digest", sha256_digest_header(body))
171            .header("content-type", "application/activity+json")
172            .body(body.to_vec())
173            .expect("valid request")
174    }
175
176    #[test]
177    fn ed25519_sign_inserts_signature_header_with_correct_shape() {
178        let key = SigningKey::generate_ed25519();
179        let mut req = sample_post(b"{}");
180        let signer = CavageSigner::new(&key, "https://example.com/actors/alice#main-key");
181        signer.sign(&mut req).expect("sign must succeed");
182
183        let raw = req
184            .headers()
185            .get(SIGNATURE_HEADER)
186            .expect("Signature header was inserted")
187            .to_str()
188            .expect("ASCII");
189
190        let params = CavageHeaderParams::parse(raw).expect("parseable");
191        assert_eq!(params.key_id, "https://example.com/actors/alice#main-key");
192        assert_eq!(params.algorithm.as_deref(), Some("ed25519"));
193        assert_eq!(params.headers.len(), DEFAULT_HEADER_SET.len());
194        assert!(!params.signature.is_empty());
195    }
196
197    #[test]
198    fn rsa_sha256_sign_emits_rsa_sha256_algorithm_name() {
199        let key = SigningKey::generate_rsa(RsaBits::Rsa2048).expect("rng");
200        let mut req = sample_post(b"{}");
201        let signer = CavageSigner::new(&key, "kid");
202        signer.sign(&mut req).expect("sign");
203        let params = CavageHeaderParams::parse(
204            req.headers()
205                .get(SIGNATURE_HEADER)
206                .unwrap()
207                .to_str()
208                .unwrap(),
209        )
210        .unwrap();
211        assert_eq!(params.algorithm.as_deref(), Some("rsa-sha256"));
212    }
213
214    #[test]
215    fn emit_algorithm_false_suppresses_algorithm_parameter() {
216        let key = SigningKey::generate_ed25519();
217        let mut req = sample_post(b"{}");
218        let signer = CavageSigner::new(&key, "kid").emit_algorithm(false);
219        signer.sign(&mut req).expect("sign");
220        let params = CavageHeaderParams::parse(
221            req.headers()
222                .get(SIGNATURE_HEADER)
223                .unwrap()
224                .to_str()
225                .unwrap(),
226        )
227        .unwrap();
228        assert_eq!(params.algorithm, None);
229    }
230
231    #[test]
232    fn missing_required_header_returns_required_header_absent() {
233        let key = SigningKey::generate_ed25519();
234        let mut req = Request::builder()
235            .method(Method::POST)
236            .uri("https://example.com/inbox")
237            .body(Vec::<u8>::new())
238            .unwrap();
239        let signer = CavageSigner::new(&key, "kid");
240        let err = signer.sign(&mut req).expect_err("missing host/date/digest");
241        assert!(matches!(err, Error::RequiredHeaderAbsent(_)));
242    }
243}