Skip to main content

solid_pod_rs_activitypub/
http_sig.rs

1//! HTTP Signatures for ActivityPub federation.
2//!
3//! ActivityPub servers in the wild (Mastodon, Pleroma, Misskey,
4//! GoToSocial) overwhelmingly use **draft-cavage-http-signatures-12**
5//! with `rsa-sha256` as the signing algorithm. RFC 9421 is newer and
6//! not yet widely deployed in the fediverse, so this module supports
7//! both — verification auto-detects by header shape.
8//!
9//! Covered headers for AP:
10//!   * `(request-target)` — method + path
11//!   * `host`
12//!   * `date`
13//!   * `digest` — SHA-256 of body (inbound only; required for POST)
14//!
15//! References:
16//!   * <https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12>
17//!   * <https://www.rfc-editor.org/rfc/rfc9421.html>
18//!   * <https://docs.joinmastodon.org/spec/security/#http>
19
20use async_trait::async_trait;
21use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
22use rsa::pkcs1v15::{Signature as RsaSignature, SigningKey, VerifyingKey};
23use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey};
24use rsa::signature::{SignatureEncoding, Signer, Verifier};
25use rsa::{RsaPrivateKey, RsaPublicKey};
26use sha2::{Digest, Sha256};
27use std::collections::HashMap;
28
29use crate::error::SigError;
30
31/// A raw inbound request awaiting signature verification.
32#[derive(Debug, Clone)]
33pub struct SignedRequest {
34    pub method: String,
35    pub path: String,
36    /// Lower-cased header name → value. Multi-valued headers are
37    /// joined with ", " per RFC 7230 §3.2.2.
38    pub headers: HashMap<String, String>,
39    pub body: Vec<u8>,
40}
41
42impl SignedRequest {
43    pub fn new(method: impl Into<String>, path: impl Into<String>, body: Vec<u8>) -> Self {
44        Self {
45            method: method.into(),
46            path: path.into(),
47            headers: HashMap::new(),
48            body,
49        }
50    }
51    pub fn with_header(mut self, name: impl AsRef<str>, value: impl Into<String>) -> Self {
52        self.headers
53            .insert(name.as_ref().to_ascii_lowercase(), value.into());
54        self
55    }
56    fn get(&self, name: &str) -> Option<&str> {
57        self.headers.get(name).map(String::as_str)
58    }
59}
60
61/// A request prepared for outbound delivery — signed headers to be
62/// attached plus the body (unchanged).
63#[derive(Debug, Clone)]
64pub struct OutboundRequest {
65    pub method: String,
66    pub url: String,
67    pub headers: Vec<(String, String)>,
68    pub body: Vec<u8>,
69}
70
71/// Verified actor — the keyId plus its fetched public-key PEM. Inbox
72/// handlers use this to tie an activity to a known AP actor.
73#[derive(Debug, Clone)]
74pub struct VerifiedActor {
75    pub key_id: String,
76    pub actor_url: String,
77    pub public_key_pem: String,
78}
79
80/// Strategy for looking up an actor's public key from its `keyId`.
81///
82/// In production this is an HTTP fetch with cache (see
83/// [`HttpActorKeyResolver`]); in tests it's a simple in-memory map.
84#[async_trait]
85pub trait ActorKeyResolver: Send + Sync {
86    async fn resolve(&self, key_id: &str) -> Result<VerifiedActor, SigError>;
87}
88
89/// HTTP-backed resolver with actor-document caching. Matches
90/// JSS's `fetchActor` behaviour: GET the URL (with `#main-key` or
91/// similar fragment stripped), parse `publicKey.publicKeyPem`.
92pub struct HttpActorKeyResolver {
93    client: reqwest::Client,
94}
95
96impl Default for HttpActorKeyResolver {
97    fn default() -> Self {
98        Self {
99            client: reqwest::Client::builder()
100                .user_agent("solid-pod-rs-activitypub/0.4.0")
101                .build()
102                .expect("reqwest client builds"),
103        }
104    }
105}
106
107#[async_trait]
108impl ActorKeyResolver for HttpActorKeyResolver {
109    async fn resolve(&self, key_id: &str) -> Result<VerifiedActor, SigError> {
110        let actor_url = key_id
111            .split_once('#')
112            .map(|(u, _)| u.to_string())
113            .unwrap_or_else(|| key_id.to_string());
114        let resp = self
115            .client
116            .get(&actor_url)
117            .header(reqwest::header::ACCEPT, "application/activity+json")
118            .send()
119            .await
120            .map_err(|e| SigError::ActorFetch(actor_url.clone(), e.to_string()))?;
121        if !resp.status().is_success() {
122            return Err(SigError::ActorFetch(
123                actor_url.clone(),
124                format!("status {}", resp.status()),
125            ));
126        }
127        let doc: serde_json::Value = resp
128            .json()
129            .await
130            .map_err(|e| SigError::ActorFetch(actor_url.clone(), e.to_string()))?;
131        let pem = doc
132            .get("publicKey")
133            .and_then(|k| k.get("publicKeyPem"))
134            .and_then(|v| v.as_str())
135            .ok_or(SigError::NoPublicKey)?;
136        Ok(VerifiedActor {
137            key_id: key_id.to_string(),
138            actor_url,
139            public_key_pem: pem.to_string(),
140        })
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Signature header parsing
146// ---------------------------------------------------------------------------
147
148#[derive(Debug, Clone, Default)]
149struct SignatureHeader {
150    key_id: String,
151    algorithm: String,
152    headers: Vec<String>,
153    signature_b64: String,
154}
155
156/// Parse a `Signature:` header in draft-cavage form:
157/// `keyId="...",algorithm="...",headers="(request-target) host date digest",signature="..."`
158fn parse_signature_header(raw: &str) -> Result<SignatureHeader, SigError> {
159    let mut out = SignatureHeader::default();
160    // Attribute parser — split on commas that are outside quoted values.
161    let mut attrs: Vec<(String, String)> = Vec::new();
162    let mut cur_key = String::new();
163    let mut cur_val = String::new();
164    let mut in_val = false;
165    let mut in_quote = false;
166    let mut expecting_eq = false;
167    for ch in raw.chars() {
168        if !in_val {
169            match ch {
170                '=' => {
171                    in_val = true;
172                    expecting_eq = false;
173                }
174                ',' | ' ' | '\t' if cur_key.is_empty() => { /* skip whitespace */ }
175                c if c.is_ascii_whitespace() => {
176                    expecting_eq = true;
177                }
178                _ if expecting_eq => {
179                    // unexpected text after key with whitespace — part of next key
180                    cur_key.push(ch);
181                    expecting_eq = false;
182                }
183                _ => cur_key.push(ch),
184            }
185        } else {
186            match ch {
187                '"' => {
188                    if in_quote {
189                        // end of quoted value
190                        attrs.push((
191                            std::mem::take(&mut cur_key).to_ascii_lowercase(),
192                            std::mem::take(&mut cur_val),
193                        ));
194                        in_quote = false;
195                        in_val = false;
196                    } else {
197                        in_quote = true;
198                    }
199                }
200                ',' if !in_quote => {
201                    if !cur_key.is_empty() {
202                        attrs.push((
203                            std::mem::take(&mut cur_key).to_ascii_lowercase(),
204                            std::mem::take(&mut cur_val),
205                        ));
206                    }
207                    in_val = false;
208                }
209                _ => {
210                    if in_quote || !ch.is_ascii_whitespace() {
211                        cur_val.push(ch);
212                    }
213                }
214            }
215        }
216    }
217    if !cur_key.is_empty() && (in_val || !cur_val.is_empty()) {
218        attrs.push((cur_key.to_ascii_lowercase(), cur_val));
219    }
220
221    for (k, v) in attrs {
222        match k.as_str() {
223            "keyid" => out.key_id = v,
224            "algorithm" => out.algorithm = v.to_ascii_lowercase(),
225            "headers" => {
226                out.headers = v
227                    .split_ascii_whitespace()
228                    .map(|s| s.to_ascii_lowercase())
229                    .collect();
230            }
231            "signature" => out.signature_b64 = v,
232            _ => {}
233        }
234    }
235    if out.key_id.is_empty() {
236        return Err(SigError::MissingKeyId);
237    }
238    if out.signature_b64.is_empty() {
239        return Err(SigError::MalformedSignature("missing signature= value".into()));
240    }
241    if out.algorithm.is_empty() {
242        // Mastodon used to omit this — default to rsa-sha256 per
243        // current AP fleet behaviour.
244        out.algorithm = "rsa-sha256".to_string();
245    }
246    if out.headers.is_empty() {
247        // Default per draft-cavage §2.1.6 — Date only. AP servers
248        // should be stricter; we preserve the default for tolerance.
249        out.headers = vec!["date".to_string()];
250    }
251    Ok(out)
252}
253
254/// Rebuild the signature base string for a draft-cavage `headers="..."`
255/// list.
256fn build_signature_base(req: &SignedRequest, header_list: &[String]) -> Result<String, SigError> {
257    let mut lines = Vec::with_capacity(header_list.len());
258    for h in header_list {
259        match h.as_str() {
260            "(request-target)" => {
261                lines.push(format!(
262                    "(request-target): {} {}",
263                    req.method.to_ascii_lowercase(),
264                    req.path
265                ));
266            }
267            name => {
268                let v = req
269                    .get(name)
270                    .ok_or_else(|| SigError::VerifyFailed(format!("missing covered header: {name}")))?;
271                lines.push(format!("{name}: {v}"));
272            }
273        }
274    }
275    Ok(lines.join("\n"))
276}
277
278/// Compute the canonical `Digest: SHA-256=...` header value for a body.
279pub fn digest_header(body: &[u8]) -> String {
280    let digest = Sha256::digest(body);
281    format!("SHA-256={}", B64.encode(digest))
282}
283
284/// Verify an inbound signed request. Returns the [`VerifiedActor`] on
285/// success so the caller can tie activity processing to the signing
286/// identity.
287pub async fn verify_request_signature(
288    req: &SignedRequest,
289    resolver: &dyn ActorKeyResolver,
290) -> Result<VerifiedActor, SigError> {
291    let sig_raw = req
292        .get("signature")
293        .ok_or(SigError::MissingHeader("signature"))?;
294    let parsed = parse_signature_header(sig_raw)?;
295    if parsed.algorithm != "rsa-sha256" && parsed.algorithm != "hs2019" {
296        return Err(SigError::UnsupportedAlgorithm(parsed.algorithm));
297    }
298
299    // Digest check — if the covered set includes `digest`, the body
300    // must hash to the header's value. This is mandatory for POST.
301    if parsed.headers.iter().any(|h| h == "digest") {
302        let received = req
303            .get("digest")
304            .ok_or(SigError::MissingHeader("digest"))?;
305        let computed = digest_header(&req.body);
306        // Mastodon historically uses `SHA-256=...`; some servers use
307        // the RFC 9530 `sha-256=:<b64>:` form. Tolerate both.
308        if received != computed
309            && !received.eq_ignore_ascii_case(&computed)
310        {
311            let rfc9530 = {
312                let digest = Sha256::digest(&req.body);
313                format!("sha-256=:{}:", B64.encode(digest))
314            };
315            if received != rfc9530 {
316                return Err(SigError::DigestMismatch);
317            }
318        }
319    }
320
321    let actor = resolver.resolve(&parsed.key_id).await?;
322    let pub_key = RsaPublicKey::from_public_key_pem(&actor.public_key_pem)
323        .map_err(|e| SigError::Rsa(e.to_string()))?;
324    let vk = VerifyingKey::<Sha256>::new(pub_key);
325
326    let base = build_signature_base(req, &parsed.headers)?;
327    let sig_bytes = B64
328        .decode(parsed.signature_b64.as_bytes())
329        .map_err(|e| SigError::Base64(e.to_string()))?;
330    let sig = RsaSignature::try_from(sig_bytes.as_slice())
331        .map_err(|e| SigError::MalformedSignature(e.to_string()))?;
332    vk.verify(base.as_bytes(), &sig)
333        .map_err(|e| SigError::VerifyFailed(e.to_string()))?;
334    Ok(actor)
335}
336
337/// Sign an outbound AP delivery. The caller provides the pod's PEM
338/// private key and its published `keyId` (e.g.
339/// `https://pod.example/profile/card.jsonld#main-key`).
340///
341/// On return, `req.headers` carries `Host`, `Date`, `Digest` and
342/// `Signature`.
343pub fn sign_request(
344    req: &mut OutboundRequest,
345    private_key_pem: &str,
346    key_id: &str,
347) -> Result<(), SigError> {
348    let url = url::Url::parse(&req.url).map_err(|e| SigError::Url(e.to_string()))?;
349    let host = url
350        .host_str()
351        .ok_or_else(|| SigError::Url("url has no host".into()))?;
352    let path = if let Some(q) = url.query() {
353        format!("{}?{}", url.path(), q)
354    } else {
355        url.path().to_string()
356    };
357    let date = httpdate::fmt_http_date(std::time::SystemTime::now());
358    let digest = digest_header(&req.body);
359
360    // Covered headers and their values.
361    let covered = vec!["(request-target)", "host", "date", "digest"];
362    let mut base_lines: Vec<String> = Vec::new();
363    for h in &covered {
364        match *h {
365            "(request-target)" => base_lines.push(format!(
366                "(request-target): {} {}",
367                req.method.to_ascii_lowercase(),
368                path
369            )),
370            "host" => base_lines.push(format!("host: {host}")),
371            "date" => base_lines.push(format!("date: {date}")),
372            "digest" => base_lines.push(format!("digest: {digest}")),
373            _ => {}
374        }
375    }
376    let base = base_lines.join("\n");
377
378    let sk = RsaPrivateKey::from_pkcs8_pem(private_key_pem)
379        .map_err(|e| SigError::Rsa(e.to_string()))?;
380    let signer = SigningKey::<Sha256>::new(sk);
381    let sig: RsaSignature = signer.sign(base.as_bytes());
382    let sig_b64 = B64.encode(sig.to_bytes());
383
384    let signature_header = format!(
385        "keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{sig}\"",
386        key_id = key_id,
387        headers = covered.join(" "),
388        sig = sig_b64,
389    );
390
391    // Append canonical headers — de-dup if already set.
392    req.headers.retain(|(n, _)| {
393        let ln = n.to_ascii_lowercase();
394        ln != "host"
395            && ln != "date"
396            && ln != "digest"
397            && ln != "signature"
398    });
399    req.headers.push(("Host".to_string(), host.to_string()));
400    req.headers.push(("Date".to_string(), date));
401    req.headers.push(("Digest".to_string(), digest));
402    req.headers.push(("Signature".to_string(), signature_header));
403    Ok(())
404}
405
406// ---------------------------------------------------------------------------
407// Tests
408// ---------------------------------------------------------------------------
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use async_trait::async_trait;
414
415    struct StaticResolver {
416        pem: String,
417    }
418
419    #[async_trait]
420    impl ActorKeyResolver for StaticResolver {
421        async fn resolve(&self, key_id: &str) -> Result<VerifiedActor, SigError> {
422            Ok(VerifiedActor {
423                key_id: key_id.to_string(),
424                actor_url: key_id.trim_end_matches("#main-key").to_string(),
425                public_key_pem: self.pem.clone(),
426            })
427        }
428    }
429
430    fn fresh_keypair() -> (String, String) {
431        crate::actor::generate_actor_keypair().unwrap()
432    }
433
434    fn build_signed_inbound(
435        method: &str,
436        path: &str,
437        body: &[u8],
438        priv_pem: &str,
439        key_id: &str,
440    ) -> SignedRequest {
441        let host = "pod.example";
442        let date = httpdate::fmt_http_date(std::time::SystemTime::now());
443        let digest = digest_header(body);
444        let base = format!(
445            "(request-target): {} {}\nhost: {}\ndate: {}\ndigest: {}",
446            method.to_ascii_lowercase(),
447            path,
448            host,
449            date,
450            digest
451        );
452        let sk = RsaPrivateKey::from_pkcs8_pem(priv_pem).unwrap();
453        let signer = SigningKey::<Sha256>::new(sk);
454        let sig: RsaSignature = signer.sign(base.as_bytes());
455        let sig_b64 = B64.encode(sig.to_bytes());
456        let sig_header = format!(
457            "keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{sig_b64}\""
458        );
459
460        SignedRequest::new(method, path, body.to_vec())
461            .with_header("host", host)
462            .with_header("date", date)
463            .with_header("digest", digest)
464            .with_header("signature", sig_header)
465    }
466
467    #[test]
468    fn parse_signature_header_valid() {
469        let raw = r#"keyId="https://a.example/actor#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="ZmFrZQ==""#;
470        let parsed = parse_signature_header(raw).unwrap();
471        assert_eq!(parsed.key_id, "https://a.example/actor#main-key");
472        assert_eq!(parsed.algorithm, "rsa-sha256");
473        assert_eq!(
474            parsed.headers,
475            vec![
476                "(request-target)".to_string(),
477                "host".to_string(),
478                "date".to_string(),
479                "digest".to_string()
480            ]
481        );
482        assert_eq!(parsed.signature_b64, "ZmFrZQ==");
483    }
484
485    #[test]
486    fn parse_signature_header_rejects_missing_keyid() {
487        let raw = r#"algorithm="rsa-sha256",signature="abc""#;
488        assert!(matches!(
489            parse_signature_header(raw),
490            Err(SigError::MissingKeyId)
491        ));
492    }
493
494    #[test]
495    fn digest_header_is_mastodon_shape() {
496        let d = digest_header(b"hello");
497        assert!(d.starts_with("SHA-256="));
498    }
499
500    #[tokio::test]
501    async fn http_signature_verify_accepts_valid_request() {
502        let (priv_pem, pub_pem) = fresh_keypair();
503        let key_id = "https://remote.example/actor#main-key";
504        let req = build_signed_inbound("POST", "/inbox", b"{}", &priv_pem, key_id);
505        let resolver = StaticResolver { pem: pub_pem };
506        let actor = verify_request_signature(&req, &resolver).await.unwrap();
507        assert_eq!(actor.key_id, key_id);
508        assert_eq!(actor.actor_url, "https://remote.example/actor");
509    }
510
511    #[tokio::test]
512    async fn http_signature_verify_rejects_tampered_body() {
513        let (priv_pem, pub_pem) = fresh_keypair();
514        let key_id = "https://remote.example/actor#main-key";
515        let mut req = build_signed_inbound("POST", "/inbox", b"{}", &priv_pem, key_id);
516        // Mutate body after signing → digest mismatch.
517        req.body = b"{\"tampered\":true}".to_vec();
518        let resolver = StaticResolver { pem: pub_pem };
519        let res = verify_request_signature(&req, &resolver).await;
520        assert!(
521            matches!(res, Err(SigError::DigestMismatch)),
522            "got {res:?}"
523        );
524    }
525
526    #[tokio::test]
527    async fn http_signature_verify_rejects_wrong_key() {
528        let (priv_pem, _pub_pem) = fresh_keypair();
529        let (_, other_pub_pem) = fresh_keypair();
530        let key_id = "https://remote.example/actor#main-key";
531        let req = build_signed_inbound("POST", "/inbox", b"{}", &priv_pem, key_id);
532        let resolver = StaticResolver {
533            pem: other_pub_pem,
534        };
535        let res = verify_request_signature(&req, &resolver).await;
536        assert!(matches!(res, Err(SigError::VerifyFailed(_))));
537    }
538
539    #[tokio::test]
540    async fn http_signature_verify_roundtrips_through_sign_request() {
541        let (priv_pem, pub_pem) = fresh_keypair();
542        let key_id = "https://pod.example/profile/card.jsonld#main-key";
543        let body = br#"{"type":"Follow"}"#.to_vec();
544        let mut out = OutboundRequest {
545            method: "POST".into(),
546            url: "https://remote.example/inbox".into(),
547            headers: vec![("Content-Type".into(), "application/activity+json".into())],
548            body: body.clone(),
549        };
550        sign_request(&mut out, &priv_pem, key_id).unwrap();
551
552        // Convert to an inbound-shaped request and verify.
553        let url = url::Url::parse(&out.url).unwrap();
554        let path = url.path().to_string();
555        let mut inbound = SignedRequest::new("POST", &path, body);
556        for (k, v) in &out.headers {
557            inbound.headers.insert(k.to_ascii_lowercase(), v.clone());
558        }
559        let resolver = StaticResolver { pem: pub_pem };
560        let actor = verify_request_signature(&inbound, &resolver).await.unwrap();
561        assert_eq!(actor.key_id, key_id);
562    }
563}