From 274a5d72bea314678112ebc5c673c287f4396099 Mon Sep 17 00:00:00 2001 From: Akshat Mahajan Date: Wed, 17 Dec 2025 13:58:56 -0500 Subject: [PATCH 1/2] Make `@authority;req` errors more prominent + fix example signature generation This change amends the `http-signature-dir` to print an error log whendirectories mistakenly sign `@authority` without the `req` parameter. It fixes a bug with the example signature agent card generation where only the host component was used to sign `@authority`, rather than the full host and port pair (i.e. the _actual_ authority component). This led to verifiers being unable to verify generated signatures. It fixes some minor comments and superfluous Github Actions changes, and does some basic refactoring to make the logic a bit more straightforward in the example. Importantly, it also adds the `alg` parameter in generated signatures - this is in line with the opinionated signing we do, whereby other elements normal to web bot auth are also enforced for arbitrary HTTP signatures. --- .github/workflows/pullrequest.yml | 8 +-- crates/http-signature-directory/src/main.rs | 47 ++++++++------ crates/web-bot-auth/src/message_signatures.rs | 17 +++++ .../Cargo.toml | 3 - .../src/lib.rs | 64 ++++++++++--------- 5 files changed, 83 insertions(+), 56 deletions(-) diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index e188f26..141224b 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -49,13 +49,13 @@ jobs: - name: Set rust toolchain run: rustup override set 1.87 && rustup component add clippy rustfmt && rustup target add wasm32-unknown-unknown - run: cargo fetch - - run: cargo build --all --verbose --exclude plexi-cli --all-features --tests - - run: cargo build --all --verbose --exclude plexi-cli --exclude http-signature-directory --all-features --tests --target wasm32-unknown-unknown + - run: cargo build --all --verbose --all-features --tests + - run: cargo build --all --verbose --exclude http-signature-directory --all-features --tests --target wasm32-unknown-unknown - run: cargo check --tests --examples --benches --all-features - run: cargo clippy --all-features --all-targets -- -D warnings - run: cargo fmt --all -- --check - - run: cargo doc --all --exclude plexi-cli --all-features --document-private-items - - run: cargo test --all --verbose --exclude plexi-cli --all-features + - run: cargo doc --all --all-features --document-private-items + - run: cargo test --all --verbose --all-features deploy-rust: name: Deploy Rust Crates diff --git a/crates/http-signature-directory/src/main.rs b/crates/http-signature-directory/src/main.rs index 760aba6..d86c211 100644 --- a/crates/http-signature-directory/src/main.rs +++ b/crates/http-signature-directory/src/main.rs @@ -61,28 +61,37 @@ struct RawKeyData { x: String, } -struct SignedDirectory { - signature: Vec, - input: Vec, +struct SignedDirectory<'a> { + headers: &'a reqwest::header::HeaderMap, authority: String, } -impl SignedMessage for SignedDirectory { +impl SignedMessage for SignedDirectory<'_> { fn lookup_component(&self, name: &CoveredComponent) -> Vec { match name { CoveredComponent::Derived(DerivedComponent::Authority { req: true }) => { - error!( - "Expected `@authority`;req in signature input components, but did not find it" + debug!( + "Resolved {} for derived component {:?}", + self.authority, name ); + vec![self.authority.clone()] } + CoveredComponent::Derived(DerivedComponent::Authority { req: false }) => { + error!( + "You are signing a plain `@authority` without the `req` component parameter. Fix by signing with `req` so that Signature-Input uses `\"@authority\";req` instead", + ); + vec![] + } CoveredComponent::HTTP(HTTPField { name, .. }) => { - if name == "signature" { - return self.signature.clone(); - } - if name == "signature-input" { - return self.input.clone(); + if let Some(header) = self.headers.get(name) { + if let Ok(value) = header.to_str() { + debug!("Found {} for header {}", value, name); + return vec![String::from(value)]; + } } + + debug!("No value for header {:?} found", name); vec![] } _ => vec![], @@ -175,9 +184,10 @@ fn main() -> Result<(), String> { warnings.push("No Content Type header found".to_string()); } + let headers = response.headers().clone(); + // Extract signature headers - let signature_headers: Vec = response - .headers() + let signature_headers: Vec = headers .get_all("Signature") .iter() .filter_map(|header| header.to_str().map(String::from).ok()) @@ -188,8 +198,7 @@ fn main() -> Result<(), String> { signature_headers ); - let signature_inputs: Vec = response - .headers() + let signature_inputs: Vec = headers .get_all("Signature-Input") .iter() .filter_map(|header| header.to_str().map(String::from).ok()) @@ -284,8 +293,7 @@ fn main() -> Result<(), String> { None => { // Key imported successfully, now verify signature let directory = SignedDirectory { - signature: signature_headers.clone(), - input: signature_inputs.clone(), + headers: &headers, authority: String::from(authority), }; @@ -305,7 +313,10 @@ fn main() -> Result<(), String> { .and_then(|tag| tag.as_string()) .is_some_and(|tag| tag.as_str() == thumbprint) && innerlist.items.iter().any(|item| { - *item == sfv::Item::new(sfv::StringRef::constant("@authority")) + (*item) + .bare_item + .as_string() + .is_some_and(|s| (*s).as_str() == "@authority") }) }) { Ok(verifier) => { diff --git a/crates/web-bot-auth/src/message_signatures.rs b/crates/web-bot-auth/src/message_signatures.rs index 5f251f1..c4c7197 100644 --- a/crates/web-bot-auth/src/message_signatures.rs +++ b/crates/web-bot-auth/src/message_signatures.rs @@ -239,6 +239,10 @@ pub trait SignedMessage { /// care should be taken to ensure HTTP field names in the message are checked in a /// case-insensitive way. Only `CoveredComponent::Http` should return a vector with /// more than one element. + /// + /// This function is also used to look up the values of `Signature-Input`, `Signature` + /// and (if used for web bot auth) `Signature-Agent` as standard HTTP headers. + /// Implementations should return those headers as well. fn lookup_component(&self, name: &CoveredComponent) -> Vec; } @@ -325,6 +329,19 @@ impl MessageSigner { ), ); + sfv_parameters.insert( + sfv::KeyRef::constant("alg").to_owned(), + sfv::BareItem::String( + sfv::StringRef::from_str(&format!("{}", algorithm)) + .map_err(|_| { + ImplementationError::ParsingError( + "tag contains non-printable ASCII characters".into(), + ) + })? + .to_owned(), + ), + ); + let created = UtcDateTime::now(); let expiry = created + expires; diff --git a/examples/signature-agent-card-and-registry/Cargo.toml b/examples/signature-agent-card-and-registry/Cargo.toml index 05853a8..cf22bca 100644 --- a/examples/signature-agent-card-and-registry/Cargo.toml +++ b/examples/signature-agent-card-and-registry/Cargo.toml @@ -19,9 +19,6 @@ worker = { version = "0.7", features = ['http'] } worker-macros = { version = "0.7", features = ['http'] } web-bot-auth = { workspace = true } -# The following libraries are deliberately held back to these versions. -# `ed25519-dalek` doesn't support latest `rand` in its latest stable version, -# so we pin to the version of `rand` and `rand_chaca` the latest versions do support. ed25519-dalek = { version = "2.2.0", features = ['rand_core'] } rand = { version = "0.8", default-features = false, features = ["getrandom"] } getrandom = { version = "0.2", features = ["js"] } diff --git a/examples/signature-agent-card-and-registry/src/lib.rs b/examples/signature-agent-card-and-registry/src/lib.rs index 3cec042..c998270 100644 --- a/examples/signature-agent-card-and-registry/src/lib.rs +++ b/examples/signature-agent-card-and-registry/src/lib.rs @@ -15,7 +15,7 @@ use worker::*; const README: &str = r#"

Example Signature Agent Card and Registry on Cloudflare Workers

This deploys a registry and a signature agent card -on the same host: a Cloudflare worker. +on the same authority: a Cloudflare worker.

Instructions

  1. Navigate to /.well-known/http-message-signatures-directory to view a generated Signature Agent card on demand.
  2. @@ -34,9 +34,9 @@ struct SignatureAgentCard { } struct SignatureHeaderGenerator<'a> { - req: &'a HttpRequest, + authority: String, digest_header: String, - outputs: (String, String), + response_headers: &'a mut worker::Headers, } impl UnsignedMessage for SignatureHeaderGenerator<'_> { @@ -46,7 +46,7 @@ impl UnsignedMessage for SignatureHeaderGenerator<'_> { IndexMap::from_iter([ ( CoveredComponent::Derived(DerivedComponent::Authority { req: true }), - self.req.uri().host().unwrap().to_string(), + self.authority.clone(), ), ( CoveredComponent::HTTP(HTTPField { @@ -59,17 +59,24 @@ impl UnsignedMessage for SignatureHeaderGenerator<'_> { } fn register_header_contents(&mut self, signature_input: String, signature_header: String) { - self.outputs = ( - format!("sig1={}", signature_input), - format!("sig1={}", signature_header), - ) + let _ = self + .response_headers + .set("signature-input", &(format!("sig1={}", signature_input))); + let _ = self + .response_headers + .set("signature", &(format!("sig1={}", signature_header))); } } #[event(fetch)] async fn fetch(req: HttpRequest, env: Env, _ctx: Context) -> Result { let kv = env.kv("signed-agent-registry-hostnames")?; - let host = req.uri().host().ok_or(worker::Error::RouteNoDataError)?; + let authority = format!( + "{}", + req.uri() + .authority() + .ok_or(worker::Error::RouteNoDataError)? + ); match req.uri().path() { "/registry.txt" => { @@ -95,13 +102,12 @@ async fn fetch(req: HttpRequest, env: Env, _ctx: Context) -> Result { } "/.well-known/http-message-signatures-directory" => { let mut rng = rand::rngs::OsRng; - - let vectorized_keypair: Vec = match kv.get(host).bytes().await? { + let vectorized_keypair: Vec = match kv.get(&authority).bytes().await? { Some(pair) => pair, None => { let signing_key: SigningKey = SigningKey::generate(&mut rng); let keypair = signing_key.to_keypair_bytes().to_vec(); - kv.put_bytes(host, &keypair)?.execute().await?; + kv.put_bytes(&authority, &keypair)?.execute().await?; keypair } }; @@ -121,7 +127,7 @@ async fn fetch(req: HttpRequest, env: Env, _ctx: Context) -> Result { let thumbprint = thumbprintable.b64_thumbprint(); let card = SignatureAgentCard { - client_name: host.to_string(), + client_name: authority.to_string(), contacts: vec!["test@example.com".to_string()], keys: vec![thumbprintable], }; @@ -131,19 +137,27 @@ async fn fetch(req: HttpRequest, env: Env, _ctx: Context) -> Result { let mut hasher = Sha256::new(); hasher.update(&body); let digest_header = format!( - "sha-256=:{}=", + "sha-256=:{}:", general_purpose::STANDARD.encode(hasher.finalize()) ); + let mut nonce: [u8; 64] = [0; 64]; + rng.fill_bytes(&mut nonce); + + let mut response = Response::from_body(ResponseBody::Body(body.into_bytes()))?; + let headers = response.headers_mut(); + headers.set("content-digest", &digest_header)?; + headers.set( + "content-type", + "application/http-message-signatures-directory+json", + )?; + let mut generator = SignatureHeaderGenerator { - req: &req, + authority, digest_header: digest_header.clone(), - outputs: (String::new(), String::new()), + response_headers: headers, }; - let mut nonce: [u8; 64] = [0; 64]; - rng.fill_bytes(&mut nonce); - let signer = MessageSigner { keyid: thumbprint, nonce: general_purpose::STANDARD.encode(nonce), @@ -158,18 +172,6 @@ async fn fetch(req: HttpRequest, env: Env, _ctx: Context) -> Result { &(signing_key.as_bytes().to_vec()), ) .unwrap(); - - let (signature_input, signature_header) = generator.outputs.clone(); - - let mut response = Response::from_body(ResponseBody::Body(body.into_bytes()))?; - let headers = response.headers_mut(); - headers.set("content-digest", &digest_header)?; - headers.set( - "content-type", - "application/http-message-signatures-directory+json", - )?; - headers.set("signature-input", &signature_input)?; - headers.set("signature", &signature_header)?; Ok(response) } _ => Response::from_html(README), From 8195ee4506da8d646bc3ae7a770aee7a4a0ec057 Mon Sep 17 00:00:00 2001 From: Akshat Mahajan Date: Wed, 17 Dec 2025 14:09:13 -0500 Subject: [PATCH 2/2] Release v0.6.0 of web-bot-auth crates These include some pretty significant and breaking changes: 1. Dependency on `time` library is now required instead of `std::time` for all API users. As a bonus, however, we gain support on Cloudflare Workers as well as removal of a class of errors related to system clocks and `created` / `expires` parsing. 2. A number of constructs were removed: `WebBotAuthSignedMessage`, `SignedMessage::fetch_all_signature_headers` and `SignedMessage::fetch_all_signature_inputs`. The library now exposes a single method to look up components to verify. 3. `Signature-Agent` can now be parsed as a dictionary, but retains support for being parsed as a raw string. 4. It enforces use of `req` parameter in `http-message-dir`. This is in line with the specification, but can break verification of existing sites. I also removed the pin to Rust v1.87 in the Github Actions handler. This ensures we're building against the latest available Rust version. --- .github/workflows/pullrequest.yml | 4 +--- Cargo.lock | 8 ++++---- Cargo.toml | 4 ++-- README.md | 6 ++++++ crates/http-signature-directory/src/main.rs | 13 ++++++------ crates/web-bot-auth/src/lib.rs | 22 ++++++++++----------- 6 files changed, 29 insertions(+), 28 deletions(-) diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 141224b..3ffff20 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -47,7 +47,7 @@ jobs: target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Set rust toolchain - run: rustup override set 1.87 && rustup component add clippy rustfmt && rustup target add wasm32-unknown-unknown + run: rustup component add clippy rustfmt && rustup target add wasm32-unknown-unknown - run: cargo fetch - run: cargo build --all --verbose --all-features --tests - run: cargo build --all --verbose --exclude http-signature-directory --all-features --tests --target wasm32-unknown-unknown @@ -85,8 +85,6 @@ jobs: ~/.cargo/git target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - name: Set rust toolchain - run: rustup override set 1.87 - run: cargo publish -p web-bot-auth # will fail if we don't bump the version continue-on-error: true - run: cargo publish -p http-signature-directory # will fail if we don't bump the version diff --git a/Cargo.lock b/Cargo.lock index 6f734ab..3571f62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -595,7 +595,7 @@ dependencies = [ [[package]] name = "http-signature-directory" -version = "0.5.1" +version = "0.6.0" dependencies = [ "clap", "env_logger", @@ -1246,7 +1246,7 @@ dependencies = [ [[package]] name = "rust-examples" -version = "0.5.1" +version = "0.6.0" dependencies = [ "indexmap", "time", @@ -1463,7 +1463,7 @@ dependencies = [ [[package]] name = "signature-agent-card-and-registry" -version = "0.5.1" +version = "0.6.0" dependencies = [ "base64", "ed25519-dalek", @@ -1892,7 +1892,7 @@ dependencies = [ [[package]] name = "web-bot-auth" -version = "0.5.1" +version = "0.6.0" dependencies = [ "base64", "data-url", diff --git a/Cargo.toml b/Cargo.toml index f296889..ded13a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.5.1" +version = "0.6.0" authors = [ "Akshat Mahajan ", "Gauri Baraskar ", @@ -35,4 +35,4 @@ regex = "1.12.2" time = { version = "0.3.44" } # workspace dependencies -web-bot-auth = { version = "0.5.1", path = "./crates/web-bot-auth" } +web-bot-auth = { version = "0.6.0", path = "./crates/web-bot-auth" } diff --git a/README.md b/README.md index 6efaec5..534d3a6 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ This deployment allows to test your implementation. | [Caddy Plugin](./examples/caddy-plugin/) | Verify RFC 9421 `Signature` for every incoming request | | [Rust](./examples/rust/) | Verify a sample test request | +### HTTP Signature Directories + +| Example | Description | +| :----------------------------------------------------------------- | :------------------------------------------------------------- | +| [Cloudflare Workers](./examples/signature-agent-card-and-registry) | Host a signature directory on Cloudflare Workers, using the [signature agent card and registry](https://datatracker.ietf.org/doc/draft-meunier-webbotauth-registry/) format | + ## Development This repository uses [npm](https://docs.npmjs.com/cli/v11/using-npm/workspaces) and [cargo](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html) workspaces. There are several packages which it provides: diff --git a/crates/http-signature-directory/src/main.rs b/crates/http-signature-directory/src/main.rs index d86c211..31c87c1 100644 --- a/crates/http-signature-directory/src/main.rs +++ b/crates/http-signature-directory/src/main.rs @@ -84,11 +84,11 @@ impl SignedMessage for SignedDirectory<'_> { vec![] } CoveredComponent::HTTP(HTTPField { name, .. }) => { - if let Some(header) = self.headers.get(name) { - if let Ok(value) = header.to_str() { - debug!("Found {} for header {}", value, name); - return vec![String::from(value)]; - } + if let Some(header) = self.headers.get(name) + && let Ok(value) = header.to_str() + { + debug!("Found {} for header {}", value, name); + return vec![String::from(value)]; } debug!("No value for header {:?} found", name); @@ -313,8 +313,7 @@ fn main() -> Result<(), String> { .and_then(|tag| tag.as_string()) .is_some_and(|tag| tag.as_str() == thumbprint) && innerlist.items.iter().any(|item| { - (*item) - .bare_item + item.bare_item .as_string() .is_some_and(|s| (*s).as_str() == "@authority") }) diff --git a/crates/web-bot-auth/src/lib.rs b/crates/web-bot-auth/src/lib.rs index 5c1f3b4..735ea17 100644 --- a/crates/web-bot-auth/src/lib.rs +++ b/crates/web-bot-auth/src/lib.rs @@ -149,13 +149,13 @@ impl WebBotAuthVerifier { let mut signature_agent_key: Option = None; 'outer_loop: for (component, _) in message_verifier.parsed.base.components.iter() { - if let CoveredComponent::HTTP(HTTPField { name, parameters }) = component { - if name == "signature-agent" { - for parameter in parameters.0.iter() { - if let HTTPFieldParameters::Key(key) = parameter { - signature_agent_key = Some(key.clone()); - break 'outer_loop; - } + if let CoveredComponent::HTTP(HTTPField { name, parameters }) = component + && name == "signature-agent" + { + for parameter in parameters.0.iter() { + if let HTTPFieldParameters::Key(key) = parameter { + signature_agent_key = Some(key.clone()); + break 'outer_loop; } } } @@ -171,12 +171,10 @@ impl WebBotAuthVerifier { let mediatype = url.mime_type(); if mediatype.type_ == "application" && mediatype.subtype == "http-message-signatures-directory" + && let Ok((body, _)) = url.decode_to_vec() + && let Ok(jwks) = serde_json::from_slice::(&body) { - if let Ok((body, _)) = url.decode_to_vec() { - if let Ok(jwks) = serde_json::from_slice::(&body) { - return Some(SignatureAgentLink::Inline(jwks)); - } - } + return Some(SignatureAgentLink::Inline(jwks)); } }