web_bot_auth/
lib.rs

1#![deny(missing_docs)]
2// Copyright (c) 2025 Cloudflare, Inc.
3// Licensed under the Apache 2.0 license found in the LICENSE file or at:
4//     https://opensource.org/licenses/Apache-2.0
5
6//! # web-bot-auth library
7//!
8//! `web-bot-auth` is a library provides a Rust implementation of HTTP Message Signatures as defined in
9//! [RFC 9421](https://datatracker.ietf.org/doc/html/rfc9421), with additional support
10//! for verifying a web bot auth signed message.
11//!
12//! ## Features
13//!
14//! - **Message Signing**: Generate HTTP message signatures using Ed25519 cryptography
15//! - **Message Verification**: Verify signed HTTP messages against public keys
16//! - **Web Bot Auth**: Specialized verification for automated agents with additional security requirements
17/// HTTP message components that can be present in a given signed / unsigned message, and all the logic
18/// to parse it from an incoming message.
19pub mod components;
20
21/// Implementation of a JSON Web Key manager suitable for use with `web-bot-auth`. Can be used for arbitrary
22/// HTTP message signatures as well.
23pub mod keyring;
24
25/// Implementation of HTTP Message Signatures
26pub mod message_signatures;
27
28use components::CoveredComponent;
29use message_signatures::{MessageVerifier, ParsedLabel, SignatureTiming, SignedMessage};
30
31use data_url::DataUrl;
32use keyring::{Algorithm, JSONWebKeySet, KeyRing};
33use std::time::SystemTimeError;
34
35/// Errors that may be thrown by this module.
36#[derive(Debug)]
37pub enum ImplementationError {
38    /// Errors that arise from invalid conversions from
39    /// parsed structs back into structured field values,
40    /// nominally "impossible" because the structs are already
41    /// in a valid state.
42    ImpossibleSfvError(sfv::Error),
43    /// Errors that arise from conversions of structured field
44    /// values into parsed structs, with an explanation of what
45    /// wrong.
46    ParsingError(String),
47    /// Errors raised when trying to get the value of a covered
48    /// component fails from a `SignedMessage` or `UnsignedMessage`,
49    /// likely because the message did not contain the value.
50    LookupError(CoveredComponent),
51    /// Errors raised when an incoming message references an algorithm
52    /// that isn't currently supported by this implementation. The subset
53    /// of [registered IANA signature algorithms](https://www.iana.org/assignments/http-message-signature/http-message-signature.xhtml)
54    /// implemented here is provided by `Algorithms` struct.
55    UnsupportedAlgorithm(Algorithm),
56    /// An attempt to resolve key identifier to a valid public key failed.
57    /// This prevents message verification.
58    NoSuchKey,
59    /// The resolved key ID did not have the sufficient length to be parsed as
60    /// a valid key for the algorithm chosen.
61    InvalidKeyLength,
62    /// The signature provided in `Signature` header was not long enough to be
63    /// a valid signature for the algorithm chosen.
64    InvalidSignatureLength,
65    /// Verification of a parsed signature against a resolved key failed, indicating
66    /// the signature was invalid.
67    FailedToVerify(ed25519_dalek::SignatureError),
68    /// A valid signature base must contain only ASCII characters; this error is thrown
69    /// if that's not the case. This may be thrown if some of the headers included in
70    /// covered components contained non-ASCII characters, for example. This will be thrown
71    /// during both signing and verification, as both steps require constructing the signature
72    /// base.
73    NonAsciiContentFound,
74    /// Signature bases are terminated with a line beginning with `@signature-params`. This error
75    /// is thrown if the value of that line could not be converted into a structured field value.
76    /// This is considered "impossible" as invalid values should not be present in the structure
77    /// containing those values.
78    SignatureParamsSerialization,
79    /// Verification of `created` or `expires` component parameter requires use of a system clock.
80    /// This error is thrown if the system clock is configured in ways that prevent adequate time
81    /// resolution, such as the clock believes the start of Unix time is in the future.
82    TimeError(SystemTimeError),
83    /// A wrapper around `WebBotAuthError`
84    WebBotAuth(WebBotAuthError),
85}
86
87/// Errors thrown when verifying a Web Bot Auth-signed message specifically.
88#[derive(Debug)]
89pub enum WebBotAuthError {
90    /// Thrown when the signature is detected to be expired, using the `expires`
91    /// and `creates` method.
92    SignatureIsExpired,
93}
94/// A trait that messages wishing to be verified as a `web-bot-auth` method specifically
95/// must implement.
96pub trait WebBotAuthSignedMessage: SignedMessage {
97    /// Obtain every `Signature-Agent` header in the message. Despite the name, you can omit
98    /// `Signature-Agents` that are known to be invalid ahead of time. However, each `Signature-Agent`
99    /// header must be unparsed and a be a valid sfv::Item::String value (meaning it should be encased
100    /// in double quotes). You should separately implement looking this up in `SignedMessage::lookup_component`
101    /// as an HTTP header with multiple values.
102    fn fetch_all_signature_agents(&self) -> Vec<String>;
103}
104
105/// A verifier for Web Bot Auth messages specifically.
106#[derive(Clone, Debug)]
107pub struct WebBotAuthVerifier {
108    message_verifier: MessageVerifier,
109    /// List of valid `Signature-Agent` headers to try, if present.
110    parsed_directories: Vec<SignatureAgentLink>,
111}
112
113/// The different types of URLs a `Signature-Agent` can have.
114#[derive(Eq, PartialEq, Debug, Clone)]
115pub enum SignatureAgentLink {
116    /// A data URL that was parsed into a JSON Web Key Set ahead of time.
117    Inline(JSONWebKeySet),
118    /// An external https:// or http:// URL that requires resolution into a JSON Web Key Set
119    External(String),
120}
121
122impl WebBotAuthVerifier {
123    /// Parse a message into a structure that is ready for verification against an
124    /// external key with a suitable algorithm. If `alg` is not set, a default will
125    /// be chosen from the `alg` parameter.
126    ///
127    /// # Errors
128    ///
129    /// Returns `ImplementationErrors` relevant to verifying and parsing.
130    pub fn parse(message: &impl WebBotAuthSignedMessage) -> Result<Self, ImplementationError> {
131        let signature_agents = message.fetch_all_signature_agents();
132        let web_bot_auth_verifier = Self {
133            message_verifier: MessageVerifier::parse(message, |(_, innerlist)| {
134                innerlist.params.contains_key("keyid")
135                    && innerlist.params.contains_key("tag")
136                    && innerlist.params.contains_key("expires")
137                    && innerlist.params.contains_key("created")
138                    && innerlist
139                        .params
140                        .get("tag")
141                        .and_then(|tag| tag.as_string())
142                        .is_some_and(|tag| tag.as_str() == "web-bot-auth")
143                    && innerlist
144                        .items
145                        .iter()
146                        .any(|item| *item == sfv::Item::new(sfv::StringRef::constant("@authority")))
147                    && (if !signature_agents.is_empty() {
148                        innerlist.items.iter().any(|item| {
149                            *item == sfv::Item::new(sfv::StringRef::constant("signature-agent"))
150                        })
151                    } else {
152                        true
153                    })
154            })?,
155            parsed_directories: signature_agents
156                .iter()
157                .map(|header| {
158                    sfv::Parser::new(header).parse_item().map_err(|e| {
159                        ImplementationError::ParsingError(format!(
160                            "Failed to parse `Signature-Agent` into valid sfv::Item: {e}"
161                        ))
162                    })
163                })
164                .collect::<Result<Vec<_>, _>>()?
165                .iter()
166                .flat_map(|item| {
167                    let as_string = item.bare_item.as_string();
168                    as_string.and_then(|link| {
169                        let link_str = link.as_str();
170                        if link_str.starts_with("https://") || link_str.starts_with("http://") {
171                            return Some(SignatureAgentLink::External(String::from(link_str)));
172                        }
173
174                        if let Ok(url) = DataUrl::process(link_str) {
175                            let mediatype = url.mime_type();
176                            if mediatype.type_ == "application"
177                                && mediatype.subtype == "http-message-signatures-directory"
178                            {
179                                if let Ok((body, _)) = url.decode_to_vec() {
180                                    if let Ok(jwks) = serde_json::from_slice::<JSONWebKeySet>(&body)
181                                    {
182                                        return Some(SignatureAgentLink::Inline(jwks));
183                                    }
184                                }
185                            }
186                        }
187
188                        None
189                    })
190                })
191                .collect(),
192        };
193
194        Ok(web_bot_auth_verifier)
195    }
196
197    /// Obtain list of Signature-Agents parsed and ready. This method is ideal for populating
198    /// a keyring ahead of time at your discretion.
199    pub fn get_signature_agents(&self) -> &Vec<SignatureAgentLink> {
200        &self.parsed_directories
201    }
202
203    /// Verify the messsage, consuming the verifier in the process.
204    /// If `key_id` is not supplied, a key ID to fetch the public key
205    /// from `keyring` will be sourced from the `keyid` parameter
206    /// within the message.
207    pub fn verify(
208        self,
209        keyring: &KeyRing,
210        key_id: Option<String>,
211    ) -> Result<SignatureTiming, ImplementationError> {
212        self.message_verifier.verify(keyring, key_id)
213    }
214
215    /// Retrieve the contents of the chosen signature and signature input label for
216    /// verification.
217    pub fn get_parsed_label(&self) -> &ParsedLabel {
218        &self.message_verifier.parsed
219    }
220}
221
222#[cfg(test)]
223mod tests {
224
225    use components::DerivedComponent;
226    use indexmap::IndexMap;
227
228    use super::*;
229
230    struct StandardTestVector {}
231
232    impl SignedMessage for StandardTestVector {
233        fn fetch_all_signature_headers(&self) -> Vec<String> {
234            vec!["sig1=:uz2SAv+VIemw+Oo890bhYh6Xf5qZdLUgv6/PbiQfCFXcX/vt1A8Pf7OcgL2yUDUYXFtffNpkEr5W6dldqFrkDg==:".to_owned()]
235        }
236        fn fetch_all_signature_inputs(&self) -> Vec<String> {
237            vec![r#"sig1=("@authority");created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";expires=1735693200;nonce="gubxywVx7hzbYKatLgzuKDllDAIXAkz41PydU7aOY7vT+Mb3GJNxW0qD4zJ+IOQ1NVtg+BNbTCRUMt1Ojr5BgA==";tag="web-bot-auth""#.to_owned()]
238        }
239        fn lookup_component(&self, name: &CoveredComponent) -> Option<String> {
240            match *name {
241                CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
242                    Some("example.com".to_string())
243                }
244                _ => None,
245            }
246        }
247    }
248
249    impl WebBotAuthSignedMessage for StandardTestVector {
250        fn fetch_all_signature_agents(&self) -> Vec<String> {
251            vec![]
252        }
253    }
254
255    #[test]
256    fn test_verifying_as_web_bot_auth() {
257        let test = StandardTestVector {};
258        let public_key: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = [
259            0x26, 0xb4, 0x0b, 0x8f, 0x93, 0xff, 0xf3, 0xd8, 0x97, 0x11, 0x2f, 0x7e, 0xbc, 0x58,
260            0x2b, 0x23, 0x2d, 0xbd, 0x72, 0x51, 0x7d, 0x08, 0x2f, 0xe8, 0x3c, 0xfb, 0x30, 0xdd,
261            0xce, 0x43, 0xd1, 0xbb,
262        ];
263        let mut keyring = KeyRing::default();
264        keyring.import_raw(
265            "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".to_string(),
266            Algorithm::Ed25519,
267            public_key.to_vec(),
268        );
269        let verifier = WebBotAuthVerifier::parse(&test).unwrap();
270        let advisory = verifier
271            .get_parsed_label()
272            .base
273            .parameters
274            .details
275            .possibly_insecure(|_| false);
276        // Since the expiry date is in the past.
277        assert!(advisory.is_expired.unwrap_or(true));
278        assert!(!advisory.nonce_is_invalid.unwrap_or(true));
279        let timing = verifier.verify(&keyring, None).unwrap();
280        assert!(timing.generation.as_nanos() > 0);
281        assert!(timing.verification.as_nanos() > 0);
282    }
283
284    #[test]
285    fn test_signing_then_verifying() {
286        struct MyTest {
287            signature_input: String,
288            signature_header: String,
289        }
290
291        impl message_signatures::UnsignedMessage for MyTest {
292            fn fetch_components_to_cover(&self) -> IndexMap<CoveredComponent, String> {
293                IndexMap::from_iter([(
294                    CoveredComponent::Derived(DerivedComponent::Authority { req: false }),
295                    "example.com".to_string(),
296                )])
297            }
298
299            fn register_header_contents(
300                &mut self,
301                signature_input: String,
302                signature_header: String,
303            ) {
304                self.signature_input = format!("sig1={signature_input}");
305                self.signature_header = format!("sig1={signature_header}");
306            }
307        }
308
309        impl SignedMessage for MyTest {
310            fn fetch_all_signature_headers(&self) -> Vec<String> {
311                vec![self.signature_header.clone()]
312            }
313            fn fetch_all_signature_inputs(&self) -> Vec<String> {
314                vec![self.signature_input.clone()]
315            }
316            fn lookup_component(&self, name: &CoveredComponent) -> Option<String> {
317                match *name {
318                    CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
319                        Some("example.com".to_string())
320                    }
321                    _ => None,
322                }
323            }
324        }
325
326        impl WebBotAuthSignedMessage for MyTest {
327            fn fetch_all_signature_agents(&self) -> Vec<String> {
328                vec![]
329            }
330        }
331
332        let public_key: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = [
333            0x26, 0xb4, 0x0b, 0x8f, 0x93, 0xff, 0xf3, 0xd8, 0x97, 0x11, 0x2f, 0x7e, 0xbc, 0x58,
334            0x2b, 0x23, 0x2d, 0xbd, 0x72, 0x51, 0x7d, 0x08, 0x2f, 0xe8, 0x3c, 0xfb, 0x30, 0xdd,
335            0xce, 0x43, 0xd1, 0xbb,
336        ];
337
338        let private_key: [u8; ed25519_dalek::SECRET_KEY_LENGTH] = [
339            0x9f, 0x83, 0x62, 0xf8, 0x7a, 0x48, 0x4a, 0x95, 0x4e, 0x6e, 0x74, 0x0c, 0x5b, 0x4c,
340            0x0e, 0x84, 0x22, 0x91, 0x39, 0xa2, 0x0a, 0xa8, 0xab, 0x56, 0xff, 0x66, 0x58, 0x6f,
341            0x6a, 0x7d, 0x29, 0xc5,
342        ];
343
344        let mut keyring = KeyRing::default();
345        keyring.import_raw(
346            "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".to_string(),
347            Algorithm::Ed25519,
348            public_key.to_vec(),
349        );
350
351        let signer = message_signatures::MessageSigner {
352            keyid: "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".into(),
353            nonce: "end-to-end-test".into(),
354            tag: "web-bot-auth".into(),
355        };
356
357        let mut mytest = MyTest {
358            signature_input: String::new(),
359            signature_header: String::new(),
360        };
361
362        signer
363            .generate_signature_headers_content(
364                &mut mytest,
365                std::time::Duration::from_secs(10),
366                Algorithm::Ed25519,
367                &private_key.to_vec(),
368            )
369            .unwrap();
370
371        let verifier = WebBotAuthVerifier::parse(&mytest).unwrap();
372        let advisory = verifier
373            .get_parsed_label()
374            .base
375            .parameters
376            .details
377            .possibly_insecure(|_| false);
378        assert!(!advisory.is_expired.unwrap_or(true));
379        assert!(!advisory.nonce_is_invalid.unwrap_or(true));
380
381        let timing = verifier.verify(&keyring, None).unwrap();
382        assert!(timing.generation.as_nanos() > 0);
383        assert!(timing.verification.as_nanos() > 0);
384    }
385
386    #[test]
387    fn test_missing_tags_break_web_bot_auth() {
388        struct MissingParametersTestVector {}
389
390        impl SignedMessage for MissingParametersTestVector {
391            fn fetch_all_signature_headers(&self) -> Vec<String> {
392                vec![
393                    "sig1=:uz2SAv+VIemw+Oo890bhYh6Xf5qZdLUgv6/PbiQfCFXcX/vt1A8Pf7OcgL2yUDUYXFtffNpkEr5W6dldqFrkDg==:".to_owned()
394                ]
395            }
396            fn fetch_all_signature_inputs(&self) -> Vec<String> {
397                vec![r#"sig1=("@authority");created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";expires=1735693200;nonce="gubxywVx7hzbYKatLgzuKDllDAIXAkz41PydU7aOY7vT+Mb3GJNxW0qD4zJ+IOQ1NVtg+BNbTCRUMt1Ojr5BgA==";tag="not-web-bot-auth""#.to_owned()]
398            }
399            fn lookup_component(&self, name: &CoveredComponent) -> Option<String> {
400                match *name {
401                    CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
402                        Some("example.com".to_string())
403                    }
404                    _ => None,
405                }
406            }
407        }
408
409        impl WebBotAuthSignedMessage for MissingParametersTestVector {
410            fn fetch_all_signature_agents(&self) -> Vec<String> {
411                vec![]
412            }
413        }
414
415        let test = MissingParametersTestVector {};
416        WebBotAuthVerifier::parse(&test).expect_err("This should not have parsed");
417    }
418
419    #[test]
420    fn test_signature_agents_are_required_in_signature_input() {
421        struct MissingParametersTestVector {}
422
423        impl SignedMessage for MissingParametersTestVector {
424            fn fetch_all_signature_headers(&self) -> Vec<String> {
425                vec!["sig1=:uz2SAv+VIemw+Oo890bhYh6Xf5qZdLUgv6/PbiQfCFXcX/vt1A8Pf7OcgL2yUDUYXFtffNpkEr5W6dldqFrkDg==:".to_owned()]
426            }
427            fn fetch_all_signature_inputs(&self) -> Vec<String> {
428                vec![r#"sig1=("@authority");created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";expires=1735693200;nonce="gubxywVx7hzbYKatLgzuKDllDAIXAkz41PydU7aOY7vT+Mb3GJNxW0qD4zJ+IOQ1NVtg+BNbTCRUMt1Ojr5BgA==";tag="web-bot-auth""#.to_owned()]
429            }
430            fn lookup_component(&self, name: &CoveredComponent) -> Option<String> {
431                match *name {
432                    CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
433                        Some("example.com".to_string())
434                    }
435                    _ => None,
436                }
437            }
438        }
439
440        impl WebBotAuthSignedMessage for MissingParametersTestVector {
441            fn fetch_all_signature_agents(&self) -> Vec<String> {
442                vec![String::from("\"https://myexample.com\"")]
443            }
444        }
445
446        let test = MissingParametersTestVector {};
447        WebBotAuthVerifier::parse(&test).expect_err("This should not have parsed");
448    }
449
450    #[test]
451    fn test_signature_agents_are_parsed_correctly() {
452        struct StandardTestVector {}
453
454        impl SignedMessage for StandardTestVector {
455            fn fetch_all_signature_headers(&self) -> Vec<String> {
456                vec!["sig1=:3q7S1TtbrFhQhpcZ1gZwHPCFHTvdKXNY1xngkp6lyaqqqv3QZupwpu/wQG5a7qybnrj2vZYMeVKuWepm+rNkDw==:".to_owned()]
457            }
458            fn fetch_all_signature_inputs(&self) -> Vec<String> {
459                vec![r#"sig1=("@authority" "signature-agent");alg="ed25519";keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";nonce="ZO3/XMEZjrvSnLtAP9M7jK0WGQf3J+pbmQRUpKDhF9/jsNCWqUh2sq+TH4WTX3/GpNoSZUa8eNWMKqxWp2/c2g==";tag="web-bot-auth";created=1749331474;expires=1749331484"#.to_owned()]
460            }
461            fn lookup_component(&self, name: &CoveredComponent) -> Option<String> {
462                match name {
463                    CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
464                        Some("example.com".to_string())
465                    }
466                    CoveredComponent::HTTP(components::HTTPField { name, .. }) => {
467                        if name == "signature-agent" {
468                            return Some(String::from("\"https://myexample.com\""));
469                        }
470                        None
471                    }
472                    _ => None,
473                }
474            }
475        }
476
477        impl WebBotAuthSignedMessage for StandardTestVector {
478            fn fetch_all_signature_agents(&self) -> Vec<String> {
479                vec![String::from("\"https://myexample.com\"")]
480            }
481        }
482
483        let public_key: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = [
484            0x26, 0xb4, 0x0b, 0x8f, 0x93, 0xff, 0xf3, 0xd8, 0x97, 0x11, 0x2f, 0x7e, 0xbc, 0x58,
485            0x2b, 0x23, 0x2d, 0xbd, 0x72, 0x51, 0x7d, 0x08, 0x2f, 0xe8, 0x3c, 0xfb, 0x30, 0xdd,
486            0xce, 0x43, 0xd1, 0xbb,
487        ];
488        let mut keyring = KeyRing::default();
489        keyring.import_raw(
490            "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".to_string(),
491            Algorithm::Ed25519,
492            public_key.to_vec(),
493        );
494
495        let test = StandardTestVector {};
496        let verifier = WebBotAuthVerifier::parse(&test).unwrap();
497        let timing = verifier.verify(&keyring, None).unwrap();
498        assert!(timing.generation.as_nanos() > 0);
499        assert!(timing.verification.as_nanos() > 0);
500    }
501}