Skip to main content

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};
33
34use crate::components::{HTTPField, HTTPFieldParameters};
35
36/// Errors that may be thrown by this module.
37#[derive(Debug)]
38pub enum ImplementationError {
39    /// Errors that arise from invalid conversions from
40    /// parsed structs back into structured field values,
41    /// nominally "impossible" because the structs are already
42    /// in a valid state.
43    ImpossibleSfvError(sfv::Error),
44    /// Errors that arise from conversions of structured field
45    /// values into parsed structs, with an explanation of what
46    /// wrong.
47    ParsingError(String),
48    /// Errors raised when trying to get the value of a covered
49    /// component fails from a `SignedMessage` or `UnsignedMessage`,
50    /// likely because the message did not contain the value.
51    LookupError(CoveredComponent),
52    /// Errors raised when an incoming message references an algorithm
53    /// that isn't currently supported by this implementation. The subset
54    /// of [registered IANA signature algorithms](https://www.iana.org/assignments/http-message-signature/http-message-signature.xhtml)
55    /// implemented here is provided by `Algorithms` struct.
56    UnsupportedAlgorithm(Algorithm),
57    /// An attempt to resolve key identifier to a valid public key failed.
58    /// This prevents message verification.
59    NoSuchKey,
60    /// The resolved key ID did not have the sufficient length to be parsed as
61    /// a valid key for the algorithm chosen.
62    InvalidKeyLength,
63    /// The signature provided in `Signature` header was not long enough to be
64    /// a valid signature for the algorithm chosen.
65    InvalidSignatureLength,
66    /// Verification of a parsed signature against a resolved key failed, indicating
67    /// the signature was invalid.
68    FailedToVerify(ed25519_dalek::SignatureError),
69    /// A valid signature base must contain only ASCII characters; this error is thrown
70    /// if that's not the case. This may be thrown if some of the headers included in
71    /// covered components contained non-ASCII characters, for example. This will be thrown
72    /// during both signing and verification, as both steps require constructing the signature
73    /// base.
74    NonAsciiContentFound,
75    /// Signature bases are terminated with a line beginning with `@signature-params`. This error
76    /// is thrown if the value of that line could not be converted into a structured field value.
77    /// This is considered "impossible" as invalid values should not be present in the structure
78    /// containing those values.
79    SignatureParamsSerialization,
80    /// A wrapper around `WebBotAuthError`
81    WebBotAuth(WebBotAuthError),
82}
83
84/// Errors thrown when verifying a Web Bot Auth-signed message specifically.
85#[derive(Debug)]
86pub enum WebBotAuthError {
87    /// Thrown when the signature is detected to be expired, using the `expires`
88    /// and `creates` method.
89    SignatureIsExpired,
90}
91
92/// A verifier for Web Bot Auth messages specifically.
93#[derive(Clone, Debug)]
94pub struct WebBotAuthVerifier {
95    message_verifier: MessageVerifier,
96    /// List of valid `Signature-Agent` headers to try, if present.
97    parsed_directories: Vec<SignatureAgentLink>,
98}
99
100/// The different types of URLs a `Signature-Agent` can have.
101#[derive(Eq, PartialEq, Debug, Clone)]
102pub enum SignatureAgentLink {
103    /// A data URL that was parsed into a JSON Web Key Set ahead of time.
104    Inline(JSONWebKeySet),
105    /// An external https:// or http:// URL that requires resolution into a JSON Web Key Set
106    External(String),
107}
108
109impl WebBotAuthVerifier {
110    /// Parse a message into a structure that is ready for verification against an
111    /// external key with a suitable algorithm. If `alg` is not set, a default will
112    /// be chosen from the `alg` parameter.
113    ///
114    /// # Errors
115    ///
116    /// Returns `ImplementationErrors` relevant to verifying and parsing.
117    pub fn parse(message: &impl SignedMessage) -> Result<Self, ImplementationError> {
118        let signature_agents = message.lookup_component(&CoveredComponent::HTTP(HTTPField {
119            name: "signature-agent".to_string(),
120            parameters: components::HTTPFieldParametersSet(vec![]),
121        }));
122
123        let message_verifier =
124            MessageVerifier::parse(message, |(_, innerlist)| {
125                innerlist.params.contains_key("keyid")
126                    && innerlist.params.contains_key("tag")
127                    && innerlist.params.contains_key("expires")
128                    && innerlist.params.contains_key("created")
129                    && innerlist
130                        .params
131                        .get("tag")
132                        .and_then(|tag| tag.as_string())
133                        .is_some_and(|tag| tag.as_str() == "web-bot-auth")
134                    && (innerlist.items.iter().any(|item| {
135                        *item == sfv::Item::new(sfv::StringRef::constant("@authority"))
136                    }) || innerlist.items.iter().any(|item| {
137                        *item == sfv::Item::new(sfv::StringRef::constant("@target-uri"))
138                    }))
139                    && (if !signature_agents.is_empty() {
140                        innerlist.items.iter().any(|item| {
141                            item.bare_item
142                                .as_string()
143                                .is_some_and(|i| i == sfv::StringRef::constant("signature-agent"))
144                        })
145                    } else {
146                        true
147                    })
148            })?;
149
150        let mut signature_agent_key: Option<String> = None;
151        'outer_loop: for (component, _) in message_verifier.parsed.base.components.iter() {
152            if let CoveredComponent::HTTP(HTTPField { name, parameters }) = component
153                && name == "signature-agent"
154            {
155                for parameter in parameters.0.iter() {
156                    if let HTTPFieldParameters::Key(key) = parameter {
157                        signature_agent_key = Some(key.clone());
158                        break 'outer_loop;
159                    }
160                }
161            }
162        }
163
164        let parse_link = |link: &sfv::StringRef| {
165            let link_str = link.as_str();
166            if link_str.starts_with("https://") || link_str.starts_with("http://") {
167                return Some(SignatureAgentLink::External(String::from(link_str)));
168            }
169
170            if let Ok(url) = DataUrl::process(link_str) {
171                let mediatype = url.mime_type();
172                if mediatype.type_ == "application"
173                    && mediatype.subtype == "http-message-signatures-directory"
174                    && let Ok((body, _)) = url.decode_to_vec()
175                    && let Ok(jwks) = serde_json::from_slice::<JSONWebKeySet>(&body)
176                {
177                    return Some(SignatureAgentLink::Inline(jwks));
178                }
179            }
180
181            None
182        };
183
184        let parsed_directories = match signature_agent_key {
185            Some(key) => signature_agents
186                .iter()
187                .filter_map(|header| sfv::Parser::new(header).parse_dictionary().ok())
188                .reduce(|mut acc, sig_agent| {
189                    acc.extend(sig_agent);
190                    acc
191                })
192                .ok_or(ImplementationError::ParsingError(
193                    "Failed to parse `Signature-Agent` into valid sfv::Dictionary".to_string(),
194                ))?
195                .into_iter()
196                .filter_map(|(label, listentry)| match listentry {
197                    sfv::ListEntry::Item(item) => Some((label, item)),
198                    sfv::ListEntry::InnerList(_) => None,
199                })
200                .filter_map(|(label, item)| {
201                    if label.as_str() != key {
202                        return None;
203                    }
204                    let as_string = item.bare_item.as_string();
205                    as_string.and_then(parse_link)
206                })
207                .collect(),
208            None => signature_agents
209                .iter()
210                .map(|header| {
211                    sfv::Parser::new(header).parse_item().map_err(|e| {
212                        ImplementationError::ParsingError(format!(
213                            "Failed to parse `Signature-Agent` into valid sfv::Item: {e}"
214                        ))
215                    })
216                })
217                .collect::<Result<Vec<_>, _>>()?
218                .iter()
219                .flat_map(|item| {
220                    let as_string = item.bare_item.as_string();
221                    as_string.and_then(parse_link)
222                })
223                .collect(),
224        };
225
226        let web_bot_auth_verifier = Self {
227            message_verifier,
228            parsed_directories,
229        };
230
231        Ok(web_bot_auth_verifier)
232    }
233
234    /// Obtain list of Signature-Agents parsed and ready. This method is ideal for populating
235    /// a keyring ahead of time at your discretion.
236    pub fn get_signature_agents(&self) -> &Vec<SignatureAgentLink> {
237        &self.parsed_directories
238    }
239
240    /// Verify the messsage, consuming the verifier in the process.
241    /// If `key_id` is not supplied, a key ID to fetch the public key
242    /// from `keyring` will be sourced from the `keyid` parameter
243    /// within the message.
244    pub fn verify(
245        self,
246        keyring: &KeyRing,
247        key_id: Option<String>,
248    ) -> Result<SignatureTiming, ImplementationError> {
249        self.message_verifier.verify(keyring, key_id)
250    }
251
252    /// Retrieve the contents of the chosen signature and signature input label for
253    /// verification.
254    pub fn get_parsed_label(&self) -> &ParsedLabel {
255        &self.message_verifier.parsed
256    }
257}
258
259#[cfg(test)]
260mod tests {
261
262    use components::DerivedComponent;
263    use indexmap::IndexMap;
264
265    use super::*;
266
267    struct StandardTestVector {}
268
269    impl SignedMessage for StandardTestVector {
270        fn lookup_component(&self, name: &CoveredComponent) -> Vec<String> {
271            match name {
272                CoveredComponent::HTTP(HTTPField { name, .. }) => {
273                    if name == "signature" {
274                        return vec!["sig1=:uz2SAv+VIemw+Oo890bhYh6Xf5qZdLUgv6/PbiQfCFXcX/vt1A8Pf7OcgL2yUDUYXFtffNpkEr5W6dldqFrkDg==:".to_owned()];
275                    }
276
277                    if name == "signature-input" {
278                        return 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()];
279                    }
280                    vec![]
281                }
282                CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
283                    vec!["example.com".to_string()]
284                }
285                _ => vec![],
286            }
287        }
288    }
289
290    #[test]
291    fn test_verifying_as_web_bot_auth() {
292        let test = StandardTestVector {};
293        let public_key: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = [
294            0x26, 0xb4, 0x0b, 0x8f, 0x93, 0xff, 0xf3, 0xd8, 0x97, 0x11, 0x2f, 0x7e, 0xbc, 0x58,
295            0x2b, 0x23, 0x2d, 0xbd, 0x72, 0x51, 0x7d, 0x08, 0x2f, 0xe8, 0x3c, 0xfb, 0x30, 0xdd,
296            0xce, 0x43, 0xd1, 0xbb,
297        ];
298        let mut keyring = KeyRing::default();
299        keyring.import_raw(
300            "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".to_string(),
301            Algorithm::Ed25519,
302            public_key.to_vec(),
303        );
304        let verifier = WebBotAuthVerifier::parse(&test).unwrap();
305        let advisory = verifier
306            .get_parsed_label()
307            .base
308            .parameters
309            .details
310            .possibly_insecure(|_| false);
311        // Since the expiry date is in the past.
312        assert!(advisory.is_expired.unwrap_or(true));
313        assert!(!advisory.nonce_is_invalid.unwrap_or(true));
314        let timing = verifier.verify(&keyring, None).unwrap();
315        assert!(timing.generation.whole_nanoseconds() > 0);
316        assert!(timing.verification.whole_nanoseconds() > 0);
317    }
318
319    #[test]
320    fn test_signing_then_verifying() {
321        struct MyTest {
322            signature_input: String,
323            signature_header: String,
324        }
325
326        impl message_signatures::UnsignedMessage for MyTest {
327            fn fetch_components_to_cover(&self) -> IndexMap<CoveredComponent, String> {
328                IndexMap::from_iter([(
329                    CoveredComponent::Derived(DerivedComponent::Authority { req: false }),
330                    "example.com".to_string(),
331                )])
332            }
333
334            fn register_header_contents(
335                &mut self,
336                signature_input: String,
337                signature_header: String,
338            ) {
339                self.signature_input = format!("sig1={signature_input}");
340                self.signature_header = format!("sig1={signature_header}");
341            }
342        }
343
344        impl SignedMessage for MyTest {
345            fn lookup_component(&self, name: &CoveredComponent) -> Vec<String> {
346                match name {
347                    CoveredComponent::HTTP(HTTPField { name, .. }) => {
348                        if name == "signature" {
349                            return vec![self.signature_header.clone()];
350                        }
351
352                        if name == "signature-input" {
353                            return vec![self.signature_input.clone()];
354                        }
355                        vec![]
356                    }
357                    CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
358                        vec!["example.com".to_string()]
359                    }
360                    _ => vec![],
361                }
362            }
363        }
364
365        let public_key: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = [
366            0x26, 0xb4, 0x0b, 0x8f, 0x93, 0xff, 0xf3, 0xd8, 0x97, 0x11, 0x2f, 0x7e, 0xbc, 0x58,
367            0x2b, 0x23, 0x2d, 0xbd, 0x72, 0x51, 0x7d, 0x08, 0x2f, 0xe8, 0x3c, 0xfb, 0x30, 0xdd,
368            0xce, 0x43, 0xd1, 0xbb,
369        ];
370
371        let private_key: [u8; ed25519_dalek::SECRET_KEY_LENGTH] = [
372            0x9f, 0x83, 0x62, 0xf8, 0x7a, 0x48, 0x4a, 0x95, 0x4e, 0x6e, 0x74, 0x0c, 0x5b, 0x4c,
373            0x0e, 0x84, 0x22, 0x91, 0x39, 0xa2, 0x0a, 0xa8, 0xab, 0x56, 0xff, 0x66, 0x58, 0x6f,
374            0x6a, 0x7d, 0x29, 0xc5,
375        ];
376
377        let mut keyring = KeyRing::default();
378        keyring.import_raw(
379            "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".to_string(),
380            Algorithm::Ed25519,
381            public_key.to_vec(),
382        );
383
384        let signer = message_signatures::MessageSigner {
385            keyid: "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".into(),
386            nonce: "end-to-end-test".into(),
387            tag: "web-bot-auth".into(),
388        };
389
390        let mut mytest = MyTest {
391            signature_input: String::new(),
392            signature_header: String::new(),
393        };
394
395        signer
396            .generate_signature_headers_content(
397                &mut mytest,
398                time::Duration::seconds(10),
399                Algorithm::Ed25519,
400                &private_key,
401            )
402            .unwrap();
403
404        let verifier = WebBotAuthVerifier::parse(&mytest).unwrap();
405        let advisory = verifier
406            .get_parsed_label()
407            .base
408            .parameters
409            .details
410            .possibly_insecure(|_| false);
411        assert!(!advisory.is_expired.unwrap_or(true));
412        assert!(!advisory.nonce_is_invalid.unwrap_or(true));
413
414        let timing = verifier.verify(&keyring, None).unwrap();
415        assert!(timing.generation.whole_nanoseconds() > 0);
416        assert!(timing.verification.whole_nanoseconds() > 0);
417    }
418
419    #[test]
420    fn test_missing_tags_break_web_bot_auth() {
421        struct MissingParametersTestVector {}
422
423        impl SignedMessage for MissingParametersTestVector {
424            fn lookup_component(&self, name: &CoveredComponent) -> Vec<String> {
425                match name {
426                    CoveredComponent::HTTP(HTTPField { name, .. }) => {
427                        if name == "signature" {
428                            return vec![
429                                "sig1=:uz2SAv+VIemw+Oo890bhYh6Xf5qZdLUgv6/PbiQfCFXcX/vt1A8Pf7OcgL2yUDUYXFtffNpkEr5W6dldqFrkDg==:".to_owned()
430                            ];
431                        }
432
433                        if name == "signature-input" {
434                            return 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()];
435                        }
436                        vec![]
437                    }
438                    CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
439                        vec!["example.com".to_string()]
440                    }
441                    _ => vec![],
442                }
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_required_in_signature_input() {
452        struct MissingParametersTestVector {}
453
454        impl SignedMessage for MissingParametersTestVector {
455            fn lookup_component(&self, name: &CoveredComponent) -> Vec<String> {
456                match name {
457                    CoveredComponent::HTTP(HTTPField { name, .. }) => {
458                        if name == "signature" {
459                            return vec!["sig1=:uz2SAv+VIemw+Oo890bhYh6Xf5qZdLUgv6/PbiQfCFXcX/vt1A8Pf7OcgL2yUDUYXFtffNpkEr5W6dldqFrkDg==:".to_owned()];
460                        }
461
462                        if name == "signature-input" {
463                            return 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()];
464                        }
465
466                        if name == "signature-agent" {
467                            return vec![String::from("\"https://myexample.com\"")];
468                        }
469                        vec![]
470                    }
471                    CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
472                        vec!["example.com".to_string()]
473                    }
474                    _ => vec![],
475                }
476            }
477        }
478
479        let test = MissingParametersTestVector {};
480        WebBotAuthVerifier::parse(&test).expect_err("This should not have parsed");
481    }
482
483    #[test]
484    fn test_signature_agents_are_parsed_with_fallback() {
485        struct StandardTestVector {}
486
487        impl SignedMessage for StandardTestVector {
488            fn lookup_component(&self, name: &CoveredComponent) -> Vec<String> {
489                match name {
490                    CoveredComponent::HTTP(HTTPField { name, .. }) => {
491                        if name == "signature" {
492                            return vec!["sig1=:3q7S1TtbrFhQhpcZ1gZwHPCFHTvdKXNY1xngkp6lyaqqqv3QZupwpu/wQG5a7qybnrj2vZYMeVKuWepm+rNkDw==:".to_owned()];
493                        }
494
495                        if name == "signature-input" {
496                            return 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()];
497                        }
498
499                        if name == "signature-agent" {
500                            return vec![
501                                String::from("\"https://myexample.com\""),
502                                String::from("\"https://myexample2.com\""),
503                            ];
504                        }
505                        vec![]
506                    }
507                    CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
508                        vec!["example.com".to_string()]
509                    }
510                    _ => vec![],
511                }
512            }
513        }
514
515        let public_key: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = [
516            0x26, 0xb4, 0x0b, 0x8f, 0x93, 0xff, 0xf3, 0xd8, 0x97, 0x11, 0x2f, 0x7e, 0xbc, 0x58,
517            0x2b, 0x23, 0x2d, 0xbd, 0x72, 0x51, 0x7d, 0x08, 0x2f, 0xe8, 0x3c, 0xfb, 0x30, 0xdd,
518            0xce, 0x43, 0xd1, 0xbb,
519        ];
520        let mut keyring = KeyRing::default();
521        keyring.import_raw(
522            "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".to_string(),
523            Algorithm::Ed25519,
524            public_key.to_vec(),
525        );
526
527        let test = StandardTestVector {};
528        let verifier = WebBotAuthVerifier::parse(&test).unwrap();
529        assert_eq!(verifier.get_signature_agents().len(), 2);
530        assert_eq!(
531            verifier.get_signature_agents()[0],
532            SignatureAgentLink::External("https://myexample.com".to_string())
533        );
534    }
535
536    #[test]
537    fn test_signature_agents_are_parsed_correctly() {
538        struct StandardTestVector {}
539
540        impl SignedMessage for StandardTestVector {
541            fn lookup_component(&self, name: &CoveredComponent) -> Vec<String> {
542                match name {
543                    CoveredComponent::HTTP(HTTPField { name, .. }) => {
544                        if name == "signature" {
545                            return vec!["sig1=:3q7S1TtbrFhQhpcZ1gZwHPCFHTvdKXNY1xngkp6lyaqqqv3QZupwpu/wQG5a7qybnrj2vZYMeVKuWepm+rNkDw==:".to_owned()];
546                        }
547
548                        if name == "signature-input" {
549                            return vec![r#"sig1=("@authority" "signature-agent";key="agent1");alg="ed25519";keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";nonce="ZO3/XMEZjrvSnLtAP9M7jK0WGQf3J+pbmQRUpKDhF9/jsNCWqUh2sq+TH4WTX3/GpNoSZUa8eNWMKqxWp2/c2g==";tag="web-bot-auth";created=1749331474;expires=1749331484"#.to_owned()];
550                        }
551
552                        if name == "signature-agent" {
553                            return vec![
554                                r#"agent1="https://myexample.com", agent2="https://example2.com""#
555                                    .to_owned(),
556                            ];
557                        }
558                        vec![]
559                    }
560                    CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
561                        vec!["example.com".to_string()]
562                    }
563                    _ => vec![],
564                }
565            }
566        }
567
568        let public_key: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = [
569            0x26, 0xb4, 0x0b, 0x8f, 0x93, 0xff, 0xf3, 0xd8, 0x97, 0x11, 0x2f, 0x7e, 0xbc, 0x58,
570            0x2b, 0x23, 0x2d, 0xbd, 0x72, 0x51, 0x7d, 0x08, 0x2f, 0xe8, 0x3c, 0xfb, 0x30, 0xdd,
571            0xce, 0x43, 0xd1, 0xbb,
572        ];
573        let mut keyring = KeyRing::default();
574        keyring.import_raw(
575            "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".to_string(),
576            Algorithm::Ed25519,
577            public_key.to_vec(),
578        );
579
580        let test = StandardTestVector {};
581        let verifier = WebBotAuthVerifier::parse(&test).unwrap();
582
583        assert_eq!(verifier.get_signature_agents().len(), 1);
584        assert_eq!(
585            verifier.get_signature_agents()[0],
586            SignatureAgentLink::External("https://myexample.com".to_string())
587        );
588    }
589}