From f36de08396f8494e1b4165b8e4045f7405de75ce Mon Sep 17 00:00:00 2001 From: Jun Kurihara Date: Sat, 14 Feb 2026 11:16:12 +0900 Subject: [PATCH 1/8] fix: Security hardening: constant-time digest comparison + regression tests --- httpsig-hyper/Cargo.toml | 1 + httpsig-hyper/src/hyper_content_digest.rs | 129 +++++++++++++++++++++- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/httpsig-hyper/Cargo.toml b/httpsig-hyper/Cargo.toml index de941b9..bd5218d 100644 --- a/httpsig-hyper/Cargo.toml +++ b/httpsig-hyper/Cargo.toml @@ -28,6 +28,7 @@ futures = { version = "0.3.31", default-features = false, features = [ "async-await", ] } indexmap = { version = "2.11.4" } +subtle = { version = "2.6.1", default-features = false } # content digest with rfc8941 structured field values sha2 = { version = "0.10.9", default-features = false } diff --git a/httpsig-hyper/src/hyper_content_digest.rs b/httpsig-hyper/src/hyper_content_digest.rs index 5564289..b8657ed 100644 --- a/httpsig-hyper/src/hyper_content_digest.rs +++ b/httpsig-hyper/src/hyper_content_digest.rs @@ -8,6 +8,7 @@ use http_body_util::{combinators::BoxBody, BodyExt, Full}; use sha2::Digest; use std::future::Future; use std::str::FromStr; +use subtle::ConstantTimeEq; // hyper's http specific extension to generate and verify http signature @@ -141,7 +142,8 @@ where .map_err(|_e| HyperDigestError::HttpBodyError("Failed to get body bytes".to_string()))?; let digest = derive_digest(&body_bytes, &cd_type); - if digest == _expected_digest { + // Use constant time equality check to prevent timing attacks + if is_equal_digest(&digest, &_expected_digest) { let new_body = Full::new(body_bytes).map_err(|never| match never {}).boxed(); let res = Request::from_parts(header, new_body); Ok(res) @@ -192,7 +194,8 @@ where .map_err(|_e| HyperDigestError::HttpBodyError("Failed to get body bytes".to_string()))?; let digest = derive_digest(&body_bytes, &cd_type); - if digest == _expected_digest { + // Use constant time equality check to prevent timing attacks + if is_equal_digest(&digest, &_expected_digest) { let new_body = Full::new(body_bytes).map_err(|never| match never {}).boxed(); let res = Response::from_parts(header, new_body); Ok(res) @@ -204,6 +207,16 @@ where } } +// Constant time equality check for digest verification to prevent timing attacks +fn is_equal_digest(digest1: &[u8], digest2: &[u8]) -> bool { + // Early return if the lengths are different to prevent unnecessary computation, + // which is not a security risk in this context since the digest lengths are fixed for each algorithm. + if digest1.len() != digest2.len() { + return false; + } + digest1.ct_eq(digest2).into() +} + async fn extract_content_digest(header_map: &http::HeaderMap) -> HyperDigestResult<(ContentDigestType, Vec)> { let content_digest_header = header_map .get(CONTENT_DIGEST_HEADER) @@ -301,4 +314,116 @@ mod tests { let verified = res.verify_content_digest().await; assert!(verified.is_ok()); } + + #[tokio::test] + async fn hyper_request_digest_mismatch_by_body_tamper_should_fail() { + // 1) Create a request and set a correct Content-Digest for the original body + let body = Full::new(&b"{\"hello\": \"world\"}"[..]); + let req = Request::builder() + .method("GET") + .uri("https://example.com/") + .header("date", "Sun, 09 May 2021 18:30:00 GMT") + .header("content-type", "application/json") + .body(body) + .unwrap(); + + let req = req.set_content_digest(&ContentDigestType::Sha256).await.unwrap(); + assert!(req.headers().contains_key(CONTENT_DIGEST_HEADER)); + + // 2) Tamper the body while keeping the digest header unchanged + let (parts, _old_body) = req.into_parts(); + let tampered_body = Full::new(&b"{\"hello\": \"pwned\"}"[..]).boxed(); + let tampered_req = Request::from_parts(parts, tampered_body); + + // 3) Verification must fail + let verified = tampered_req.verify_content_digest().await; + assert!(verified.is_err()); + match verified.err().unwrap() { + HyperDigestError::InvalidContentDigest(_) => {} + e => panic!("unexpected error: {e:?}"), + } + } + + #[tokio::test] + async fn hyper_response_digest_mismatch_by_header_tamper_should_fail() { + // 1) Create a response and set a correct Content-Digest + let body = Full::new(&b"{\"hello\": \"world\"}"[..]); + let res = Response::builder() + .status(200) + .header("date", "Sun, 09 May 2021 18:30:00 GMT") + .header("content-type", "application/json") + .body(body) + .unwrap(); + + let res = res.set_content_digest(&ContentDigestType::Sha256).await.unwrap(); + let (mut parts, body) = res.into_parts(); + + // 2) Tamper the Content-Digest header (keep it syntactically valid) + // Expected digest is: X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE= + // Change the first character to another valid base64 character. + parts.headers.insert( + CONTENT_DIGEST_HEADER, + "sha-256=:Y48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:".parse().unwrap(), + ); + + let tampered_res = Response::from_parts(parts, body); + + // 3) Verification must fail + let verified = tampered_res.verify_content_digest().await; + assert!(verified.is_err()); + match verified.err().unwrap() { + HyperDigestError::InvalidContentDigest(_) => {} + e => panic!("unexpected error: {e:?}"), + } + } + + #[tokio::test] + async fn hyper_request_missing_content_digest_header_should_fail() { + let body = Full::new(&b"{\"hello\": \"world\"}"[..]); + let req = Request::builder() + .method("GET") + .uri("https://example.com/") + .header("date", "Sun, 09 May 2021 18:30:00 GMT") + .header("content-type", "application/json") + .body(body) + .unwrap(); + + // No set_content_digest() call => header missing + let verified = req.verify_content_digest().await; + assert!(verified.is_err()); + match verified.err().unwrap() { + HyperDigestError::NoDigestHeader(_) => {} + e => panic!("unexpected error: {e:?}"), + } + } + + #[tokio::test] + async fn hyper_request_digest_length_mismatch_should_fail() { + // 1) Create a request and attach a valid Content-Digest header + let body = Full::new(&b"{\"hello\": \"world\"}"[..]); + let req = Request::builder() + .method("GET") + .uri("https://example.com/") + .header("date", "Sun, 09 May 2021 18:30:00 GMT") + .header("content-type", "application/json") + .body(body) + .unwrap(); + + let req = req.set_content_digest(&ContentDigestType::Sha256).await.unwrap(); + + // 2) Extract parts and replace the Content-Digest header + // with a syntactically valid but length-mismatched base64 value. + // This ensures that length mismatches are properly rejected. + let (mut parts, body) = req.into_parts(); + + parts + .headers + .insert(CONTENT_DIGEST_HEADER, "sha-256=:AAAA=:".parse().unwrap()); + + let tampered_req = Request::from_parts(parts, body); + + // 3) Verification must fail due to digest length mismatch + let verified = tampered_req.verify_content_digest().await; + assert!(verified.is_err()); + } } From 7a7cedc6489c72ac756b5585555cdb2fe3315770 Mon Sep 17 00:00:00 2001 From: Jun Kurihara Date: Sat, 14 Feb 2026 11:16:41 +0900 Subject: [PATCH 2/8] bump --- Cargo.toml | 2 +- httpsig-hyper/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 06b5f55..81f2267 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.0.22" +version = "0.0.23" authors = ["Jun Kurihara"] homepage = "https://github.com/junkurihara/httpsig-rs" repository = "https://github.com/junkurihara/httpsig-rs" diff --git a/httpsig-hyper/Cargo.toml b/httpsig-hyper/Cargo.toml index bd5218d..0b4a8fd 100644 --- a/httpsig-hyper/Cargo.toml +++ b/httpsig-hyper/Cargo.toml @@ -19,7 +19,7 @@ rsa-signature = ["httpsig/rsa-signature"] [dependencies] -httpsig = { path = "../httpsig", version = "0.0.22" } +httpsig = { path = "../httpsig", version = "0.0.23" } thiserror = { version = "2.0.18" } tracing = { version = "0.1.44" } From 669614d008b15f140f0d47bd09d45638c3e21c73 Mon Sep 17 00:00:00 2001 From: Jun Kurihara Date: Sat, 14 Feb 2026 11:21:25 +0900 Subject: [PATCH 3/8] chore(refactor) --- httpsig-hyper/src/hyper_content_digest.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/httpsig-hyper/src/hyper_content_digest.rs b/httpsig-hyper/src/hyper_content_digest.rs index b8657ed..51eb75c 100644 --- a/httpsig-hyper/src/hyper_content_digest.rs +++ b/httpsig-hyper/src/hyper_content_digest.rs @@ -134,7 +134,7 @@ where Self: Sized, { let header_map = self.headers(); - let (cd_type, _expected_digest) = extract_content_digest(header_map).await?; + let (cd_type, expected_digest) = extract_content_digest(header_map).await?; let (header, body) = self.into_parts(); let body_bytes = body .into_bytes() @@ -143,7 +143,7 @@ where let digest = derive_digest(&body_bytes, &cd_type); // Use constant time equality check to prevent timing attacks - if is_equal_digest(&digest, &_expected_digest) { + if is_equal_digest(&digest, &expected_digest) { let new_body = Full::new(body_bytes).map_err(|never| match never {}).boxed(); let res = Request::from_parts(header, new_body); Ok(res) @@ -186,7 +186,7 @@ where Self: Sized, { let header_map = self.headers(); - let (cd_type, _expected_digest) = extract_content_digest(header_map).await?; + let (cd_type, expected_digest) = extract_content_digest(header_map).await?; let (header, body) = self.into_parts(); let body_bytes = body .into_bytes() @@ -195,7 +195,7 @@ where let digest = derive_digest(&body_bytes, &cd_type); // Use constant time equality check to prevent timing attacks - if is_equal_digest(&digest, &_expected_digest) { + if is_equal_digest(&digest, &expected_digest) { let new_body = Full::new(body_bytes).map_err(|never| match never {}).boxed(); let res = Response::from_parts(header, new_body); Ok(res) From 4b8a04f1716f8fbc381b81ce8aa9877941372324 Mon Sep 17 00:00:00 2001 From: Jun Kurihara Date: Sun, 15 Feb 2026 14:12:16 +0900 Subject: [PATCH 4/8] fix(bug): fix the internal validation logic for parametes of derived-components --- httpsig-hyper/src/hyper_http.rs | 54 ++++++++++++++++++---- httpsig/src/message_component/component.rs | 5 +- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/httpsig-hyper/src/hyper_http.rs b/httpsig-hyper/src/hyper_http.rs index ad9b2ac..2b31561 100644 --- a/httpsig-hyper/src/hyper_http.rs +++ b/httpsig-hyper/src/hyper_http.rs @@ -824,13 +824,37 @@ fn extract_derived_component( "invalid http message component name as derived component".to_string(), )); }; - if !id.params.0.is_empty() - && matches!(req_or_res, RequestOrResponse::Request(_)) - && !(id.params.0.contains(&HttpMessageComponentParam::Req) && id.params.0.len() == 1) - { - return Err(HyperSigError::InvalidComponentParam( - "derived component does not allow parameters for request".to_string(), - )); + // Validate parameters allowed on derived components (RFC 9421). + // - `name`: only valid on `@query-param` + // - `req`: only valid on response messages (to reference request-derived components, §2.4) + // - `sf`, `key`, `bs`, `tr`: only valid on HTTP field components, not derived components + for param in id.params.0.iter() { + match param { + HttpMessageComponentParam::Name(_) => { + if !matches!(derived_id, DerivedComponentName::QueryParam) { + return Err(HyperSigError::InvalidComponentParam( + "`name` parameter is only allowed for `@query-param`".to_string(), + )); + } + } + HttpMessageComponentParam::Req => { + // `req` is only meaningful in response signatures (RFC 9421 §2.4). + // `build_signature_base` already validates this and re-dispatches extraction + // against the original request, so `req_or_res` here should always be + // `Request`. Guard against misuse by callers that bypass `build_signature_base`. + if !matches!(req_or_res, RequestOrResponse::Request(_)) { + return Err(HyperSigError::InvalidComponentParam( + "`req`-tagged component must be extracted from the source request".to_string(), + )); + } + } + _ => { + return Err(HyperSigError::InvalidComponentParam(format!( + "parameter `{}` is not allowed on derived components", + String::from(param.clone()) + ))); + } + } } match req_or_res { @@ -842,9 +866,21 @@ fn extract_derived_component( } } RequestOrResponse::Response(_) => { - if !matches!(derived_id, DerivedComponentName::Status) && !matches!(derived_id, DerivedComponentName::SignatureParams) { + let has_req = id.params.0.contains(&HttpMessageComponentParam::Req); + // Response messages can use `@status` and `@signature-params` directly, + // or any request-derived component with the `req` parameter (RFC 9421 §2.4). + if !matches!(derived_id, DerivedComponentName::Status) + && !matches!(derived_id, DerivedComponentName::SignatureParams) + && !has_req + { return Err(HyperSigError::InvalidComponentName( - "Only `status` and `signature-params` are allowed for response".to_string(), + "derived components other than `@status` and `@signature-params` require `req` parameter for response".to_string(), + )); + } + // `@status` must not have `req` parameter + if matches!(derived_id, DerivedComponentName::Status) && has_req { + return Err(HyperSigError::InvalidComponentParam( + "`@status` does not accept `req` parameter".to_string(), )); } } diff --git a/httpsig/src/message_component/component.rs b/httpsig/src/message_component/component.rs index a74ade7..4fed6e1 100644 --- a/httpsig/src/message_component/component.rs +++ b/httpsig/src/message_component/component.rs @@ -283,9 +283,12 @@ mod tests { #[test] fn test_field_params_derived_component() { // params check - // only req field param is allowed + // only req and query-param field params are allowed let comp = HttpMessageComponent::try_from("\"@method\";req: POST"); assert!(comp.is_ok()); + let comp = HttpMessageComponent::try_from("\"@query-param\";name=\"id\": POST"); + assert!(comp.is_ok()); + let comp = HttpMessageComponent::try_from("\"@method\";bs: POST"); assert!(comp.is_err()); let comp = HttpMessageComponent::try_from("\"@method\";key=\"hoge\": POST"); From 2b2994a1ec92478ede667ca7e329e5afc5cf2b84 Mon Sep 17 00:00:00 2001 From: Jun Kurihara Date: Sun, 15 Feb 2026 14:28:01 +0900 Subject: [PATCH 5/8] fix(bug): fix the bug for error propagation --- httpsig-hyper/src/hyper_http.rs | 241 ++++++++++++++++++++++++++------ 1 file changed, 198 insertions(+), 43 deletions(-) diff --git a/httpsig-hyper/src/hyper_http.rs b/httpsig-hyper/src/hyper_http.rs index 2b31561..8bb8433 100644 --- a/httpsig-hyper/src/hyper_http.rs +++ b/httpsig-hyper/src/hyper_http.rs @@ -286,14 +286,20 @@ where T: SigningKey + Sync, { let req_or_res = RequestOrResponse::Request(self); - let vec_signature_headers_fut = params_key_name.iter().flat_map(|(params, key, name)| { - build_signature_base(&req_or_res, params, None as Option<&Request<()>>) - .map(|base| async move { base.build_signature_headers(*key, *name) }) - }); - let vec_signature_headers = futures::future::join_all(vec_signature_headers_fut) - .await - .into_iter() + let vec_signature_bases = params_key_name + .iter() + .map(|(params, key, name)| { + build_signature_base(&req_or_res, params, None as Option<&Request<()>>).map(|base| (base, *key, *name)) + }) .collect::, _>>()?; + let vec_signature_headers = futures::future::join_all( + vec_signature_bases + .into_iter() + .map(|(base, key, name)| async move { base.build_signature_headers(key, name) }), + ) + .await + .into_iter() + .collect::, _>>()?; vec_signature_headers.iter().try_for_each(|headers| { self .headers_mut() @@ -405,14 +411,18 @@ where { let req_or_res = RequestOrResponse::Response(self); - let vec_signature_headers_fut = params_key_name.iter().flat_map(|(params, key, name)| { - build_signature_base(&req_or_res, params, req_for_param) - .map(|base| async move { base.build_signature_headers(*key, *name) }) - }); - let vec_signature_headers = futures::future::join_all(vec_signature_headers_fut) - .await - .into_iter() + let vec_signature_bases = params_key_name + .iter() + .map(|(params, key, name)| build_signature_base(&req_or_res, params, req_for_param).map(|base| (base, *key, *name))) .collect::, _>>()?; + let vec_signature_headers = futures::future::join_all( + vec_signature_bases + .into_iter() + .map(|(base, key, name)| async move { base.build_signature_headers(key, name) }), + ) + .await + .into_iter() + .collect::, _>>()?; vec_signature_headers.iter().try_for_each(|headers| { self @@ -828,34 +838,24 @@ fn extract_derived_component( // - `name`: only valid on `@query-param` // - `req`: only valid on response messages (to reference request-derived components, §2.4) // - `sf`, `key`, `bs`, `tr`: only valid on HTTP field components, not derived components - for param in id.params.0.iter() { - match param { - HttpMessageComponentParam::Name(_) => { - if !matches!(derived_id, DerivedComponentName::QueryParam) { - return Err(HyperSigError::InvalidComponentParam( - "`name` parameter is only allowed for `@query-param`".to_string(), - )); - } - } - HttpMessageComponentParam::Req => { - // `req` is only meaningful in response signatures (RFC 9421 §2.4). - // `build_signature_base` already validates this and re-dispatches extraction - // against the original request, so `req_or_res` here should always be - // `Request`. Guard against misuse by callers that bypass `build_signature_base`. - if !matches!(req_or_res, RequestOrResponse::Request(_)) { - return Err(HyperSigError::InvalidComponentParam( - "`req`-tagged component must be extracted from the source request".to_string(), - )); - } - } - _ => { - return Err(HyperSigError::InvalidComponentParam(format!( - "parameter `{}` is not allowed on derived components", - String::from(param.clone()) - ))); - } - } - } + id.params.0.iter().try_for_each(|param| match param { + HttpMessageComponentParam::Name(_) if matches!(derived_id, DerivedComponentName::QueryParam) => Ok(()), + HttpMessageComponentParam::Name(_) => Err(HyperSigError::InvalidComponentParam( + "`name` parameter is only allowed for `@query-param`".to_string(), + )), + // `req` is only meaningful in response signatures (RFC 9421 §2.4). + // `build_signature_base` already validates this and re-dispatches extraction against the + // original request, so `req_or_res` here should always be `Request`. + // Guard against misuse by callers that bypass `build_signature_base`. + HttpMessageComponentParam::Req if matches!(req_or_res, RequestOrResponse::Request(_)) => Ok(()), + HttpMessageComponentParam::Req => Err(HyperSigError::InvalidComponentParam( + "`req`-tagged component must be extracted from the source request".to_string(), + )), + _ => Err(HyperSigError::InvalidComponentParam(format!( + "parameter `{}` is not allowed on derived components", + String::from(param.clone()) + ))), + })?; match req_or_res { RequestOrResponse::Request(_) => { @@ -953,7 +953,7 @@ mod tests { }, *, }; - use http_body_util::Full; + use http_body_util::{BodyExt, Full}; use httpsig::prelude::{AlgorithmName, PublicKey, SecretKey, SharedKey}; type BoxBody = http_body_util::combinators::BoxBody; @@ -1322,4 +1322,159 @@ ii+31DW+YulmysZKQKDvuk96TARuWMO/vDbhk777a2QF3bgNoIj8UPMwnw== let verification_res = res.verify_message_signature_sync(&public_key, None, Some(&req)); assert!(verification_res.is_ok()); } + + // ---- Issue #17: @query-param;name="..." ---- + + /// Helper to build a request with query parameters (no content-digest needed) + fn build_query_request() -> Request { + let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); + Request::builder() + .method("GET") + .uri("https://example.com/path?foo=bar&id=123&x=y") + .header("date", "Sun, 09 May 2021 18:30:00 GMT") + .body(body) + .unwrap() + } + + /// Regression test for issue #17: @query-param;name="id" must produce signature headers (sync) + #[cfg(feature = "blocking")] + #[test] + fn test_query_param_sign_verify_sync() { + let mut req = build_query_request(); + + let covered = ["@method", "\"@query-param\";name=\"id\"", "date"]; + let covered_components = covered + .iter() + .map(|v| HttpMessageComponentId::try_from(*v)) + .collect::, _>>() + .unwrap(); + + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered_components).unwrap(); + signature_params.set_key_info(&secret_key); + + req + .set_message_signature_sync(&signature_params, &secret_key, Some("qp")) + .unwrap(); + + assert!( + req.headers().get("signature-input").is_some(), + "signature-input header is missing" + ); + assert!(req.headers().get("signature").is_some(), "signature header is missing"); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = req.verify_message_signature_sync(&public_key, None); + assert!( + verification_res.is_ok(), + "signature verification failed: {:?}", + verification_res.err() + ); + } + + /// Regression test for issue #17: @query-param;name="id" must produce signature headers (async) + #[tokio::test] + async fn test_query_param_sign_verify_async() { + let mut req = build_query_request(); + + let covered = ["@method", "\"@query-param\";name=\"id\"", "date"]; + let covered_components = covered + .iter() + .map(|v| HttpMessageComponentId::try_from(*v)) + .collect::, _>>() + .unwrap(); + + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered_components).unwrap(); + signature_params.set_key_info(&secret_key); + + req + .set_message_signature(&signature_params, &secret_key, Some("qp")) + .await + .unwrap(); + + assert!( + req.headers().get("signature-input").is_some(), + "signature-input header is missing" + ); + assert!(req.headers().get("signature").is_some(), "signature header is missing"); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = req.verify_message_signature(&public_key, None).await; + assert!( + verification_res.is_ok(), + "signature verification failed: {:?}", + verification_res.err() + ); + } + + // ---- Derived component parameter validation ---- + + #[test] + fn test_extract_derived_component_rejects_name_on_non_query_param() { + let req = build_query_request(); + let req_or_res = RequestOrResponse::Request(&req); + // `@method;name="foo"` is invalid — `name` is only for `@query-param` + let id = HttpMessageComponentId::try_from("\"@method\";name=\"foo\""); + // component_id parsing itself may reject this; if it doesn't, extraction should + if let Ok(id) = id { + let result = extract_derived_component(&req_or_res, &id); + assert!(result.is_err(), "expected error for `name` on `@method`"); + } + } + + #[test] + fn test_extract_derived_component_rejects_sf_on_derived() { + let req = build_query_request(); + let req_or_res = RequestOrResponse::Request(&req); + // `@method;sf` is invalid — `sf` is only for HTTP field components + let id = HttpMessageComponentId::try_from("\"@method\";sf"); + if let Ok(id) = id { + let result = extract_derived_component(&req_or_res, &id); + assert!(result.is_err(), "expected error for `sf` on derived component"); + } + } + + // ---- Error propagation ---- + + #[tokio::test] + async fn test_set_message_signature_propagates_build_error() { + // Use `@status` on a request — this is invalid and must return Err, not Ok + let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); + let mut req: Request = Request::builder() + .method("GET") + .uri("https://example.com/") + .body(body) + .unwrap(); + + let covered = vec![HttpMessageComponentId::try_from("@status").unwrap()]; + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered).unwrap(); + signature_params.set_key_info(&secret_key); + + let result = req + .set_message_signature(&signature_params, &secret_key, None as Option<&str>) + .await; + assert!(result.is_err(), "expected Err when using `@status` on request, got Ok"); + } + + #[cfg(feature = "blocking")] + #[test] + fn test_set_message_signature_sync_propagates_build_error() { + // Same as above but for sync path + let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); + let mut req: Request = Request::builder() + .method("GET") + .uri("https://example.com/") + .body(body) + .unwrap(); + + let covered = vec![HttpMessageComponentId::try_from("@status").unwrap()]; + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered).unwrap(); + signature_params.set_key_info(&secret_key); + + let result = req.set_message_signature_sync(&signature_params, &secret_key, None); + assert!(result.is_err(), "expected Err when using `@status` on request, got Ok"); + } } From 19ac193788186fa7991fc191a4c1d5037a8d7da9 Mon Sep 17 00:00:00 2001 From: Jun Kurihara Date: Sun, 15 Feb 2026 14:31:27 +0900 Subject: [PATCH 6/8] chore(refactor): add tests for validation --- httpsig-hyper/src/hyper_http.rs | 103 ++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/httpsig-hyper/src/hyper_http.rs b/httpsig-hyper/src/hyper_http.rs index 8bb8433..6bca611 100644 --- a/httpsig-hyper/src/hyper_http.rs +++ b/httpsig-hyper/src/hyper_http.rs @@ -1477,4 +1477,107 @@ ii+31DW+YulmysZKQKDvuk96TARuWMO/vDbhk777a2QF3bgNoIj8UPMwnw== let result = req.set_message_signature_sync(&signature_params, &secret_key, None); assert!(result.is_err(), "expected Err when using `@status` on request, got Ok"); } + + // ---- RFC 9421 §2.2: Derived component extraction values ---- + + #[test] + fn test_extract_derived_components_values() { + let req = build_query_request(); + // URI: https://example.com/path?foo=bar&id=123&x=y + let req_or_res = RequestOrResponse::Request(&req); + + // @method (§2.2.1) + let id = HttpMessageComponentId::try_from("@method").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@method\": GET"); + + // @target-uri (§2.2.2) + let id = HttpMessageComponentId::try_from("@target-uri").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@target-uri\": https://example.com/path?foo=bar&id=123&x=y"); + + // @authority (§2.2.3) + let id = HttpMessageComponentId::try_from("@authority").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@authority\": example.com"); + + // @scheme (§2.2.4) + let id = HttpMessageComponentId::try_from("@scheme").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@scheme\": https"); + + // @path (§2.2.6) + let id = HttpMessageComponentId::try_from("@path").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@path\": /path"); + + // @query (§2.2.7) + let id = HttpMessageComponentId::try_from("@query").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@query\": ?foo=bar&id=123&x=y"); + + // @query-param;name="id" (§2.2.8) + let id = HttpMessageComponentId::try_from("\"@query-param\";name=\"id\"").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@query-param\";name=\"id\": 123"); + } + + // ---- RFC 9421 §2.4: @query-param;name="...";req on response ---- + + #[tokio::test] + async fn test_response_with_query_param_req_sign_verify() { + // Build a request with query params + let req = build_query_request(); + // Build a response + let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); + let mut res: Response = Response::builder().status(200).body(body).unwrap(); + + // Response signature covering @status + @query-param;name="id";req + let covered = ["@status", "\"@query-param\";name=\"id\";req"]; + let covered_components = covered + .iter() + .map(|v| HttpMessageComponentId::try_from(*v)) + .collect::, _>>() + .unwrap(); + + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered_components).unwrap(); + signature_params.set_key_info(&secret_key); + + res + .set_message_signature(&signature_params, &secret_key, None, Some(&req)) + .await + .unwrap(); + + assert!(req.headers().get("signature-input").is_none(), "request should not be modified"); + assert!(res.headers().get("signature-input").is_some(), "signature-input header is missing on response"); + assert!(res.headers().get("signature").is_some(), "signature header is missing on response"); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = res.verify_message_signature(&public_key, None, Some(&req)).await; + assert!(verification_res.is_ok(), "signature verification failed: {:?}", verification_res.err()); + } + + // ---- RFC 9421: Response must reject request-derived components without `req` ---- + + #[tokio::test] + async fn test_response_rejects_derived_component_without_req() { + let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); + let mut res: Response = Response::builder().status(200).body(body).unwrap(); + + // `@method` without `req` on a response — must fail + let covered = vec![ + HttpMessageComponentId::try_from("@status").unwrap(), + HttpMessageComponentId::try_from("@method").unwrap(), + ]; + + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered).unwrap(); + signature_params.set_key_info(&secret_key); + + let result = res + .set_message_signature(&signature_params, &secret_key, None, None as Option<&Request<()>>) + .await; + assert!(result.is_err(), "expected Err when using `@method` without `req` on response"); + } } From c14769016efcae80e1259844fadf564140442570 Mon Sep 17 00:00:00 2001 From: Jun Kurihara Date: Sun, 15 Feb 2026 14:39:39 +0900 Subject: [PATCH 7/8] chore(refactor): split test file from hyper_http.rs --- httpsig-hyper/src/hyper_http.rs | 640 +------------------------- httpsig-hyper/src/hyper_http_tests.rs | 634 +++++++++++++++++++++++++ 2 files changed, 636 insertions(+), 638 deletions(-) create mode 100644 httpsig-hyper/src/hyper_http_tests.rs diff --git a/httpsig-hyper/src/hyper_http.rs b/httpsig-hyper/src/hyper_http.rs index 6bca611..cea0f08 100644 --- a/httpsig-hyper/src/hyper_http.rs +++ b/httpsig-hyper/src/hyper_http.rs @@ -943,641 +943,5 @@ fn extract_http_message_component( /* --------------------------------------- */ #[cfg(test)] -mod tests { - - use super::{ - super::{ - error::HyperDigestError, - hyper_content_digest::{RequestContentDigest, ResponseContentDigest}, - ContentDigestType, - }, - *, - }; - use http_body_util::{BodyExt, Full}; - use httpsig::prelude::{AlgorithmName, PublicKey, SecretKey, SharedKey}; - - type BoxBody = http_body_util::combinators::BoxBody; - - const EDDSA_SECRET_KEY: &str = r##"-----BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEIDSHAE++q1BP7T8tk+mJtS+hLf81B0o6CFyWgucDFN/C ------END PRIVATE KEY----- -"##; - const EDDSA_PUBLIC_KEY: &str = r##"-----BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEA1ixMQcxO46PLlgQfYS46ivFd+n0CcDHSKUnuhm3i1O0= ------END PUBLIC KEY----- -"##; - // const EDDSA_KEY_ID: &str = "gjrE7ACMxgzYfFHgabgf4kLTg1eKIdsJ94AiFTFj1is="; - const COVERED_COMPONENTS_REQ: &[&str] = &["@method", "date", "content-type", "content-digest"]; - const COVERED_COMPONENTS_RES: &[&str] = &["@status", "\"@method\";req", "date", "content-type", "\"content-digest\";req"]; - - async fn build_request() -> Request { - let body = Full::new(&b"{\"hello\": \"world\"}"[..]); - let req = Request::builder() - .method("GET") - .uri("https://example.com/parameters?var=this%20is%20a%20big%0Amultiline%20value&bar=with+plus+whitespace&fa%C3%A7ade%22%3A%20=something") - .header("date", "Sun, 09 May 2021 18:30:00 GMT") - .header("content-type", "application/json") - .header("content-type", "application/json-patch+json") - .body(body) - .unwrap(); - req.set_content_digest(&ContentDigestType::Sha256).await.unwrap() - } - - async fn build_response() -> Response { - let body = Full::new(&b"{\"hello\": \"world!!\"}"[..]); - let res = Response::builder() - .status(200) - .header("date", "Sun, 09 May 2021 18:30:00 GMT") - .header("content-type", "application/json") - .header("content-type", "application/json-patch+json") - .body(body) - .unwrap(); - res.set_content_digest(&ContentDigestType::Sha256).await.unwrap() - } - - fn build_covered_components_req() -> Vec { - COVERED_COMPONENTS_REQ - .iter() - .map(|&s| HttpMessageComponentId::try_from(s).unwrap()) - .collect() - } - - fn build_covered_components_res() -> Vec { - COVERED_COMPONENTS_RES - .iter() - .map(|&s| HttpMessageComponentId::try_from(s).unwrap()) - .collect() - } - - #[tokio::test] - async fn test_extract_component_from_request() { - let req = build_request().await; - let req_or_res = RequestOrResponse::Request(&req); - - let component_id_method = HttpMessageComponentId::try_from("\"@method\"").unwrap(); - let component = extract_http_message_component(&req_or_res, &component_id_method).unwrap(); - assert_eq!(component.to_string(), "\"@method\": GET"); - - let component_id = HttpMessageComponentId::try_from("\"date\"").unwrap(); - let component = extract_http_message_component(&req_or_res, &component_id).unwrap(); - assert_eq!(component.to_string(), "\"date\": Sun, 09 May 2021 18:30:00 GMT"); - - let component_id = HttpMessageComponentId::try_from("content-type").unwrap(); - let component = extract_http_field(&req_or_res, &component_id).unwrap(); - assert_eq!( - component.to_string(), - "\"content-type\": application/json, application/json-patch+json" - ); - - let component_id = HttpMessageComponentId::try_from("content-digest").unwrap(); - let component = extract_http_message_component(&req_or_res, &component_id).unwrap(); - assert_eq!( - component.to_string(), - "\"content-digest\": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:" - ); - } - - #[tokio::test] - async fn test_extract_signature_params_from_request() { - let mut req = build_request().await; - let headers = req.headers_mut(); - headers.insert( - "signature-input", - http::HeaderValue::from_static(r##"sig1=("@method" "@authority")"##), - ); - let component_id = HttpMessageComponentId::try_from("@signature-params").unwrap(); - let req_or_res = RequestOrResponse::Request(&req); - let component = extract_http_message_component(&req_or_res, &component_id).unwrap(); - assert_eq!(component.to_string(), "\"@signature-params\": (\"@method\" \"@authority\")"); - assert_eq!(component.value.to_string(), r##"("@method" "@authority")"##); - assert_eq!(component.value.as_field_value(), r##"sig1=("@method" "@authority")"##); - assert_eq!(component.value.as_component_value(), r##"("@method" "@authority")"##); - assert_eq!(component.value.key(), Some("sig1")); - } - - #[tokio::test] - async fn test_build_signature_base_from_request() { - let req = build_request().await; - - const SIGPARA: &str = r##";created=1704972031;alg="ed25519";keyid="gjrE7ACMxgzYfFHgabgf4kLTg1eKIdsJ94AiFTFj1is=""##; - let values = (r##""@method" "content-type" "date" "content-digest""##, SIGPARA); - let signature_params = HttpSignatureParams::try_from(format!("({}){}", values.0, values.1).as_str()).unwrap(); - - let req_or_res = RequestOrResponse::Request(&req); - let signature_base = build_signature_base(&req_or_res, &signature_params, None as Option<&Request<()>>).unwrap(); - assert_eq!( - signature_base.to_string(), - r##""@method": GET -"content-type": application/json, application/json-patch+json -"date": Sun, 09 May 2021 18:30:00 GMT -"content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: -"@signature-params": ("@method" "content-type" "date" "content-digest");created=1704972031;alg="ed25519";keyid="gjrE7ACMxgzYfFHgabgf4kLTg1eKIdsJ94AiFTFj1is=""## - ); - } - - #[tokio::test] - async fn test_extract_tuples_from_request() { - let mut req = build_request().await; - let headers = req.headers_mut(); - headers.insert( - "signature-input", - http::HeaderValue::from_static(r##"sig11=("@method" "@authority");created=1704972031"##), - ); - headers.insert( - "signature", - http::HeaderValue::from_static( - r##"sig11=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:"##, - ), - ); - - let req_or_res = RequestOrResponse::Request(&req); - let tuples = extract_signature_headers_with_name(&req_or_res).unwrap(); - assert_eq!(tuples.len(), 1); - assert_eq!(tuples.get("sig11").unwrap().signature_name(), "sig11"); - assert_eq!( - tuples.get("sig11").unwrap().signature_params().to_string(), - r##"("@method" "@authority");created=1704972031"## - ); - } - - #[tokio::test] - async fn test_set_verify_message_signature_req() { - let mut req = build_request().await; - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); - signature_params.set_key_info(&secret_key); - - req.set_message_signature(&signature_params, &secret_key, None).await.unwrap(); - let signature_input = req.headers().get("signature-input").unwrap().to_str().unwrap(); - assert!(signature_input.starts_with(r##"sig=("@method" "date" "content-type" "content-digest")"##)); - // let signature = req.headers().get("signature").unwrap().to_str().unwrap(); - - let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); - let verification_res = req.verify_message_signature(&public_key, None).await; - assert!(verification_res.is_ok()); - } - - #[tokio::test] - async fn test_set_verify_message_signature_res() { - let req = build_request().await; - let mut res = build_response().await; - - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - - let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_res()).unwrap(); - signature_params.set_key_info(&secret_key); - // let req_or_res = RequestOrResponse::Response(&res); - // let base = build_signature_base(&req_or_res, &signature_params, Some(&req)); - // println!("{}", base.unwrap()); - // // println!("{:#?}", req); - - res - .set_message_signature(&signature_params, &secret_key, None, Some(&req)) - .await - .unwrap(); - // println!("{:#?}", res.headers()); - let signature_input = res.headers().get("signature-input").unwrap().to_str().unwrap(); - assert!(signature_input.starts_with(r##"sig=("@status" "@method";req "date" "content-type" "content-digest";req)"##)); - // let signature = req.headers().get("signature").unwrap().to_str().unwrap(); - - let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); - let verification_res = res.verify_message_signature(&public_key, None, Some(&req)).await; - assert!(verification_res.is_ok()); - } - - #[tokio::test] - async fn test_expired_signature() { - let mut req = build_request().await; - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); - signature_params.set_key_info(&secret_key); - let created = signature_params.created.unwrap(); - signature_params.set_expires(created - 1); - assert!(signature_params.is_expired()); - - req.set_message_signature(&signature_params, &secret_key, None).await.unwrap(); - - let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); - let verification_res = req.verify_message_signature(&public_key, None).await; - assert!(verification_res.is_err()); - } - - #[tokio::test] - async fn test_set_verify_with_signature_name() { - let mut req = build_request().await; - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); - signature_params.set_key_info(&secret_key); - - req - .set_message_signature(&signature_params, &secret_key, Some("custom_sig_name")) - .await - .unwrap(); - - let req_or_res = RequestOrResponse::Request(&req); - let signature_headers_map = extract_signature_headers_with_name(&req_or_res).unwrap(); - assert_eq!(signature_headers_map.len(), 1); - assert_eq!(signature_headers_map[0].signature_name(), "custom_sig_name"); - - let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); - let verification_res = req.verify_message_signature(&public_key, None).await; - assert!(verification_res.is_ok()); - } - - #[tokio::test] - async fn test_set_verify_with_key_id() { - let mut req = build_request().await; - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); - signature_params.set_key_info(&secret_key); - - req.set_message_signature(&signature_params, &secret_key, None).await.unwrap(); - - let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); - let key_id = public_key.key_id(); - let verification_res = req.verify_message_signature(&public_key, Some(&key_id)).await; - assert!(verification_res.is_ok()); - - let verification_res = req.verify_message_signature(&public_key, Some("NotFoundKeyId")).await; - assert!(verification_res.is_err()); - } - - const HMACSHA256_SECRET_KEY: &str = - r##"uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ=="##; - - #[tokio::test] - async fn test_set_verify_with_key_id_hmac_sha256() { - let mut req = build_request().await; - let secret_key = SharedKey::from_base64(&AlgorithmName::HmacSha256, HMACSHA256_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); - signature_params.set_key_info(&secret_key); - // Random nonce is highly recommended for HMAC - signature_params.set_random_nonce(); - - req.set_message_signature(&signature_params, &secret_key, None).await.unwrap(); - - let org_key_id = VerifyingKey::key_id(&secret_key); - let (alg, key_id) = req.get_alg_key_ids().unwrap().into_iter().next().unwrap().1; - let alg = alg.unwrap(); - let key_id = key_id.unwrap(); - assert_eq!(org_key_id, key_id); - let verification_key = SharedKey::from_base64(&alg, HMACSHA256_SECRET_KEY).unwrap(); - let verification_res = req.verify_message_signature(&verification_key, Some(&key_id)).await; - assert!(verification_res.is_ok()); - - let verification_res = req.verify_message_signature(&verification_key, Some("NotFoundKeyId")).await; - assert!(verification_res.is_err()); - } - - #[tokio::test] - async fn test_get_alg_key_ids() { - let mut req = build_request().await; - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); - signature_params.set_key_info(&secret_key); - - req.set_message_signature(&signature_params, &secret_key, None).await.unwrap(); - let key_ids = req.get_alg_key_ids().unwrap(); - assert_eq!(key_ids.len(), 1); - assert_eq!(key_ids[0].0.as_ref().unwrap(), &AlgorithmName::Ed25519); - assert_eq!(key_ids[0].1.as_ref().unwrap(), "gjrE7ACMxgzYfFHgabgf4kLTg1eKIdsJ94AiFTFj1is="); - } - - const P256_SECRET_KEY: &str = r##"-----BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgv7zxW56ojrWwmSo1 -4uOdbVhUfj9Jd+5aZIB9u8gtWnihRANCAARGYsMe0CT6pIypwRvoJlLNs4+cTh2K -L7fUNb5i6WbKxkpAoO+6T3pMBG5Yw7+8NuGTvvtrZAXduA2giPxQ8zCf ------END PRIVATE KEY----- -"##; - const P256_PUBLIC_KEY: &str = r##"-----BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAERmLDHtAk+qSMqcEb6CZSzbOPnE4d -ii+31DW+YulmysZKQKDvuk96TARuWMO/vDbhk777a2QF3bgNoIj8UPMwnw== ------END PUBLIC KEY----- -"##; - #[tokio::test] - async fn test_set_verify_multiple_signatures() { - let mut req = build_request().await; - - let secret_key_eddsa = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params_eddsa = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); - signature_params_eddsa.set_key_info(&secret_key_eddsa); - - let secret_key_p256 = SecretKey::from_pem(&AlgorithmName::EcdsaP256Sha256, P256_SECRET_KEY).unwrap(); - let mut signature_params_hmac = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); - signature_params_hmac.set_key_info(&secret_key_p256); - - let params_key_name = &[ - (&signature_params_eddsa, &secret_key_eddsa, Some("eddsa_sig")), - (&signature_params_hmac, &secret_key_p256, Some("p256_sig")), - ]; - - req.set_message_signatures(params_key_name).await.unwrap(); - - let public_key_eddsa = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); - let public_key_p256 = PublicKey::from_pem(&AlgorithmName::EcdsaP256Sha256, P256_PUBLIC_KEY).unwrap(); - let key_id_eddsa = public_key_eddsa.key_id(); - let key_id_p256 = public_key_p256.key_id(); - - let verification_res = req - .verify_message_signatures(&[ - (&public_key_eddsa, Some(&key_id_eddsa)), - (&public_key_p256, Some(&key_id_p256)), - ]) - .await - .unwrap(); - - assert!(verification_res.len() == 2 && verification_res.iter().all(|r| r.is_ok())); - assert!(verification_res[0].as_ref().unwrap() == "eddsa_sig"); - assert!(verification_res[1].as_ref().unwrap() == "p256_sig"); - } - - #[cfg(feature = "blocking")] - #[test] - fn test_blocking_set_verify_message_signature_req() { - let mut req = futures::executor::block_on(build_request()); - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); - signature_params.set_key_info(&secret_key); - - req.set_message_signature_sync(&signature_params, &secret_key, None).unwrap(); - - let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); - let verification_res = req.verify_message_signature_sync(&public_key, None); - assert!(verification_res.is_ok()); - } - - #[cfg(feature = "blocking")] - #[test] - fn test_blocking_set_verify_message_signature_res() { - let req = futures::executor::block_on(build_request()); - let mut res = futures::executor::block_on(build_response()); - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_res()).unwrap(); - signature_params.set_key_info(&secret_key); - res - .set_message_signature_sync(&signature_params, &secret_key, None, Some(&req)) - .unwrap(); - - let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); - let verification_res = res.verify_message_signature_sync(&public_key, None, Some(&req)); - assert!(verification_res.is_ok()); - } - - // ---- Issue #17: @query-param;name="..." ---- - - /// Helper to build a request with query parameters (no content-digest needed) - fn build_query_request() -> Request { - let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); - Request::builder() - .method("GET") - .uri("https://example.com/path?foo=bar&id=123&x=y") - .header("date", "Sun, 09 May 2021 18:30:00 GMT") - .body(body) - .unwrap() - } - - /// Regression test for issue #17: @query-param;name="id" must produce signature headers (sync) - #[cfg(feature = "blocking")] - #[test] - fn test_query_param_sign_verify_sync() { - let mut req = build_query_request(); - - let covered = ["@method", "\"@query-param\";name=\"id\"", "date"]; - let covered_components = covered - .iter() - .map(|v| HttpMessageComponentId::try_from(*v)) - .collect::, _>>() - .unwrap(); - - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&covered_components).unwrap(); - signature_params.set_key_info(&secret_key); - - req - .set_message_signature_sync(&signature_params, &secret_key, Some("qp")) - .unwrap(); - - assert!( - req.headers().get("signature-input").is_some(), - "signature-input header is missing" - ); - assert!(req.headers().get("signature").is_some(), "signature header is missing"); - - let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); - let verification_res = req.verify_message_signature_sync(&public_key, None); - assert!( - verification_res.is_ok(), - "signature verification failed: {:?}", - verification_res.err() - ); - } - - /// Regression test for issue #17: @query-param;name="id" must produce signature headers (async) - #[tokio::test] - async fn test_query_param_sign_verify_async() { - let mut req = build_query_request(); - - let covered = ["@method", "\"@query-param\";name=\"id\"", "date"]; - let covered_components = covered - .iter() - .map(|v| HttpMessageComponentId::try_from(*v)) - .collect::, _>>() - .unwrap(); - - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&covered_components).unwrap(); - signature_params.set_key_info(&secret_key); - - req - .set_message_signature(&signature_params, &secret_key, Some("qp")) - .await - .unwrap(); - - assert!( - req.headers().get("signature-input").is_some(), - "signature-input header is missing" - ); - assert!(req.headers().get("signature").is_some(), "signature header is missing"); - - let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); - let verification_res = req.verify_message_signature(&public_key, None).await; - assert!( - verification_res.is_ok(), - "signature verification failed: {:?}", - verification_res.err() - ); - } - - // ---- Derived component parameter validation ---- - - #[test] - fn test_extract_derived_component_rejects_name_on_non_query_param() { - let req = build_query_request(); - let req_or_res = RequestOrResponse::Request(&req); - // `@method;name="foo"` is invalid — `name` is only for `@query-param` - let id = HttpMessageComponentId::try_from("\"@method\";name=\"foo\""); - // component_id parsing itself may reject this; if it doesn't, extraction should - if let Ok(id) = id { - let result = extract_derived_component(&req_or_res, &id); - assert!(result.is_err(), "expected error for `name` on `@method`"); - } - } - - #[test] - fn test_extract_derived_component_rejects_sf_on_derived() { - let req = build_query_request(); - let req_or_res = RequestOrResponse::Request(&req); - // `@method;sf` is invalid — `sf` is only for HTTP field components - let id = HttpMessageComponentId::try_from("\"@method\";sf"); - if let Ok(id) = id { - let result = extract_derived_component(&req_or_res, &id); - assert!(result.is_err(), "expected error for `sf` on derived component"); - } - } - - // ---- Error propagation ---- - - #[tokio::test] - async fn test_set_message_signature_propagates_build_error() { - // Use `@status` on a request — this is invalid and must return Err, not Ok - let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); - let mut req: Request = Request::builder() - .method("GET") - .uri("https://example.com/") - .body(body) - .unwrap(); - - let covered = vec![HttpMessageComponentId::try_from("@status").unwrap()]; - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&covered).unwrap(); - signature_params.set_key_info(&secret_key); - - let result = req - .set_message_signature(&signature_params, &secret_key, None as Option<&str>) - .await; - assert!(result.is_err(), "expected Err when using `@status` on request, got Ok"); - } - - #[cfg(feature = "blocking")] - #[test] - fn test_set_message_signature_sync_propagates_build_error() { - // Same as above but for sync path - let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); - let mut req: Request = Request::builder() - .method("GET") - .uri("https://example.com/") - .body(body) - .unwrap(); - - let covered = vec![HttpMessageComponentId::try_from("@status").unwrap()]; - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&covered).unwrap(); - signature_params.set_key_info(&secret_key); - - let result = req.set_message_signature_sync(&signature_params, &secret_key, None); - assert!(result.is_err(), "expected Err when using `@status` on request, got Ok"); - } - - // ---- RFC 9421 §2.2: Derived component extraction values ---- - - #[test] - fn test_extract_derived_components_values() { - let req = build_query_request(); - // URI: https://example.com/path?foo=bar&id=123&x=y - let req_or_res = RequestOrResponse::Request(&req); - - // @method (§2.2.1) - let id = HttpMessageComponentId::try_from("@method").unwrap(); - let c = extract_derived_component(&req_or_res, &id).unwrap(); - assert_eq!(c.to_string(), "\"@method\": GET"); - - // @target-uri (§2.2.2) - let id = HttpMessageComponentId::try_from("@target-uri").unwrap(); - let c = extract_derived_component(&req_or_res, &id).unwrap(); - assert_eq!(c.to_string(), "\"@target-uri\": https://example.com/path?foo=bar&id=123&x=y"); - - // @authority (§2.2.3) - let id = HttpMessageComponentId::try_from("@authority").unwrap(); - let c = extract_derived_component(&req_or_res, &id).unwrap(); - assert_eq!(c.to_string(), "\"@authority\": example.com"); - - // @scheme (§2.2.4) - let id = HttpMessageComponentId::try_from("@scheme").unwrap(); - let c = extract_derived_component(&req_or_res, &id).unwrap(); - assert_eq!(c.to_string(), "\"@scheme\": https"); - - // @path (§2.2.6) - let id = HttpMessageComponentId::try_from("@path").unwrap(); - let c = extract_derived_component(&req_or_res, &id).unwrap(); - assert_eq!(c.to_string(), "\"@path\": /path"); - - // @query (§2.2.7) - let id = HttpMessageComponentId::try_from("@query").unwrap(); - let c = extract_derived_component(&req_or_res, &id).unwrap(); - assert_eq!(c.to_string(), "\"@query\": ?foo=bar&id=123&x=y"); - - // @query-param;name="id" (§2.2.8) - let id = HttpMessageComponentId::try_from("\"@query-param\";name=\"id\"").unwrap(); - let c = extract_derived_component(&req_or_res, &id).unwrap(); - assert_eq!(c.to_string(), "\"@query-param\";name=\"id\": 123"); - } - - // ---- RFC 9421 §2.4: @query-param;name="...";req on response ---- - - #[tokio::test] - async fn test_response_with_query_param_req_sign_verify() { - // Build a request with query params - let req = build_query_request(); - // Build a response - let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); - let mut res: Response = Response::builder().status(200).body(body).unwrap(); - - // Response signature covering @status + @query-param;name="id";req - let covered = ["@status", "\"@query-param\";name=\"id\";req"]; - let covered_components = covered - .iter() - .map(|v| HttpMessageComponentId::try_from(*v)) - .collect::, _>>() - .unwrap(); - - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&covered_components).unwrap(); - signature_params.set_key_info(&secret_key); - - res - .set_message_signature(&signature_params, &secret_key, None, Some(&req)) - .await - .unwrap(); - - assert!(req.headers().get("signature-input").is_none(), "request should not be modified"); - assert!(res.headers().get("signature-input").is_some(), "signature-input header is missing on response"); - assert!(res.headers().get("signature").is_some(), "signature header is missing on response"); - - let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); - let verification_res = res.verify_message_signature(&public_key, None, Some(&req)).await; - assert!(verification_res.is_ok(), "signature verification failed: {:?}", verification_res.err()); - } - - // ---- RFC 9421: Response must reject request-derived components without `req` ---- - - #[tokio::test] - async fn test_response_rejects_derived_component_without_req() { - let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); - let mut res: Response = Response::builder().status(200).body(body).unwrap(); - - // `@method` without `req` on a response — must fail - let covered = vec![ - HttpMessageComponentId::try_from("@status").unwrap(), - HttpMessageComponentId::try_from("@method").unwrap(), - ]; - - let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); - let mut signature_params = HttpSignatureParams::try_new(&covered).unwrap(); - signature_params.set_key_info(&secret_key); - - let result = res - .set_message_signature(&signature_params, &secret_key, None, None as Option<&Request<()>>) - .await; - assert!(result.is_err(), "expected Err when using `@method` without `req` on response"); - } -} +#[path = "hyper_http_tests.rs"] +mod tests; diff --git a/httpsig-hyper/src/hyper_http_tests.rs b/httpsig-hyper/src/hyper_http_tests.rs new file mode 100644 index 0000000..ebee294 --- /dev/null +++ b/httpsig-hyper/src/hyper_http_tests.rs @@ -0,0 +1,634 @@ +use super::{ + super::{ + error::HyperDigestError, + hyper_content_digest::{RequestContentDigest, ResponseContentDigest}, + ContentDigestType, + }, + *, +}; +use http_body_util::{BodyExt, Full}; +use httpsig::prelude::{AlgorithmName, PublicKey, SecretKey, SharedKey}; + +type BoxBody = http_body_util::combinators::BoxBody; + +const EDDSA_SECRET_KEY: &str = r##"-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIDSHAE++q1BP7T8tk+mJtS+hLf81B0o6CFyWgucDFN/C +-----END PRIVATE KEY----- +"##; +const EDDSA_PUBLIC_KEY: &str = r##"-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA1ixMQcxO46PLlgQfYS46ivFd+n0CcDHSKUnuhm3i1O0= +-----END PUBLIC KEY----- +"##; +// const EDDSA_KEY_ID: &str = "gjrE7ACMxgzYfFHgabgf4kLTg1eKIdsJ94AiFTFj1is="; +const COVERED_COMPONENTS_REQ: &[&str] = &["@method", "date", "content-type", "content-digest"]; +const COVERED_COMPONENTS_RES: &[&str] = &["@status", "\"@method\";req", "date", "content-type", "\"content-digest\";req"]; + +async fn build_request() -> Request { + let body = Full::new(&b"{\"hello\": \"world\"}"[..]); + let req = Request::builder() + .method("GET") + .uri("https://example.com/parameters?var=this%20is%20a%20big%0Amultiline%20value&bar=with+plus+whitespace&fa%C3%A7ade%22%3A%20=something") + .header("date", "Sun, 09 May 2021 18:30:00 GMT") + .header("content-type", "application/json") + .header("content-type", "application/json-patch+json") + .body(body) + .unwrap(); + req.set_content_digest(&ContentDigestType::Sha256).await.unwrap() +} + +async fn build_response() -> Response { + let body = Full::new(&b"{\"hello\": \"world!!\"}"[..]); + let res = Response::builder() + .status(200) + .header("date", "Sun, 09 May 2021 18:30:00 GMT") + .header("content-type", "application/json") + .header("content-type", "application/json-patch+json") + .body(body) + .unwrap(); + res.set_content_digest(&ContentDigestType::Sha256).await.unwrap() +} + +fn build_covered_components_req() -> Vec { + COVERED_COMPONENTS_REQ + .iter() + .map(|&s| HttpMessageComponentId::try_from(s).unwrap()) + .collect() +} + +fn build_covered_components_res() -> Vec { + COVERED_COMPONENTS_RES + .iter() + .map(|&s| HttpMessageComponentId::try_from(s).unwrap()) + .collect() +} + +/// Helper to build a request with query parameters (no content-digest needed) +fn build_query_request() -> Request { + let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); + Request::builder() + .method("GET") + .uri("https://example.com/path?foo=bar&id=123&x=y") + .header("date", "Sun, 09 May 2021 18:30:00 GMT") + .body(body) + .unwrap() +} + +// ---- Component extraction ---- + +#[tokio::test] +async fn test_extract_component_from_request() { + let req = build_request().await; + let req_or_res = RequestOrResponse::Request(&req); + + let component_id_method = HttpMessageComponentId::try_from("\"@method\"").unwrap(); + let component = extract_http_message_component(&req_or_res, &component_id_method).unwrap(); + assert_eq!(component.to_string(), "\"@method\": GET"); + + let component_id = HttpMessageComponentId::try_from("\"date\"").unwrap(); + let component = extract_http_message_component(&req_or_res, &component_id).unwrap(); + assert_eq!(component.to_string(), "\"date\": Sun, 09 May 2021 18:30:00 GMT"); + + let component_id = HttpMessageComponentId::try_from("content-type").unwrap(); + let component = extract_http_field(&req_or_res, &component_id).unwrap(); + assert_eq!( + component.to_string(), + "\"content-type\": application/json, application/json-patch+json" + ); + + let component_id = HttpMessageComponentId::try_from("content-digest").unwrap(); + let component = extract_http_message_component(&req_or_res, &component_id).unwrap(); + assert_eq!( + component.to_string(), + "\"content-digest\": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:" + ); +} + +#[tokio::test] +async fn test_extract_signature_params_from_request() { + let mut req = build_request().await; + let headers = req.headers_mut(); + headers.insert( + "signature-input", + http::HeaderValue::from_static(r##"sig1=("@method" "@authority")"##), + ); + let component_id = HttpMessageComponentId::try_from("@signature-params").unwrap(); + let req_or_res = RequestOrResponse::Request(&req); + let component = extract_http_message_component(&req_or_res, &component_id).unwrap(); + assert_eq!(component.to_string(), "\"@signature-params\": (\"@method\" \"@authority\")"); + assert_eq!(component.value.to_string(), r##"("@method" "@authority")"##); + assert_eq!(component.value.as_field_value(), r##"sig1=("@method" "@authority")"##); + assert_eq!(component.value.as_component_value(), r##"("@method" "@authority")"##); + assert_eq!(component.value.key(), Some("sig1")); +} + +#[tokio::test] +async fn test_build_signature_base_from_request() { + let req = build_request().await; + + const SIGPARA: &str = r##";created=1704972031;alg="ed25519";keyid="gjrE7ACMxgzYfFHgabgf4kLTg1eKIdsJ94AiFTFj1is=""##; + let values = (r##""@method" "content-type" "date" "content-digest""##, SIGPARA); + let signature_params = HttpSignatureParams::try_from(format!("({}){}", values.0, values.1).as_str()).unwrap(); + + let req_or_res = RequestOrResponse::Request(&req); + let signature_base = build_signature_base(&req_or_res, &signature_params, None as Option<&Request<()>>).unwrap(); + assert_eq!( + signature_base.to_string(), + r##""@method": GET +"content-type": application/json, application/json-patch+json +"date": Sun, 09 May 2021 18:30:00 GMT +"content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: +"@signature-params": ("@method" "content-type" "date" "content-digest");created=1704972031;alg="ed25519";keyid="gjrE7ACMxgzYfFHgabgf4kLTg1eKIdsJ94AiFTFj1is=""## + ); +} + +#[tokio::test] +async fn test_extract_tuples_from_request() { + let mut req = build_request().await; + let headers = req.headers_mut(); + headers.insert( + "signature-input", + http::HeaderValue::from_static(r##"sig11=("@method" "@authority");created=1704972031"##), + ); + headers.insert( + "signature", + http::HeaderValue::from_static( + r##"sig11=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:"##, + ), + ); + + let req_or_res = RequestOrResponse::Request(&req); + let tuples = extract_signature_headers_with_name(&req_or_res).unwrap(); + assert_eq!(tuples.len(), 1); + assert_eq!(tuples.get("sig11").unwrap().signature_name(), "sig11"); + assert_eq!( + tuples.get("sig11").unwrap().signature_params().to_string(), + r##"("@method" "@authority");created=1704972031"## + ); +} + +// ---- Sign and verify ---- + +#[tokio::test] +async fn test_set_verify_message_signature_req() { + let mut req = build_request().await; + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); + signature_params.set_key_info(&secret_key); + + req.set_message_signature(&signature_params, &secret_key, None).await.unwrap(); + let signature_input = req.headers().get("signature-input").unwrap().to_str().unwrap(); + assert!(signature_input.starts_with(r##"sig=("@method" "date" "content-type" "content-digest")"##)); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = req.verify_message_signature(&public_key, None).await; + assert!(verification_res.is_ok()); +} + +#[tokio::test] +async fn test_set_verify_message_signature_res() { + let req = build_request().await; + let mut res = build_response().await; + + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + + let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_res()).unwrap(); + signature_params.set_key_info(&secret_key); + + res + .set_message_signature(&signature_params, &secret_key, None, Some(&req)) + .await + .unwrap(); + let signature_input = res.headers().get("signature-input").unwrap().to_str().unwrap(); + assert!(signature_input.starts_with(r##"sig=("@status" "@method";req "date" "content-type" "content-digest";req)"##)); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = res.verify_message_signature(&public_key, None, Some(&req)).await; + assert!(verification_res.is_ok()); +} + +#[tokio::test] +async fn test_expired_signature() { + let mut req = build_request().await; + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); + signature_params.set_key_info(&secret_key); + let created = signature_params.created.unwrap(); + signature_params.set_expires(created - 1); + assert!(signature_params.is_expired()); + + req.set_message_signature(&signature_params, &secret_key, None).await.unwrap(); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = req.verify_message_signature(&public_key, None).await; + assert!(verification_res.is_err()); +} + +#[tokio::test] +async fn test_set_verify_with_signature_name() { + let mut req = build_request().await; + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); + signature_params.set_key_info(&secret_key); + + req + .set_message_signature(&signature_params, &secret_key, Some("custom_sig_name")) + .await + .unwrap(); + + let req_or_res = RequestOrResponse::Request(&req); + let signature_headers_map = extract_signature_headers_with_name(&req_or_res).unwrap(); + assert_eq!(signature_headers_map.len(), 1); + assert_eq!(signature_headers_map[0].signature_name(), "custom_sig_name"); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = req.verify_message_signature(&public_key, None).await; + assert!(verification_res.is_ok()); +} + +#[tokio::test] +async fn test_set_verify_with_key_id() { + let mut req = build_request().await; + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); + signature_params.set_key_info(&secret_key); + + req.set_message_signature(&signature_params, &secret_key, None).await.unwrap(); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let key_id = public_key.key_id(); + let verification_res = req.verify_message_signature(&public_key, Some(&key_id)).await; + assert!(verification_res.is_ok()); + + let verification_res = req.verify_message_signature(&public_key, Some("NotFoundKeyId")).await; + assert!(verification_res.is_err()); +} + +const HMACSHA256_SECRET_KEY: &str = + r##"uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ=="##; + +#[tokio::test] +async fn test_set_verify_with_key_id_hmac_sha256() { + let mut req = build_request().await; + let secret_key = SharedKey::from_base64(&AlgorithmName::HmacSha256, HMACSHA256_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); + signature_params.set_key_info(&secret_key); + // Random nonce is highly recommended for HMAC + signature_params.set_random_nonce(); + + req.set_message_signature(&signature_params, &secret_key, None).await.unwrap(); + + let org_key_id = VerifyingKey::key_id(&secret_key); + let (alg, key_id) = req.get_alg_key_ids().unwrap().into_iter().next().unwrap().1; + let alg = alg.unwrap(); + let key_id = key_id.unwrap(); + assert_eq!(org_key_id, key_id); + let verification_key = SharedKey::from_base64(&alg, HMACSHA256_SECRET_KEY).unwrap(); + let verification_res = req.verify_message_signature(&verification_key, Some(&key_id)).await; + assert!(verification_res.is_ok()); + + let verification_res = req.verify_message_signature(&verification_key, Some("NotFoundKeyId")).await; + assert!(verification_res.is_err()); +} + +#[tokio::test] +async fn test_get_alg_key_ids() { + let mut req = build_request().await; + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); + signature_params.set_key_info(&secret_key); + + req.set_message_signature(&signature_params, &secret_key, None).await.unwrap(); + let key_ids = req.get_alg_key_ids().unwrap(); + assert_eq!(key_ids.len(), 1); + assert_eq!(key_ids[0].0.as_ref().unwrap(), &AlgorithmName::Ed25519); + assert_eq!(key_ids[0].1.as_ref().unwrap(), "gjrE7ACMxgzYfFHgabgf4kLTg1eKIdsJ94AiFTFj1is="); +} + +const P256_SECRET_KEY: &str = r##"-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgv7zxW56ojrWwmSo1 +4uOdbVhUfj9Jd+5aZIB9u8gtWnihRANCAARGYsMe0CT6pIypwRvoJlLNs4+cTh2K +L7fUNb5i6WbKxkpAoO+6T3pMBG5Yw7+8NuGTvvtrZAXduA2giPxQ8zCf +-----END PRIVATE KEY----- +"##; +const P256_PUBLIC_KEY: &str = r##"-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAERmLDHtAk+qSMqcEb6CZSzbOPnE4d +ii+31DW+YulmysZKQKDvuk96TARuWMO/vDbhk777a2QF3bgNoIj8UPMwnw== +-----END PUBLIC KEY----- +"##; +#[tokio::test] +async fn test_set_verify_multiple_signatures() { + let mut req = build_request().await; + + let secret_key_eddsa = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params_eddsa = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); + signature_params_eddsa.set_key_info(&secret_key_eddsa); + + let secret_key_p256 = SecretKey::from_pem(&AlgorithmName::EcdsaP256Sha256, P256_SECRET_KEY).unwrap(); + let mut signature_params_hmac = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); + signature_params_hmac.set_key_info(&secret_key_p256); + + let params_key_name = &[ + (&signature_params_eddsa, &secret_key_eddsa, Some("eddsa_sig")), + (&signature_params_hmac, &secret_key_p256, Some("p256_sig")), + ]; + + req.set_message_signatures(params_key_name).await.unwrap(); + + let public_key_eddsa = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let public_key_p256 = PublicKey::from_pem(&AlgorithmName::EcdsaP256Sha256, P256_PUBLIC_KEY).unwrap(); + let key_id_eddsa = public_key_eddsa.key_id(); + let key_id_p256 = public_key_p256.key_id(); + + let verification_res = req + .verify_message_signatures(&[ + (&public_key_eddsa, Some(&key_id_eddsa)), + (&public_key_p256, Some(&key_id_p256)), + ]) + .await + .unwrap(); + + assert!(verification_res.len() == 2 && verification_res.iter().all(|r| r.is_ok())); + assert!(verification_res[0].as_ref().unwrap() == "eddsa_sig"); + assert!(verification_res[1].as_ref().unwrap() == "p256_sig"); +} + +// ---- Blocking (sync) ---- + +#[cfg(feature = "blocking")] +#[test] +fn test_blocking_set_verify_message_signature_req() { + let mut req = futures::executor::block_on(build_request()); + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_req()).unwrap(); + signature_params.set_key_info(&secret_key); + + req.set_message_signature_sync(&signature_params, &secret_key, None).unwrap(); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = req.verify_message_signature_sync(&public_key, None); + assert!(verification_res.is_ok()); +} + +#[cfg(feature = "blocking")] +#[test] +fn test_blocking_set_verify_message_signature_res() { + let req = futures::executor::block_on(build_request()); + let mut res = futures::executor::block_on(build_response()); + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&build_covered_components_res()).unwrap(); + signature_params.set_key_info(&secret_key); + res + .set_message_signature_sync(&signature_params, &secret_key, None, Some(&req)) + .unwrap(); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = res.verify_message_signature_sync(&public_key, None, Some(&req)); + assert!(verification_res.is_ok()); +} + +// ---- Issue #17: @query-param;name="..." ---- + +/// Regression test for issue #17: @query-param;name="id" must produce signature headers (sync) +#[cfg(feature = "blocking")] +#[test] +fn test_query_param_sign_verify_sync() { + let mut req = build_query_request(); + + let covered = ["@method", "\"@query-param\";name=\"id\"", "date"]; + let covered_components = covered + .iter() + .map(|v| HttpMessageComponentId::try_from(*v)) + .collect::, _>>() + .unwrap(); + + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered_components).unwrap(); + signature_params.set_key_info(&secret_key); + + req + .set_message_signature_sync(&signature_params, &secret_key, Some("qp")) + .unwrap(); + + assert!( + req.headers().get("signature-input").is_some(), + "signature-input header is missing" + ); + assert!(req.headers().get("signature").is_some(), "signature header is missing"); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = req.verify_message_signature_sync(&public_key, None); + assert!( + verification_res.is_ok(), + "signature verification failed: {:?}", + verification_res.err() + ); +} + +/// Regression test for issue #17: @query-param;name="id" must produce signature headers (async) +#[tokio::test] +async fn test_query_param_sign_verify_async() { + let mut req = build_query_request(); + + let covered = ["@method", "\"@query-param\";name=\"id\"", "date"]; + let covered_components = covered + .iter() + .map(|v| HttpMessageComponentId::try_from(*v)) + .collect::, _>>() + .unwrap(); + + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered_components).unwrap(); + signature_params.set_key_info(&secret_key); + + req + .set_message_signature(&signature_params, &secret_key, Some("qp")) + .await + .unwrap(); + + assert!( + req.headers().get("signature-input").is_some(), + "signature-input header is missing" + ); + assert!(req.headers().get("signature").is_some(), "signature header is missing"); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = req.verify_message_signature(&public_key, None).await; + assert!( + verification_res.is_ok(), + "signature verification failed: {:?}", + verification_res.err() + ); +} + +// ---- Derived component parameter validation ---- + +#[test] +fn test_extract_derived_component_rejects_name_on_non_query_param() { + let req = build_query_request(); + let req_or_res = RequestOrResponse::Request(&req); + // `@method;name="foo"` is invalid — `name` is only for `@query-param` + let id = HttpMessageComponentId::try_from("\"@method\";name=\"foo\""); + // component_id parsing itself may reject this; if it doesn't, extraction should + if let Ok(id) = id { + let result = extract_derived_component(&req_or_res, &id); + assert!(result.is_err(), "expected error for `name` on `@method`"); + } +} + +#[test] +fn test_extract_derived_component_rejects_sf_on_derived() { + let req = build_query_request(); + let req_or_res = RequestOrResponse::Request(&req); + // `@method;sf` is invalid — `sf` is only for HTTP field components + let id = HttpMessageComponentId::try_from("\"@method\";sf"); + if let Ok(id) = id { + let result = extract_derived_component(&req_or_res, &id); + assert!(result.is_err(), "expected error for `sf` on derived component"); + } +} + +// ---- Error propagation (Bug #2 regression) ---- + +#[tokio::test] +async fn test_set_message_signature_propagates_build_error() { + // Use `@status` on a request — this is invalid and must return Err, not Ok + let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); + let mut req: Request = Request::builder() + .method("GET") + .uri("https://example.com/") + .body(body) + .unwrap(); + + let covered = vec![HttpMessageComponentId::try_from("@status").unwrap()]; + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered).unwrap(); + signature_params.set_key_info(&secret_key); + + let result = req + .set_message_signature(&signature_params, &secret_key, None as Option<&str>) + .await; + assert!(result.is_err(), "expected Err when using `@status` on request, got Ok"); +} + +#[cfg(feature = "blocking")] +#[test] +fn test_set_message_signature_sync_propagates_build_error() { + // Same as above but for sync path + let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); + let mut req: Request = Request::builder() + .method("GET") + .uri("https://example.com/") + .body(body) + .unwrap(); + + let covered = vec![HttpMessageComponentId::try_from("@status").unwrap()]; + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered).unwrap(); + signature_params.set_key_info(&secret_key); + + let result = req.set_message_signature_sync(&signature_params, &secret_key, None); + assert!(result.is_err(), "expected Err when using `@status` on request, got Ok"); +} + +// ---- RFC 9421 §2.2: Derived component extraction values ---- + +#[test] +fn test_extract_derived_components_values() { + let req = build_query_request(); + // URI: https://example.com/path?foo=bar&id=123&x=y + let req_or_res = RequestOrResponse::Request(&req); + + // @method (§2.2.1) + let id = HttpMessageComponentId::try_from("@method").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@method\": GET"); + + // @target-uri (§2.2.2) + let id = HttpMessageComponentId::try_from("@target-uri").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@target-uri\": https://example.com/path?foo=bar&id=123&x=y"); + + // @authority (§2.2.3) + let id = HttpMessageComponentId::try_from("@authority").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@authority\": example.com"); + + // @scheme (§2.2.4) + let id = HttpMessageComponentId::try_from("@scheme").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@scheme\": https"); + + // @path (§2.2.6) + let id = HttpMessageComponentId::try_from("@path").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@path\": /path"); + + // @query (§2.2.7) + let id = HttpMessageComponentId::try_from("@query").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@query\": ?foo=bar&id=123&x=y"); + + // @query-param;name="id" (§2.2.8) + let id = HttpMessageComponentId::try_from("\"@query-param\";name=\"id\"").unwrap(); + let c = extract_derived_component(&req_or_res, &id).unwrap(); + assert_eq!(c.to_string(), "\"@query-param\";name=\"id\": 123"); +} + +// ---- RFC 9421 §2.4: @query-param;name="...";req on response ---- + +#[tokio::test] +async fn test_response_with_query_param_req_sign_verify() { + // Build a request with query params + let req = build_query_request(); + // Build a response + let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); + let mut res: Response = Response::builder().status(200).body(body).unwrap(); + + // Response signature covering @status + @query-param;name="id";req + let covered = ["@status", "\"@query-param\";name=\"id\";req"]; + let covered_components = covered + .iter() + .map(|v| HttpMessageComponentId::try_from(*v)) + .collect::, _>>() + .unwrap(); + + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered_components).unwrap(); + signature_params.set_key_info(&secret_key); + + res + .set_message_signature(&signature_params, &secret_key, None, Some(&req)) + .await + .unwrap(); + + assert!(req.headers().get("signature-input").is_none(), "request should not be modified"); + assert!(res.headers().get("signature-input").is_some(), "signature-input header is missing on response"); + assert!(res.headers().get("signature").is_some(), "signature header is missing on response"); + + let public_key = PublicKey::from_pem(&AlgorithmName::Ed25519, EDDSA_PUBLIC_KEY).unwrap(); + let verification_res = res.verify_message_signature(&public_key, None, Some(&req)).await; + assert!(verification_res.is_ok(), "signature verification failed: {:?}", verification_res.err()); +} + +// ---- RFC 9421: Response must reject request-derived components without `req` ---- + +#[tokio::test] +async fn test_response_rejects_derived_component_without_req() { + let body = Full::new(bytes::Bytes::new()).map_err(|never| match never {}).boxed(); + let mut res: Response = Response::builder().status(200).body(body).unwrap(); + + // `@method` without `req` on a response — must fail + let covered = vec![ + HttpMessageComponentId::try_from("@status").unwrap(), + HttpMessageComponentId::try_from("@method").unwrap(), + ]; + + let secret_key = SecretKey::from_pem(&AlgorithmName::Ed25519, EDDSA_SECRET_KEY).unwrap(); + let mut signature_params = HttpSignatureParams::try_new(&covered).unwrap(); + signature_params.set_key_info(&secret_key); + + let result = res + .set_message_signature(&signature_params, &secret_key, None, None as Option<&Request<()>>) + .await; + assert!(result.is_err(), "expected Err when using `@method` without `req` on response"); +} From 0a1c6206ebc6d7cb794cbce835bfe387b997e83f Mon Sep 17 00:00:00 2001 From: Jun Kurihara Date: Sun, 15 Feb 2026 14:57:59 +0900 Subject: [PATCH 8/8] bump --- Cargo.toml | 2 +- httpsig-hyper/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 81f2267..bcafee7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.0.23" +version = "0.0.24" authors = ["Jun Kurihara"] homepage = "https://github.com/junkurihara/httpsig-rs" repository = "https://github.com/junkurihara/httpsig-rs" diff --git a/httpsig-hyper/Cargo.toml b/httpsig-hyper/Cargo.toml index 0b4a8fd..0c5b0f1 100644 --- a/httpsig-hyper/Cargo.toml +++ b/httpsig-hyper/Cargo.toml @@ -19,7 +19,7 @@ rsa-signature = ["httpsig/rsa-signature"] [dependencies] -httpsig = { path = "../httpsig", version = "0.0.23" } +httpsig = { path = "../httpsig", version = "0.0.24" } thiserror = { version = "2.0.18" } tracing = { version = "0.1.44" }