From 6fd7f9d0bff6ada12b8b40e3815e975b983a3e04 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Wed, 12 Aug 2026 09:43:43 +0700 Subject: [PATCH 1/3] fix(web-bot-auth): fail closed on expired signatures in Rust verify WebBotAuthVerifier::verify previously accepted cryptographically valid signatures after expires. SignatureIsExpired existed but was unused, and the TypeScript http-message-sig verifier already rejects expired inputs. Enforce the expires window before crypto verify and update the sample. --- crates/web-bot-auth/src/lib.rs | 29 +++++++++++++++++++++++++---- examples/rust/verify.rs | 9 +++++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/web-bot-auth/src/lib.rs b/crates/web-bot-auth/src/lib.rs index cb7f385..896b7ec 100644 --- a/crates/web-bot-auth/src/lib.rs +++ b/crates/web-bot-auth/src/lib.rs @@ -315,11 +315,30 @@ impl WebBotAuthVerifier { /// If `key_id` is not supplied, a key ID to fetch the public key /// from `keyring` will be sourced from the `keyid` parameter /// within the message. + /// + /// This fails closed when `expires` is in the past (or unparsable), + /// returning [`WebBotAuthError::SignatureIsExpired`] before cryptographic + /// verification. That matches the TypeScript `http-message-sig` verifier. + /// Callers that need advisory-only expiry checks can still inspect + /// `possibly_insecure` via [`Self::get_parsed_label`]. pub fn verify( self, keyring: &KeyRing, key_id: Option, ) -> Result { + let advisory = self + .message_verifier + .parsed + .base + .parameters + .details + .possibly_insecure(|_| false); + // Web Bot Auth parse requires `expires`; treat missing/unparsable as expired. + if advisory.is_expired.unwrap_or(true) { + return Err(ImplementationError::WebBotAuth( + WebBotAuthError::SignatureIsExpired, + )); + } self.message_verifier.verify(keyring, key_id) } @@ -386,10 +405,12 @@ mod tests { // Since the expiry date is in the past. assert!(advisory.is_expired.unwrap_or(true)); assert!(!advisory.nonce_is_invalid.unwrap_or(true)); - let timing = verifier.verify(&keyring, None).unwrap(); - - assert!(timing.generation.as_nanos() > 0); - assert!(timing.verification.as_nanos() > 0); + // WebBotAuthVerifier::verify must fail closed on expired signatures. + let err = verifier.verify(&keyring, None).unwrap_err(); + assert!(matches!( + err, + ImplementationError::WebBotAuth(WebBotAuthError::SignatureIsExpired) + )); } #[test] diff --git a/examples/rust/verify.rs b/examples/rust/verify.rs index 2694148..b5047dc 100644 --- a/examples/rust/verify.rs +++ b/examples/rust/verify.rs @@ -13,7 +13,7 @@ // limitations under the License. use web_bot_auth::{ - SignatureAgentLink, WebBotAuthVerifier, + ImplementationError, SignatureAgentLink, WebBotAuthError, WebBotAuthVerifier, components::{CoveredComponent, DerivedComponent, HTTPField}, keyring::{Algorithm, KeyRing}, message_signatures::SignedMessage, @@ -77,5 +77,10 @@ fn main() { // Since the expiry date is in the past. assert!(advisory.is_expired.unwrap_or(true)); assert!(!advisory.nonce_is_invalid.unwrap_or(true)); - assert!(verifier.verify(&keyring, None).is_ok()); + assert!(matches!( + verifier.verify(&keyring, None), + Err(ImplementationError::WebBotAuth( + WebBotAuthError::SignatureIsExpired + )) + )); } From 4210a82d2c790d718652a405cf73a20451380079 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Wed, 12 Aug 2026 09:43:43 +0700 Subject: [PATCH 2/3] web-bot-auth: add opt-in verify_with_advisory for expired signatures verify keeps the fail-closed default (SignatureIsExpired before crypto). The new verify_with_advisory runs full cryptographic verification and returns AdvisoryVerification { timing, advisory } so callers that must parse expired-but-valid signatures get the SecurityAdvisory as a warning instead of an error, per maintainer review on #125. Also fixes an unclosed paren in the rust example and demonstrates the opt-in there. Co-authored-by: Cursor --- crates/web-bot-auth/src/lib.rs | 66 ++++++++++++++++++- crates/web-bot-auth/src/message_signatures.rs | 1 + examples/rust/verify.rs | 6 ++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/web-bot-auth/src/lib.rs b/crates/web-bot-auth/src/lib.rs index 896b7ec..e366473 100644 --- a/crates/web-bot-auth/src/lib.rs +++ b/crates/web-bot-auth/src/lib.rs @@ -32,7 +32,9 @@ use data_url::DataUrl; use components::{CoveredComponent, HTTPField, HTTPFieldParameters}; use keyring::{Algorithm, JSONWebKeySet, KeyRing}; -use message_signatures::{MessageVerifier, ParsedLabel, SignatureTiming, SignedMessage}; +use message_signatures::{ + MessageVerifier, ParsedLabel, SecurityAdvisory, SignatureTiming, SignedMessage, +}; use registry::{SignatureAgentDiscoveryType, parse_signature_agent_header}; /// Errors that may be thrown by this module. @@ -150,6 +152,18 @@ pub struct WebBotAuthVerifier { parsed_directories: Vec, } +/// The outcome of [`WebBotAuthVerifier::verify_with_advisory`]: cryptographic +/// verification has completed, and advisory conditions that the strict +/// [`WebBotAuthVerifier::verify`] would have enforced are reported instead. +#[derive(Clone, Debug)] +pub struct AdvisoryVerification { + /// Micro-measurements of the verification process. + pub timing: SignatureTiming, + /// Advisory observed before verification, e.g. whether the signature + /// window had already expired. + pub advisory: SecurityAdvisory, +} + /// The different types of URLs a `Signature-Agent` can have. #[derive(Eq, PartialEq, Debug, Clone)] pub enum SignatureAgentLink { @@ -319,8 +333,8 @@ impl WebBotAuthVerifier { /// This fails closed when `expires` is in the past (or unparsable), /// returning [`WebBotAuthError::SignatureIsExpired`] before cryptographic /// verification. That matches the TypeScript `http-message-sig` verifier. - /// Callers that need advisory-only expiry checks can still inspect - /// `possibly_insecure` via [`Self::get_parsed_label`]. + /// Callers that must process expired-but-valid signatures can opt into + /// advisory-only expiry handling via [`Self::verify_with_advisory`]. pub fn verify( self, keyring: &KeyRing, @@ -342,6 +356,30 @@ impl WebBotAuthVerifier { self.message_verifier.verify(keyring, key_id) } + /// Verify the message like [`Self::verify`], but without failing closed on + /// the `expires` window: full cryptographic verification always runs, and + /// the pre-verification [`SecurityAdvisory`] is returned alongside the + /// timing so the caller can decide how to treat an expired signature. + /// + /// RFC 9421 leaves enforcement of application requirements such as expiry + /// to the application; this opt-in serves verifiers that need to parse and + /// judge such signatures themselves rather than reject them up front. + pub fn verify_with_advisory( + self, + keyring: &KeyRing, + key_id: Option, + ) -> Result { + let advisory = self + .message_verifier + .parsed + .base + .parameters + .details + .possibly_insecure(|_| false); + let timing = self.message_verifier.verify(keyring, key_id)?; + Ok(AdvisoryVerification { timing, advisory }) + } + /// Retrieve the contents of the chosen signature and signature input label for /// verification. pub fn get_parsed_label(&self) -> &ParsedLabel { @@ -413,6 +451,28 @@ mod tests { )); } + #[test] + fn test_verify_with_advisory_on_expired_signature() { + let test = StandardTestVector {}; + let public_key: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = [ + 0x26, 0xb4, 0x0b, 0x8f, 0x93, 0xff, 0xf3, 0xd8, 0x97, 0x11, 0x2f, 0x7e, 0xbc, 0x58, + 0x2b, 0x23, 0x2d, 0xbd, 0x72, 0x51, 0x7d, 0x08, 0x2f, 0xe8, 0x3c, 0xfb, 0x30, 0xdd, + 0xce, 0x43, 0xd1, 0xbb, + ]; + let mut keyring = KeyRing::default(); + keyring.import_raw( + "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".to_string(), + Algorithm::Ed25519, + public_key.to_vec(), + ); + let verifier = WebBotAuthVerifier::parse(&test).unwrap(); + // Opt-in path: full cryptographic verification runs despite the expired + // window, and the expiry surfaces as an advisory instead of an error. + let outcome = verifier.verify_with_advisory(&keyring, None).unwrap(); + assert!(outcome.advisory.is_expired.unwrap_or(true)); + assert!(!outcome.advisory.nonce_is_invalid.unwrap_or(true)); + } + #[test] fn test_signing_then_verifying() { struct MyTest { diff --git a/crates/web-bot-auth/src/message_signatures.rs b/crates/web-bot-auth/src/message_signatures.rs index 15c1d63..d15e880 100644 --- a/crates/web-bot-auth/src/message_signatures.rs +++ b/crates/web-bot-auth/src/message_signatures.rs @@ -92,6 +92,7 @@ impl From for SignatureParams { /// Advises whether or not to accept the message as valid prior to /// verification, based on a cursory examination of the message parameters. +#[derive(Clone, Debug)] pub struct SecurityAdvisory { /// If the `expires` tag was present on the message, whether or not /// the message expired in the past. diff --git a/examples/rust/verify.rs b/examples/rust/verify.rs index b5047dc..53798ff 100644 --- a/examples/rust/verify.rs +++ b/examples/rust/verify.rs @@ -83,4 +83,10 @@ fn main() { WebBotAuthError::SignatureIsExpired )) )); + + // Opt-in advisory path: full cryptographic verification still runs, and + // the expired window is reported as a warning instead of an error. + let verifier = WebBotAuthVerifier::parse(&test).unwrap(); + let outcome = verifier.verify_with_advisory(&keyring, None).unwrap(); + assert!(outcome.advisory.is_expired.unwrap_or(true)); } From 85f0119a27628cb6fa6e2a38eb9b3938fbc36ceb Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Wed, 12 Aug 2026 23:28:59 +0700 Subject: [PATCH 3/3] web-bot-auth: rename verify_with_advisory to verify_ignore_expiry Matches maintainer naming: the opt-in path ignores the expires window and still runs full cryptographic verification. --- crates/web-bot-auth/src/lib.rs | 10 +++++----- examples/rust/verify.rs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/web-bot-auth/src/lib.rs b/crates/web-bot-auth/src/lib.rs index e366473..44987be 100644 --- a/crates/web-bot-auth/src/lib.rs +++ b/crates/web-bot-auth/src/lib.rs @@ -152,7 +152,7 @@ pub struct WebBotAuthVerifier { parsed_directories: Vec, } -/// The outcome of [`WebBotAuthVerifier::verify_with_advisory`]: cryptographic +/// The outcome of [`WebBotAuthVerifier::verify_ignore_expiry`]: cryptographic /// verification has completed, and advisory conditions that the strict /// [`WebBotAuthVerifier::verify`] would have enforced are reported instead. #[derive(Clone, Debug)] @@ -334,7 +334,7 @@ impl WebBotAuthVerifier { /// returning [`WebBotAuthError::SignatureIsExpired`] before cryptographic /// verification. That matches the TypeScript `http-message-sig` verifier. /// Callers that must process expired-but-valid signatures can opt into - /// advisory-only expiry handling via [`Self::verify_with_advisory`]. + /// [`Self::verify_ignore_expiry`]. pub fn verify( self, keyring: &KeyRing, @@ -364,7 +364,7 @@ impl WebBotAuthVerifier { /// RFC 9421 leaves enforcement of application requirements such as expiry /// to the application; this opt-in serves verifiers that need to parse and /// judge such signatures themselves rather than reject them up front. - pub fn verify_with_advisory( + pub fn verify_ignore_expiry( self, keyring: &KeyRing, key_id: Option, @@ -452,7 +452,7 @@ mod tests { } #[test] - fn test_verify_with_advisory_on_expired_signature() { + fn test_verify_ignore_expiry_on_expired_signature() { let test = StandardTestVector {}; let public_key: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = [ 0x26, 0xb4, 0x0b, 0x8f, 0x93, 0xff, 0xf3, 0xd8, 0x97, 0x11, 0x2f, 0x7e, 0xbc, 0x58, @@ -468,7 +468,7 @@ mod tests { let verifier = WebBotAuthVerifier::parse(&test).unwrap(); // Opt-in path: full cryptographic verification runs despite the expired // window, and the expiry surfaces as an advisory instead of an error. - let outcome = verifier.verify_with_advisory(&keyring, None).unwrap(); + let outcome = verifier.verify_ignore_expiry(&keyring, None).unwrap(); assert!(outcome.advisory.is_expired.unwrap_or(true)); assert!(!outcome.advisory.nonce_is_invalid.unwrap_or(true)); } diff --git a/examples/rust/verify.rs b/examples/rust/verify.rs index 53798ff..3803d18 100644 --- a/examples/rust/verify.rs +++ b/examples/rust/verify.rs @@ -87,6 +87,6 @@ fn main() { // Opt-in advisory path: full cryptographic verification still runs, and // the expired window is reported as a warning instead of an error. let verifier = WebBotAuthVerifier::parse(&test).unwrap(); - let outcome = verifier.verify_with_advisory(&keyring, None).unwrap(); + let outcome = verifier.verify_ignore_expiry(&keyring, None).unwrap(); assert!(outcome.advisory.is_expired.unwrap_or(true)); }