diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index e188f26..3ffff20 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -47,15 +47,15 @@ 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 --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 @@ -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 760aba6..31c87c1 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) + && 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,9 @@ 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/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)); } } 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),