diff --git a/README.md b/README.md index 3d42b57..5745cf1 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,13 @@ async fn main() { assert!(res.is_ok()) // receiver verifies the request with a signature - let verification_res = receiver(request_from_sender).await; + let verified_message = receiver(&request_from_sender).await; assert!(verification_res.is_ok()); assert!(verification_res.unwrap()) + + // if needed, content-digest can be verified separately + let verified_cd = request_from_sender.verify_content_digest().await.unwrap(); + assert!(verified); } ``` diff --git a/httpsig-hyper/Cargo.toml b/httpsig-hyper/Cargo.toml index 1473596..754fa45 100644 --- a/httpsig-hyper/Cargo.toml +++ b/httpsig-hyper/Cargo.toml @@ -20,8 +20,9 @@ async-trait = { version = "0.1.77" } indexmap = { version = "2.2.2" } fxhash = { version = "0.2.1" } -# content digest +# content digest with rfc8941 structured field values sha2 = { version = "0.10.8", default-features = false } +sfv = "0.9.4" # encoding base64 = { version = "0.21.7" } @@ -32,6 +33,7 @@ http-body = { version = "1.0.0" } http-body-util = { version = "0.1.0" } bytes = { version = "1.5.0" } + [dev-dependencies] tokio = { version = "1.36.0", default-features = false, features = [ "macros", diff --git a/httpsig-hyper/README.md b/httpsig-hyper/README.md index 8dd5a7e..d97dd99 100644 --- a/httpsig-hyper/README.md +++ b/httpsig-hyper/README.md @@ -13,4 +13,18 @@ You can run a basic example in [./examples](./examples/) as follows. ## Caveats -Note that even if `content-digest` header is specified as one of covered component for signature, the verification process of `httpsig-hyper` doesn't validate the message body. Namely, it only check the consistency between the signature and message components. +Note that even if `content-digest` header is specified as one of covered component for signature, the verification process of `httpsig-hyper` doesn't validate the message body automatically. Namely, it only check the consistency between the signature and message components. + +If you need to verify the body of a given message when `content-digest` is covered in `signature-input` header, you need to invoke `verify_content_digest()` function as follows. + +```:rust: +// first verifies the signature according to `signature-input` header +let public_key = PublicKey::from_pem(EDDSA_PUBLIC_KEY).unwrap(); +let signature_verification = req.verify_message_signature(&public_key, None).await.unwrap(); +assert!(verification_res); + +// if needed, content-digest can be verified separately (only if content-digest header is included in the header) +let verified = request_from_sender.verify_content_digest().await.unwrap(); +``` + +In the context of cryptography, the content-digest of *covered components* in signature-input is verified in the process of signature verification. So, hash value of `content-digest` is verified. To check if the `content-digest` is correctly bound with the message body, we need to run the hashing process separately. diff --git a/httpsig-hyper/examples/hyper.rs b/httpsig-hyper/examples/hyper.rs index 7bf1443..c232abe 100644 --- a/httpsig-hyper/examples/hyper.rs +++ b/httpsig-hyper/examples/hyper.rs @@ -51,7 +51,7 @@ async fn sender() -> Request> { } /// Receiver function that verifies a request with a signature -async fn receiver(req: Request>) -> bool { +async fn receiver(req: &Request>) -> bool { let public_key = PublicKey::from_pem(EDDSA_PUBLIC_KEY).unwrap(); let key_id = public_key.key_id(); @@ -75,6 +75,10 @@ async fn main() { assert!(signature.starts_with(r##"custom_sig_name=:"##)); // receiver verifies the request with a signature - let verification_res = receiver(request_from_sender).await; + let verification_res = receiver(&request_from_sender).await; assert!(verification_res); + + // if needed, content-digest can be verified separately + let verified = request_from_sender.verify_content_digest().await.unwrap(); + assert!(verified); } diff --git a/httpsig-hyper/src/hyper_content_digest.rs b/httpsig-hyper/src/hyper_content_digest.rs index f7ed9ce..b4801fe 100644 --- a/httpsig-hyper/src/hyper_content_digest.rs +++ b/httpsig-hyper/src/hyper_content_digest.rs @@ -1,10 +1,12 @@ use super::{ContentDigestType, CONTENT_DIGEST_HEADER}; +use anyhow::ensure; use async_trait::async_trait; use base64::{engine::general_purpose, Engine as _}; use bytes::{Buf, Bytes}; use http::{Request, Response}; use http_body::Body; use http_body_util::{BodyExt, Full}; +use sfv::FromStr; use sha2::Digest; // hyper's http specific extension to generate and verify http signature @@ -45,6 +47,30 @@ pub trait ContentDigest: http_body::Body { Ok((body_bytes, general_purpose::STANDARD.encode(digest))) } + + /// Verifies the consistency between self and given content-digest in &[u8] + async fn verify_digest(self, cd_type: &ContentDigestType, _cd: &[u8]) -> std::result::Result + where + Self: Sized, + Self::Data: Send, + { + let body_bytes = self.into_bytes().await?; + let digest = match cd_type { + ContentDigestType::Sha256 => { + let mut hasher = sha2::Sha256::new(); + hasher.update(&body_bytes); + hasher.finalize().to_vec() + } + + ContentDigestType::Sha512 => { + let mut hasher = sha2::Sha512::new(); + hasher.update(&body_bytes); + hasher.finalize().to_vec() + } + }; + + Ok(matches!(digest, _cd)) + } } impl ContentDigest for T where T: http_body::Body {} @@ -57,6 +83,9 @@ pub trait RequestContentDigest { async fn set_content_digest(self, cd_type: &ContentDigestType) -> std::result::Result>, Self::Error> where Self: Sized; + async fn verify_content_digest(self) -> std::result::Result + where + Self: Sized; } #[async_trait] @@ -66,6 +95,9 @@ pub trait ResponseContentDigest { async fn set_content_digest(self, cd_type: &ContentDigestType) -> std::result::Result>, Self::Error> where Self: Sized; + async fn verify_content_digest(self) -> std::result::Result + where + Self: Sized; } #[async_trait] @@ -94,6 +126,19 @@ where let new_req = Request::from_parts(parts, new_body); Ok(new_req) } + + async fn verify_content_digest(self) -> std::result::Result + where + Self: Sized, + { + let header_map = self.headers(); + let (cd_type, cd) = extract_content_digest(header_map).await?; + let (_, body) = self.into_parts(); + body + .verify_digest(&cd_type, &cd) + .await + .map_err(|_e| anyhow::anyhow!("Failed to verify digest")) + } } #[async_trait] @@ -122,6 +167,48 @@ where let new_req = Response::from_parts(parts, new_body); Ok(new_req) } + async fn verify_content_digest(self) -> std::result::Result + where + Self: Sized, + { + let header_map = self.headers(); + let (cd_type, cd) = extract_content_digest(header_map).await?; + let (_, body) = self.into_parts(); + body + .verify_digest(&cd_type, &cd) + .await + .map_err(|_e| anyhow::anyhow!("Failed to verify digest")) + } +} + +async fn extract_content_digest(header_map: &http::HeaderMap) -> anyhow::Result<(ContentDigestType, Vec)> { + let content_digest_header = header_map + .get(CONTENT_DIGEST_HEADER) + .ok_or(anyhow::anyhow!("Content-Digest header not found"))? + .to_str()?; + let indexmap = sfv::Parser::parse_dictionary(content_digest_header.as_bytes()) + .map_err(|e| anyhow::anyhow!("Failed to parse Content-Digest header: {e}"))?; + ensure!(indexmap.len() == 1, "Content-Digest header should have only one value"); + let (cd_type, cd) = indexmap.iter().next().unwrap(); + let cd_type = ContentDigestType::from_str(cd_type).map_err(|e| anyhow::anyhow!("Invalid Content-Digest type: {e}"))?; + ensure!( + matches!( + cd, + sfv::ListEntry::Item(sfv::Item { + bare_item: sfv::BareItem::ByteSeq(_), + .. + }) + ), + "Invalid Content-Digest value" + ); + let cd = match cd { + sfv::ListEntry::Item(sfv::Item { + bare_item: sfv::BareItem::ByteSeq(cd), + .. + }) => cd, + _ => unreachable!(), + }; + Ok((cd_type, cd.to_owned())) } /* --------------------------------------- */ @@ -159,6 +246,9 @@ mod tests { assert!(req.headers().contains_key(CONTENT_DIGEST_HEADER)); let digest = req.headers().get(CONTENT_DIGEST_HEADER).unwrap().to_str().unwrap(); assert_eq!(digest, format!("sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:")); + + let verified = req.verify_content_digest().await.unwrap(); + assert!(verified); } #[tokio::test] @@ -176,5 +266,8 @@ mod tests { assert!(res.headers().contains_key(CONTENT_DIGEST_HEADER)); let digest = res.headers().get(CONTENT_DIGEST_HEADER).unwrap().to_str().unwrap(); assert_eq!(digest, format!("sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:")); + + let verified = res.verify_content_digest().await.unwrap(); + assert!(verified); } } diff --git a/httpsig-hyper/src/lib.rs b/httpsig-hyper/src/lib.rs index f4d40b4..f3f3d3d 100644 --- a/httpsig-hyper/src/lib.rs +++ b/httpsig-hyper/src/lib.rs @@ -21,6 +21,17 @@ impl std::fmt::Display for ContentDigestType { } } +impl std::str::FromStr for ContentDigestType { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + match s { + "sha-256" => Ok(ContentDigestType::Sha256), + "sha-512" => Ok(ContentDigestType::Sha512), + _ => Err(anyhow::anyhow!("Invalid content-digest type")), + } + } +} + pub use httpsig::prelude; pub use hyper_content_digest::{ContentDigest, RequestContentDigest}; pub use hyper_http::RequestMessageSignature;