Skip to main content

netget/server/activitypub/
sig.rs

1//! HTTP Signatures as the fediverse uses them (draft-cavage-http-signatures-12,
2//! `rsa-sha256`, the form Mastodon and every Fedify fallback speak): signing outgoing
3//! requests, verifying incoming ones, and the `Digest` and `Date` headers they cover.
4use anyhow::{bail, ensure, Context, Result};
5use base64::Engine;
6use rsa::pkcs1::DecodeRsaPublicKey;
7use rsa::pkcs1v15::{Signature, SigningKey, VerifyingKey};
8use rsa::pkcs8::{DecodePublicKey, EncodePublicKey, LineEnding};
9use rsa::signature::{SignatureEncoding, Signer, Verifier};
10use rsa::{RsaPrivateKey, RsaPublicKey};
11use sha2::{Digest, Sha256};
12use std::collections::HashMap;
13
14/// A request whose `Date` is further than this from now is refused (Mastodon allows 12 h).
15pub const MAX_CLOCK_SKEW_SECS: i64 = 12 * 3600;
16/// A `Signature` header is at most this long.
17pub const MAX_SIGNATURE_HEADER: usize = 4096;
18pub const KEY_BITS: usize = 2048;
19
20fn b64() -> base64::engine::GeneralPurpose {
21    base64::engine::general_purpose::STANDARD
22}
23
24pub fn generate_key() -> Result<RsaPrivateKey> {
25    Ok(RsaPrivateKey::new(&mut rand::rngs::OsRng, KEY_BITS)?)
26}
27
28pub fn public_pem(key: &RsaPrivateKey) -> Result<String> {
29    Ok(RsaPublicKey::from(key).to_public_key_pem(LineEnding::LF)?)
30}
31
32/// A PEM public key: SPKI (`BEGIN PUBLIC KEY`, what everyone publishes) or PKCS#1.
33pub fn parse_public_pem(pem: &str) -> Result<RsaPublicKey> {
34    RsaPublicKey::from_public_key_pem(pem.trim())
35        .or_else(|_| RsaPublicKey::from_pkcs1_pem(pem.trim()))
36        .context("publicKeyPem is not an RSA public key")
37}
38
39/// `SHA-256=<base64>` of a body.
40pub fn digest(body: &[u8]) -> String {
41    format!("SHA-256={}", b64().encode(Sha256::digest(body)))
42}
43
44pub fn http_date() -> String {
45    httpdate_now()
46}
47
48fn httpdate_now() -> String {
49    chrono::Utc::now()
50        .format("%a, %d %b %Y %H:%M:%S GMT")
51        .to_string()
52}
53
54/// The signing string over `headers` (lower-case names, `(request-target)` included).
55fn signing_string(
56    headers: &[&str],
57    method: &str,
58    path: &str,
59    values: &HashMap<String, String>,
60) -> Result<String> {
61    let mut lines = Vec::new();
62    for h in headers {
63        if *h == "(request-target)" {
64            lines.push(format!(
65                "(request-target): {} {}",
66                method.to_lowercase(),
67                path
68            ));
69        } else {
70            let v = values
71                .get(*h)
72                .with_context(|| format!("signed header {h} is missing from the request"))?;
73            lines.push(format!("{h}: {v}"));
74        }
75    }
76    Ok(lines.join("\n"))
77}
78
79/// The `Signature` header for a request with these header values (lower-case names).
80pub fn sign(
81    key: &RsaPrivateKey,
82    key_id: &str,
83    method: &str,
84    path: &str,
85    values: &HashMap<String, String>,
86) -> Result<String> {
87    let headers: Vec<&str> = if values.contains_key("digest") {
88        vec!["(request-target)", "host", "date", "digest"]
89    } else {
90        vec!["(request-target)", "host", "date"]
91    };
92    let text = signing_string(&headers, method, path, values)?;
93    let sig = SigningKey::<Sha256>::new(key.clone()).sign(text.as_bytes());
94    Ok(format!(
95        "keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"{}\",signature=\"{}\"",
96        headers.join(" "),
97        b64().encode(sig.to_bytes())
98    ))
99}
100
101/// A parsed `Signature` header.
102pub struct Parsed {
103    pub key_id: String,
104    pub headers: Vec<String>,
105    pub signature: Vec<u8>,
106}
107
108pub fn parse(header: &str) -> Result<Parsed> {
109    ensure!(
110        header.len() <= MAX_SIGNATURE_HEADER,
111        "Signature header too long"
112    );
113    let mut fields = HashMap::new();
114    for part in header.split(',') {
115        let (k, v) = part
116            .trim()
117            .split_once('=')
118            .context("malformed Signature header")?;
119        fields.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string());
120    }
121    let algorithm = fields
122        .get("algorithm")
123        .map(String::as_str)
124        .unwrap_or("hs2019");
125    ensure!(
126        matches!(algorithm, "rsa-sha256" | "hs2019"),
127        "signature algorithm {algorithm} is not supported (rsa-sha256 only)"
128    );
129    Ok(Parsed {
130        key_id: fields
131            .get("keyId")
132            .cloned()
133            .context("Signature has no keyId")?,
134        headers: fields
135            .get("headers")
136            .map(|h| h.split_whitespace().map(str::to_lowercase).collect())
137            .unwrap_or_else(|| vec!["date".into()]),
138        signature: b64()
139            .decode(
140                fields
141                    .get("signature")
142                    .context("Signature has no signature")?,
143            )
144            .context("signature is not base64")?,
145    })
146}
147
148/// Check a request's signature with the signer's key, and that it covers what it must:
149/// the request target, `host` and `date`, and the body's `digest` on a POST, which must
150/// match the body.
151pub fn verify(
152    parsed: &Parsed,
153    key_pem: &str,
154    method: &str,
155    path: &str,
156    values: &HashMap<String, String>,
157    body: Option<&[u8]>,
158) -> Result<()> {
159    for must in ["(request-target)", "host", "date"] {
160        ensure!(
161            parsed.headers.iter().any(|h| h == must),
162            "the signature does not cover {must}"
163        );
164    }
165    if let Some(body) = body {
166        ensure!(
167            parsed.headers.iter().any(|h| h == "digest"),
168            "the signature does not cover the body's digest"
169        );
170        let claimed = values.get("digest").context("no Digest header")?;
171        let ours = digest(body);
172        ensure!(
173            claimed.split(',').any(|d| d.trim() == ours),
174            "the Digest header does not match the body"
175        );
176    }
177    let date = values.get("date").context("no Date header")?;
178    let when = chrono::DateTime::parse_from_rfc2822(date).context("Date is not an HTTP date")?;
179    let skew = (chrono::Utc::now().timestamp() - when.timestamp()).abs();
180    ensure!(skew <= MAX_CLOCK_SKEW_SECS, "Date is {skew} s from now");
181    let headers: Vec<&str> = parsed.headers.iter().map(String::as_str).collect();
182    let text = signing_string(&headers, method, path, values)?;
183    let key = parse_public_pem(key_pem)?;
184    let sig = Signature::try_from(parsed.signature.as_slice())
185        .context("signature has the wrong length")?;
186    if VerifyingKey::<Sha256>::new(key)
187        .verify(text.as_bytes(), &sig)
188        .is_err()
189    {
190        bail!("the signature does not verify against {}", parsed.key_id);
191    }
192    Ok(())
193}