Skip to main content

ojak_sig/
verification.rs

1//! Checking a signed request that arrives in an inbox.
2//!
3//! In three steps, kept apart so that each can be inspected and tested alone:
4//!
5//! 1.  [`parse`] reads the signature, draft-cavage or RFC 9421, into plain
6//!     data: which key signed, when, and what it covers.
7//! 2.  [`check`] applies the policy a signature has to meet before its key is
8//!     even fetched: it covers the request target, the host, when it was made
9//!     and, for a request with a body, the body's digest; the digest is there
10//!     and matches the body; it was made recently and has not expired; and it
11//!     was made for this server.
12//! 3.  [`verify`] checks the signature against a key the caller found.
13//!
14//! What lies between the second and third steps — fetching the key, and
15//! checking that the actor who signed is the actor the activity is from — is
16//! the caller's, since it needs the network and the application's cache.
17//! [`key_owner`] and [`published_key_pem`] are the pure halves of that.
18//!
19//! Without the second step a signature over `date` alone would authorise any
20//! body, and a request captured on its way to another server, or an hour
21//! ago, could be replayed here. `signature::verify_request` checks a digest
22//! only when one happens to be sent, and never the time or the host.
23
24use crate::digest::{content_digest_matches, digest_matches};
25use crate::integrity::{self, PublicKey};
26use crate::{rfc9421, signature};
27use core::fmt;
28use serde_json::Value;
29
30/// A request as it arrived.
31#[derive(Clone, Copy, Debug)]
32pub struct Request<'a> {
33    /// The method, in any case.
34    pub method: &'a str,
35    /// The path and query the request was made to, as received.
36    pub path_and_query: &'a str,
37    /// Every header, with lower-case names.
38    pub headers: &'a [(&'a str, &'a str)],
39    pub body: &'a [u8],
40}
41
42impl Request<'_> {
43    fn header(&self, name: &str) -> Option<&str> {
44        self.headers
45            .iter()
46            .find(|(key, _)| key.eq_ignore_ascii_case(name))
47            .map(|(_, value)| *value)
48    }
49
50    fn has_body(&self) -> bool {
51        !self.method.eq_ignore_ascii_case("get") && !self.method.eq_ignore_ascii_case("head")
52    }
53}
54
55/// What a signature has to meet.
56#[derive(Clone, Copy, Debug)]
57pub struct Policy<'a> {
58    /// The hosts this server answers to — its domain and any aliases. A
59    /// request signed for another host is refused. A host listed without a port
60    /// also accepts itself with one, which is how a server on a non-default
61    /// port is reached; one listed with a port accepts only that port.
62    pub hosts: &'a [&'a str],
63    /// How far from now a signature may have been made, in either direction.
64    pub max_skew_seconds: i64,
65}
66
67impl<'a> Policy<'a> {
68    /// The policy Mastodon applies: an hour either way.
69    #[must_use]
70    pub fn new(hosts: &'a [&'a str]) -> Self {
71        Self {
72            hosts,
73            max_skew_seconds: 3600,
74        }
75    }
76}
77
78/// Which signature scheme a request is signed with.
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum Scheme {
81    DraftCavage,
82    Rfc9421,
83}
84
85/// A signature, read but not yet checked.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct Signature {
88    pub scheme: Scheme,
89    /// The IRI of the key that made it.
90    pub key_id: String,
91    /// What it covers: header names in lower case for draft-cavage, component
92    /// names for RFC 9421.
93    pub covered: Vec<String>,
94    /// When it was made, from a signed `created` or the `Date` header, as
95    /// seconds since the Unix epoch.
96    pub created: Option<i64>,
97    /// When the signer said it stops being valid, if it said so in what it
98    /// signed.
99    pub expires: Option<i64>,
100    signature: String,
101    signature_input: Option<String>,
102}
103
104/// Why a request is refused.
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub enum Rejection {
107    /// No `Signature` header.
108    Unsigned,
109    /// A signature header that cannot be read.
110    Malformed(String),
111    /// The signature does not cover something it has to.
112    NotCovered(String),
113    /// A header the signature relies on is missing.
114    MissingHeader(&'static str),
115    /// The body is not the one the digest describes.
116    DigestMismatch,
117    /// The signature was made too far from now.
118    Stale { seconds: i64 },
119    /// The signature expired, `seconds` ago.
120    Expired { seconds: i64 },
121    /// The request was signed for another host.
122    WrongHost(String),
123    /// The signature does not verify against the key.
124    Invalid(String),
125}
126
127impl fmt::Display for Rejection {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            Self::Unsigned => f.write_str("no Signature header"),
131            Self::Malformed(why) => write!(f, "malformed signature: {why}"),
132            Self::NotCovered(what) => write!(f, "signature does not cover {what}"),
133            Self::MissingHeader(name) => write!(f, "missing {name} header"),
134            Self::DigestMismatch => f.write_str("the body does not match its digest"),
135            Self::Stale { seconds } => write!(f, "signature made {seconds}s from now"),
136            Self::Expired { seconds } => write!(f, "signature expired {seconds}s ago"),
137            Self::WrongHost(host) => write!(f, "signed for another host: {host}"),
138            Self::Invalid(why) => write!(f, "signature does not verify: {why}"),
139        }
140    }
141}
142
143impl core::error::Error for Rejection {}
144
145/// The key a signature is checked against.
146#[derive(Clone, Copy, Debug)]
147pub enum Key<'a> {
148    /// A PEM-encoded RSA public key.
149    RsaPem(&'a str),
150    /// A raw Ed25519 public key. Only RFC 9421 signatures use one.
151    Ed25519(&'a [u8; 32]),
152}
153
154/// Read the signature on `request`.
155///
156/// # Errors
157///
158/// When there is none, or it cannot be read.
159pub fn parse(request: &Request<'_>) -> Result<Signature, Rejection> {
160    let signature = request
161        .header("signature")
162        .ok_or(Rejection::Unsigned)?
163        .to_owned();
164    // A `Signature-Input` beside it means RFC 9421: the two schemes share the
165    // `Signature` field name and nothing else.
166    if let Some(input) = request.header("signature-input") {
167        let key_id = rfc9421::key_id(input)
168            .ok_or_else(|| Rejection::Malformed("no keyid in Signature-Input".into()))?;
169        let covered = rfc9421::covered_components(input)
170            .ok_or_else(|| Rejection::Malformed("unreadable Signature-Input".into()))?;
171        return Ok(Signature {
172            scheme: Scheme::Rfc9421,
173            key_id,
174            covered,
175            created: rfc9421::created_at(input),
176            expires: rfc9421::expires_at(input),
177            signature,
178            signature_input: Some(input.to_owned()),
179        });
180    }
181
182    let params = signature::parse_params(&signature);
183    let key_id = params
184        .get("keyId")
185        .cloned()
186        .ok_or_else(|| Rejection::Malformed("no keyId".into()))?;
187    let covered: Vec<String> = params
188        .get("headers")
189        .map_or("date", String::as_str)
190        .split_whitespace()
191        .map(str::to_ascii_lowercase)
192        .collect();
193    // The `created` parameter is signed only when `(created)` is covered;
194    // otherwise anyone relaying a captured request could set it to now, and
195    // the time the signature vouches for is the `Date` header's.
196    let created = if covered.iter().any(|name| name == "(created)") {
197        Some(
198            params
199                .get("created")
200                .ok_or_else(|| Rejection::Malformed("(created) covered but not given".into()))?
201                .parse()
202                .map_err(|_| Rejection::Malformed("unreadable created".into()))?,
203        )
204    } else {
205        request.header("date").and_then(http_date)
206    };
207    let expires = if covered.iter().any(|name| name == "(expires)") {
208        Some(
209            params
210                .get("expires")
211                .ok_or_else(|| Rejection::Malformed("(expires) covered but not given".into()))?
212                .parse()
213                .map_err(|_| Rejection::Malformed("unreadable expires".into()))?,
214        )
215    } else {
216        None
217    };
218    Ok(Signature {
219        scheme: Scheme::DraftCavage,
220        key_id,
221        covered,
222        created,
223        expires,
224        signature,
225        signature_input: None,
226    })
227}
228
229/// Apply `policy` to `signature` on `request`, `now` being seconds since the
230/// Unix epoch.
231///
232/// # Errors
233///
234/// With the first rule the signature breaks.
235pub fn check(
236    signature: &Signature,
237    request: &Request<'_>,
238    policy: &Policy<'_>,
239    now: i64,
240) -> Result<(), Rejection> {
241    let covers = |name: &str| signature.covered.iter().any(|covered| covered == name);
242    let require = |name: &str| {
243        if covers(name) {
244            Ok(())
245        } else {
246            Err(Rejection::NotCovered(name.to_owned()))
247        }
248    };
249    match signature.scheme {
250        Scheme::DraftCavage => {
251            require("(request-target)")?;
252            require("host")?;
253            if !covers("date") && !covers("(created)") {
254                return Err(Rejection::NotCovered("date".into()));
255            }
256            if request.has_body() {
257                require("digest")?;
258                let digest = request
259                    .header("digest")
260                    .ok_or(Rejection::MissingHeader("Digest"))?;
261                if !digest_matches(digest, request.body) {
262                    return Err(Rejection::DigestMismatch);
263                }
264            }
265        }
266        Scheme::Rfc9421 => {
267            require("@method")?;
268            require("@target-uri")?;
269            if request.has_body() {
270                require("content-digest")?;
271                let digest = request
272                    .header("content-digest")
273                    .ok_or(Rejection::MissingHeader("Content-Digest"))?;
274                if !content_digest_matches(digest, request.body) {
275                    return Err(Rejection::DigestMismatch);
276                }
277            }
278        }
279    }
280
281    let created = signature.created.ok_or(Rejection::MissingHeader("Date"))?;
282    let seconds = created - now;
283    if seconds.abs() > policy.max_skew_seconds {
284        return Err(Rejection::Stale { seconds });
285    }
286    // The skew allowed is for clocks that disagree, not for outliving what
287    // the signer allowed.
288    if let Some(expires) = signature.expires
289        && expires < now
290    {
291        return Err(Rejection::Expired {
292            seconds: now - expires,
293        });
294    }
295
296    let host = request
297        .header("host")
298        .ok_or(Rejection::MissingHeader("Host"))?;
299    if !policy
300        .hosts
301        .iter()
302        .any(|accepted| host_matches(accepted, host))
303    {
304        return Err(Rejection::WrongHost(host.to_owned()));
305    }
306    Ok(())
307}
308
309/// Whether a `Host` header names `accepted`: exactly, or with a port when
310/// `accepted` has none. Both are compared without case or a trailing dot.
311fn host_matches(accepted: &str, host: &str) -> bool {
312    let normalise = |value: &str| value.trim().trim_end_matches('.').to_ascii_lowercase();
313    let (accepted, host) = (normalise(accepted), normalise(host));
314    if accepted == host {
315        return true;
316    }
317    if split_port(&accepted).is_some() {
318        return false;
319    }
320    split_port(&host).is_some_and(|name| name.trim_end_matches('.') == accepted)
321}
322
323/// The host part of `host:port`, when there is a numeric port.
324fn split_port(value: &str) -> Option<&str> {
325    let (name, port) = value.rsplit_once(':')?;
326    // A bare IPv6 address is all colons; only `[…]:port` has a port.
327    if name.contains(':') && !name.ends_with(']') {
328        return None;
329    }
330    (!port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())).then_some(name)
331}
332
333/// Check `signature` on `request` against `key`.
334///
335/// # Errors
336///
337/// When it does not verify, or `key` is not a kind the scheme can use.
338pub fn verify(signature: &Signature, request: &Request<'_>, key: Key<'_>) -> Result<(), Rejection> {
339    match signature.scheme {
340        Scheme::DraftCavage => {
341            let Key::RsaPem(pem) = key else {
342                return Err(Rejection::Invalid(
343                    "a draft-cavage signature is checked with an RSA key".into(),
344                ));
345            };
346            signature::verify_request(
347                &request.method.to_ascii_lowercase(),
348                request.path_and_query,
349                request.headers,
350                request.body,
351                pem,
352            )
353            .map_err(|error| Rejection::Invalid(error.to_string()))
354        }
355        Scheme::Rfc9421 => {
356            let host = request
357                .header("host")
358                .ok_or(Rejection::MissingHeader("Host"))?;
359            // The sender signed the URL it addressed, and federation always
360            // reaches an inbox over https, however this process is fronted.
361            let target = format!("https://{host}{}", request.path_and_query);
362            let key = match key {
363                Key::RsaPem(pem) => rfc9421::VerifyingKey::RsaPem(pem),
364                Key::Ed25519(bytes) => rfc9421::VerifyingKey::Ed25519(bytes),
365            };
366            rfc9421::verify_request(
367                request.method,
368                &target,
369                signature.signature_input.as_deref().unwrap_or_default(),
370                &signature.signature,
371                request.header("content-digest"),
372                request.body,
373                request.headers,
374                &key,
375            )
376            .map_err(|error| Rejection::Invalid(error.to_string()))
377        }
378    }
379}
380
381/// The actor a key ID most likely belongs to: the key ID without its
382/// fragment, or without GoToSocial's `/main-key` path, which is where the key
383/// is fetched from and whose owner is then checked.
384#[must_use]
385pub fn key_owner(key_id: &str) -> &str {
386    if let Some((owner, _)) = key_id.split_once('#') {
387        return owner;
388    }
389    key_id.strip_suffix("/main-key").unwrap_or(key_id)
390}
391
392/// The PEM of `key_id` if `actor` publishes it as its own key: a `publicKey`
393/// whose `id` is `key_id` and whose `owner`, if it names one, is the actor.
394///
395/// This is the other half of the claim a key ID makes. The key ID says which
396/// actor it belongs to; the actor has to say so too, or anyone who can put a
397/// key document on a server could sign as any actor there.
398///
399/// PeerTube signs with its actor's `id` for the key ID while publishing the
400/// key as `#main-key`; a key ID that is the actor's own `id` names that key.
401#[must_use]
402pub fn published_key_pem(actor: &Value, key_id: &str) -> Option<String> {
403    let actor_id = actor.get("id").and_then(Value::as_str)?;
404    let keys: Vec<&Value> = match actor.get("publicKey")? {
405        Value::Array(keys) => keys.iter().collect(),
406        key => vec![key],
407    };
408    keys.into_iter().find_map(|key| {
409        let id = key.get("id").and_then(Value::as_str)?;
410        let owner = key.get("owner").and_then(Value::as_str);
411        let named =
412            id == key_id || (key_id == actor_id && id.strip_prefix(actor_id) == Some("#main-key"));
413        (named && owner.is_none_or(|owner| owner == actor_id))
414            .then(|| key.get("publicKeyPem").and_then(Value::as_str))
415            .flatten()
416            .map(str::to_owned)
417    })
418}
419
420/// A key an actor publishes, as a signature is checked against it.
421#[derive(Clone, Debug, Eq, PartialEq)]
422pub enum PublishedKey {
423    /// A PEM-encoded RSA public key, from `publicKey`.
424    RsaPem(String),
425    /// A raw Ed25519 public key, from an `assertionMethod` Multikey.
426    Ed25519([u8; 32]),
427}
428
429impl PublishedKey {
430    /// The key, as [`verify`] takes it.
431    #[must_use]
432    pub fn as_key(&self) -> Key<'_> {
433        match self {
434            Self::RsaPem(pem) => Key::RsaPem(pem),
435            Self::Ed25519(bytes) => Key::Ed25519(bytes),
436        }
437    }
438}
439
440/// `key_id` if `actor` publishes it as its own key: as [`published_key_pem`]
441/// reads a `publicKey`, or an Ed25519 `assertionMethod` Multikey (FEP-521a)
442/// whose `id` is `key_id` and whose `controller` is the actor.
443#[must_use]
444pub fn published_key(actor: &Value, key_id: &str) -> Option<PublishedKey> {
445    if let Some(pem) = published_key_pem(actor, key_id) {
446        return Some(PublishedKey::RsaPem(pem));
447    }
448    let actor_id = actor.get("id").and_then(Value::as_str)?;
449    let methods: Vec<&Value> = match actor.get("assertionMethod")? {
450        Value::Array(methods) => methods.iter().collect(),
451        method => vec![method],
452    };
453    methods.into_iter().find_map(|method| {
454        if method.get("id").and_then(Value::as_str) != Some(key_id)
455            || method.get("controller").and_then(Value::as_str) != Some(actor_id)
456        {
457            return None;
458        }
459        let multibase = method.get("publicKeyMultibase")?.as_str()?;
460        match integrity::decode_multikey(multibase).ok()? {
461            PublicKey::Ed25519(bytes) => Some(PublishedKey::Ed25519(*bytes)),
462            PublicKey::MlDsa44(_) => None,
463        }
464    })
465}
466
467/// Parse an HTTP-date (RFC 9110), as a `Date` header carries.
468fn http_date(value: &str) -> Option<i64> {
469    let value = value.trim();
470    chrono::NaiveDateTime::parse_from_str(value, "%a, %d %b %Y %H:%M:%S GMT")
471        .map(|date| date.and_utc().timestamp())
472        .or_else(|_| chrono::DateTime::parse_from_rfc2822(value).map(|date| date.timestamp()))
473        .ok()
474}
475
476#[cfg(test)]
477mod tests {
478    use super::host_matches;
479
480    #[test]
481    fn a_host_is_matched_with_or_without_its_port() {
482        assert!(host_matches("eunha.example", "eunha.example"));
483        assert!(host_matches("eunha.example", "EUNHA.example."));
484        assert!(host_matches("eunha.example", "eunha.example:8443"));
485        assert!(host_matches("localhost:3000", "localhost:3000"));
486        assert!(host_matches("[::1]", "[::1]:3000"));
487        assert!(!host_matches("localhost:3000", "localhost:4000"));
488        assert!(!host_matches("localhost:3000", "localhost"));
489        assert!(!host_matches("eunha.example", "other.example"));
490        assert!(!host_matches("eunha.example", "eunha.example.evil.example"));
491        assert!(!host_matches("eunha.example", "eunha.example:"));
492        assert!(!host_matches("eunha.example", "eunha.example:https"));
493    }
494}