Skip to main content

actpub_httpsig/rfc9421/
sign.rs

1//! RFC 9421 request signer.
2
3use http::Request;
4use http::header::HeaderValue;
5
6use crate::error::Error;
7use crate::key::{Algorithm, SigningKey};
8use crate::rfc9421::components::{Component, build_signature_base};
9use crate::rfc9421::signature::{SIGNATURE_HEADER, serialise_signature_dict};
10use crate::rfc9421::signature_input::{
11    SIGNATURE_INPUT_HEADER, SignatureInput, serialise_signature_input_dict,
12};
13
14/// Default component sequence emitted by [`Rfc9421Signer::new`].
15///
16/// Mirrors the Cavage default in every respect except the body digest,
17/// which follows the modern RFC 9530 `Content-Digest:` form that RFC
18/// 9421 implementations expect (Mastodon 4.5+, Mitra, Takahē). Callers
19/// running in dual-stack deployments attach the legacy `Digest:` header
20/// for their Cavage signer and the modern `Content-Digest:` header for
21/// this one; both names can be emitted side by side, since receivers
22/// simply ignore headers they do not recognise.
23pub const DEFAULT_COMPONENTS: &[&str] =
24    &["@method", "@target-uri", "host", "date", "content-digest"];
25
26/// A request signer that produces RFC 9421 `Signature-Input:` and
27/// `Signature:` headers.
28#[derive(Debug)]
29pub struct Rfc9421Signer<'a> {
30    key: &'a SigningKey,
31    key_id: &'a str,
32    label: String,
33    components: Vec<Component>,
34    created: Option<i64>,
35    expires: Option<i64>,
36    emit_alg: bool,
37    nonce: Option<String>,
38    tag: Option<String>,
39}
40
41impl<'a> Rfc9421Signer<'a> {
42    /// Creates a signer with the [`DEFAULT_COMPONENTS`] layout, label
43    /// `"sig1"`, and `alg=` emitted for compatibility.
44    ///
45    /// # Panics
46    ///
47    /// Panics if any entry in [`DEFAULT_COMPONENTS`] fails to parse as
48    /// a valid identifier. The default list is a compile-time constant,
49    /// so this is unreachable at runtime.
50    #[must_use]
51    pub fn new(key: &'a SigningKey, key_id: &'a str) -> Self {
52        #[allow(
53            clippy::expect_used,
54            reason = "the DEFAULT_COMPONENTS constant contains only valid identifiers"
55        )]
56        let components = DEFAULT_COMPONENTS
57            .iter()
58            .map(|ident| Component::parse(ident).expect("valid default component"))
59            .collect();
60        Self {
61            key,
62            key_id,
63            label: "sig1".into(),
64            components,
65            created: None,
66            expires: None,
67            emit_alg: true,
68            nonce: None,
69            tag: None,
70        }
71    }
72
73    /// Replaces the full component list.
74    #[must_use]
75    pub fn with_components(mut self, components: Vec<Component>) -> Self {
76        self.components = components;
77        self
78    }
79
80    /// Replaces the `Signature-Input:` label (default `"sig1"`).
81    #[must_use]
82    pub fn with_label(mut self, label: impl Into<String>) -> Self {
83        self.label = label.into();
84        self
85    }
86
87    /// Sets the `created=` parameter.
88    #[must_use]
89    pub const fn with_created(mut self, seconds: i64) -> Self {
90        self.created = Some(seconds);
91        self
92    }
93
94    /// Sets the `expires=` parameter.
95    #[must_use]
96    pub const fn with_expires(mut self, seconds: i64) -> Self {
97        self.expires = Some(seconds);
98        self
99    }
100
101    /// Sets the `nonce=` parameter.
102    #[must_use]
103    pub fn with_nonce(mut self, nonce: impl Into<String>) -> Self {
104        self.nonce = Some(nonce.into());
105        self
106    }
107
108    /// Sets the `tag=` parameter.
109    #[must_use]
110    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
111        self.tag = Some(tag.into());
112        self
113    }
114
115    /// Controls whether the `alg=` parameter is emitted. Defaults to
116    /// `true`; set to `false` to match RFC 9421 §3.3.7's stated
117    /// preference for relying on out-of-band key agreement instead.
118    #[must_use]
119    pub const fn emit_alg(mut self, emit: bool) -> Self {
120        self.emit_alg = emit;
121        self
122    }
123
124    /// Signs `req` and inserts `Signature-Input:` and `Signature:` headers.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`Error::RequiredHeaderAbsent`] if the request is missing
129    /// any referenced header, [`Error::Crypto`] if the signing primitive
130    /// fails, and [`Error::InvalidHeader`] if the resulting header value
131    /// cannot be converted to an [`http::HeaderValue`] (extremely rare,
132    /// only if the key id contains non-ASCII bytes).
133    pub fn sign<B>(&self, req: &mut Request<B>) -> Result<(), Error> {
134        let input = SignatureInput {
135            components: self.components.clone(),
136            keyid: Some(self.key_id.to_owned()),
137            algorithm: self.emit_alg.then(|| algorithm_name(self.key).to_owned()),
138            created: self.created,
139            expires: self.expires,
140            nonce: self.nonce.clone(),
141            tag: self.tag.clone(),
142        };
143        let inner_list = input.serialise_inner_list();
144        let base = build_signature_base(req, &self.components, &inner_list)?;
145        let sig_bytes = self.key.sign(base.as_bytes())?;
146
147        let input_value = serialise_signature_input_dict(&[(self.label.clone(), input)]);
148        let sig_value = serialise_signature_dict(&[(self.label.clone(), sig_bytes)]);
149
150        insert_header(req, SIGNATURE_INPUT_HEADER, &input_value)?;
151        insert_header(req, SIGNATURE_HEADER, &sig_value)?;
152        Ok(())
153    }
154}
155
156const fn algorithm_name(key: &SigningKey) -> &'static str {
157    match key.algorithm() {
158        Algorithm::RsaSha256 => "rsa-v1_5-sha256",
159        Algorithm::Ed25519 => "ed25519",
160    }
161}
162
163fn insert_header<B>(req: &mut Request<B>, name: &'static str, value: &str) -> Result<(), Error> {
164    let value = HeaderValue::from_str(value).map_err(|e| Error::InvalidHeader {
165        name,
166        reason: e.to_string(),
167    })?;
168    req.headers_mut().insert(name, value);
169    Ok(())
170}
171
172#[cfg(test)]
173mod tests {
174    use http::{Method, Request};
175    use pretty_assertions::assert_eq;
176
177    use super::*;
178    use crate::content_digest::content_digest_header;
179    use crate::rfc9421::signature::parse_signature_dict;
180    use crate::rfc9421::signature_input::parse_signature_input_dict;
181
182    fn sample_request() -> Request<Vec<u8>> {
183        let body = b"{}";
184        Request::builder()
185            .method(Method::POST)
186            .uri("https://example.com/inbox")
187            .header("host", "example.com")
188            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
189            .header("content-digest", content_digest_header(body))
190            .body(body.to_vec())
191            .expect("valid")
192    }
193
194    #[test]
195    fn sign_inserts_both_headers_with_matching_label() {
196        let key = SigningKey::generate_ed25519();
197        let mut req = sample_request();
198        Rfc9421Signer::new(&key, "https://example.com/actor#sig")
199            .with_label("sig1")
200            .with_created(1_700_000_000)
201            .sign(&mut req)
202            .expect("sign");
203
204        let input_raw = req
205            .headers()
206            .get(SIGNATURE_INPUT_HEADER)
207            .expect("Signature-Input present")
208            .to_str()
209            .expect("ASCII");
210        let sig_raw = req
211            .headers()
212            .get(SIGNATURE_HEADER)
213            .expect("Signature present")
214            .to_str()
215            .expect("ASCII");
216
217        let input = parse_signature_input_dict(input_raw).expect("parse input");
218        let sig = parse_signature_dict(sig_raw).expect("parse sig");
219        assert_eq!(input[0].0, "sig1");
220        assert_eq!(sig[0].0, "sig1");
221        assert_eq!(
222            input[0].1.keyid.as_deref(),
223            Some("https://example.com/actor#sig")
224        );
225        assert_eq!(input[0].1.algorithm.as_deref(), Some("ed25519"));
226        assert_eq!(input[0].1.created, Some(1_700_000_000));
227    }
228
229    #[test]
230    fn rsa_signer_uses_rfc9421_algorithm_name() {
231        let key = SigningKey::generate_rsa(crate::key::RsaBits::Rsa2048).expect("rng");
232        let mut req = sample_request();
233        Rfc9421Signer::new(&key, "kid")
234            .sign(&mut req)
235            .expect("sign");
236        let input_raw = req
237            .headers()
238            .get(SIGNATURE_INPUT_HEADER)
239            .unwrap()
240            .to_str()
241            .unwrap();
242        let input = parse_signature_input_dict(input_raw).unwrap();
243        assert_eq!(input[0].1.algorithm.as_deref(), Some("rsa-v1_5-sha256"));
244    }
245
246    #[test]
247    fn emit_alg_false_suppresses_alg_parameter() {
248        let key = SigningKey::generate_ed25519();
249        let mut req = sample_request();
250        Rfc9421Signer::new(&key, "kid")
251            .emit_alg(false)
252            .sign(&mut req)
253            .expect("sign");
254        let input_raw = req
255            .headers()
256            .get(SIGNATURE_INPUT_HEADER)
257            .unwrap()
258            .to_str()
259            .unwrap();
260        let input = parse_signature_input_dict(input_raw).unwrap();
261        assert_eq!(input[0].1.algorithm, None);
262    }
263}