Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
2 changes: 2 additions & 0 deletions .rustfmt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
edition = "2021"
tab_spaces = 2
13 changes: 12 additions & 1 deletion httpsig-hyper/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ rust-version.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[features]
default = ["blocking"]
default = ["blocking", "digest-sha256", "digest-sha512"]
digest-sha256 = []
digest-sha512 = []
blocking = ["futures/executor"]
rsa-signature = ["httpsig/rsa-signature"]

Expand Down Expand Up @@ -49,3 +51,12 @@ tokio = { version = "1.49.0", default-features = false, features = [
"macros",
"rt-multi-thread",
] } # testing only

[[example]]
name = "hyper-response"
required-features = ["digest-sha256"]


[[example]]
name = "hyper-request"
required-features = ["digest-sha256"]
149 changes: 113 additions & 36 deletions httpsig-hyper/src/hyper_content_digest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ pub trait ContentDigest: http_body::Body {
/// Returns the digest of the given body in Vec<u8>
fn derive_digest(body_bytes: &Bytes, cd_type: &ContentDigestType) -> Vec<u8> {
match cd_type {
#[cfg(feature = "digest-sha256")]
ContentDigestType::Sha256 => {
let mut hasher = sha2::Sha256::new();
hasher.update(body_bytes);
hasher.finalize().to_vec()
}

#[cfg(feature = "digest-sha512")]
ContentDigestType::Sha512 => {
let mut hasher = sha2::Sha512::new();
hasher.update(body_bytes);
Expand All @@ -75,7 +77,9 @@ pub trait RequestContentDigest {
Self: Sized;

/// Verify the content digest in the request and returns self if it's valid otherwise returns error
fn verify_content_digest(self) -> impl Future<Output = Result<Self::PassthroughRequest, Self::Error>> + Send
fn verify_content_digest(
self,
) -> impl Future<Output = Result<Self::PassthroughRequest, Self::Error>> + Send
where
Self: Sized;
}
Expand All @@ -94,7 +98,9 @@ pub trait ResponseContentDigest {
Self: Sized;

/// Verify the content digest in the response and returns self if it's valid otherwise returns error
fn verify_content_digest(self) -> impl Future<Output = Result<Self::PassthroughResponse, Self::Error>> + Send
fn verify_content_digest(
self,
) -> impl Future<Output = Result<Self::PassthroughResponse, Self::Error>> + Send
where
Self: Sized;
}
Expand All @@ -108,7 +114,10 @@ where
type PassthroughRequest = Request<BoxBody<Bytes, Self::Error>>;

/// Set the content digest in the request
async fn set_content_digest(self, cd_type: &ContentDigestType) -> HyperDigestResult<Self::PassthroughRequest>
async fn set_content_digest(
self,
cd_type: &ContentDigestType,
) -> HyperDigestResult<Self::PassthroughRequest>
where
Self: Sized,
{
Expand All @@ -117,11 +126,14 @@ where
.into_bytes_with_digest(cd_type)
.await
.map_err(|_e| HyperDigestError::HttpBodyError("Failed to generate digest".to_string()))?;
let new_body = Full::new(body_bytes).map_err(|never| match never {}).boxed();
let new_body = Full::new(body_bytes)
.map_err(|never| match never {})
.boxed();

parts
.headers
.insert(CONTENT_DIGEST_HEADER, format!("{cd_type}=:{digest}:").parse().unwrap());
parts.headers.insert(
CONTENT_DIGEST_HEADER,
format!("{cd_type}=:{digest}:").parse().unwrap(),
);

let new_req = Request::from_parts(parts, new_body);
Ok(new_req)
Expand All @@ -144,7 +156,9 @@ where

// 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 new_body = Full::new(body_bytes)
.map_err(|never| match never {})
.boxed();
let res = Request::from_parts(header, new_body);
Ok(res)
} else {
Expand All @@ -163,7 +177,10 @@ where
type Error = HyperDigestError;
type PassthroughResponse = Response<BoxBody<Bytes, Self::Error>>;

async fn set_content_digest(self, cd_type: &ContentDigestType) -> HyperDigestResult<Self::PassthroughResponse>
async fn set_content_digest(
self,
cd_type: &ContentDigestType,
) -> HyperDigestResult<Self::PassthroughResponse>
where
Self: Sized,
{
Expand All @@ -172,11 +189,14 @@ where
.into_bytes_with_digest(cd_type)
.await
.map_err(|_e| HyperDigestError::HttpBodyError("Failed to generate digest".to_string()))?;
let new_body = Full::new(body_bytes).map_err(|never| match never {}).boxed();
let new_body = Full::new(body_bytes)
.map_err(|never| match never {})
.boxed();

parts
.headers
.insert(CONTENT_DIGEST_HEADER, format!("{cd_type}=:{digest}:").parse().unwrap());
parts.headers.insert(
CONTENT_DIGEST_HEADER,
format!("{cd_type}=:{digest}:").parse().unwrap(),
);

let new_req = Response::from_parts(parts, new_body);
Ok(new_req)
Expand All @@ -196,7 +216,9 @@ where

// 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 new_body = Full::new(body_bytes)
.map_err(|never| match never {})
.boxed();
let res = Response::from_parts(header, new_body);
Ok(res)
} else {
Expand All @@ -217,10 +239,14 @@ fn is_equal_digest(digest1: &[u8], digest2: &[u8]) -> bool {
digest1.ct_eq(digest2).into()
}

async fn extract_content_digest(header_map: &http::HeaderMap) -> HyperDigestResult<(ContentDigestType, Vec<u8>)> {
async fn extract_content_digest(
header_map: &http::HeaderMap,
) -> HyperDigestResult<(ContentDigestType, Vec<u8>)> {
let content_digest_header = header_map
.get(CONTENT_DIGEST_HEADER)
.ok_or(HyperDigestError::NoDigestHeader("No content-digest header".to_string()))?
.ok_or(HyperDigestError::NoDigestHeader(
"No content-digest header".to_string(),
))?
.to_str()?;
let indexmap = sfv::Parser::new(content_digest_header)
.parse::<sfv::Dictionary>()
Expand All @@ -231,8 +257,9 @@ async fn extract_content_digest(header_map: &http::HeaderMap) -> HyperDigestResu
));
};
let (cd_type, cd) = indexmap.iter().next().unwrap();
let cd_type = ContentDigestType::from_str(cd_type.as_str())
.map_err(|e| HyperDigestError::InvalidHeaderValue(format!("Invalid Content-Digest type: {e}")))?;
let cd_type = ContentDigestType::from_str(cd_type.as_str()).map_err(|e| {
HyperDigestError::InvalidHeaderValue(format!("Invalid Content-Digest type: {e}"))
})?;
if !matches!(
cd,
sfv::ListEntry::Item(sfv::Item {
Expand Down Expand Up @@ -263,17 +290,30 @@ mod tests {
#[tokio::test]
async fn content_digest() {
let body = Full::new(&b"{\"hello\": \"world\"}"[..]);
let (_body_bytes, digest) = body.into_bytes_with_digest(&ContentDigestType::Sha256).await.unwrap();

assert_eq!(digest, "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=");
#[cfg(feature = "digest-sha256")]
{
let (_body_bytes, digest) = body
.into_bytes_with_digest(&ContentDigestType::Sha256)
.await
.unwrap();

assert_eq!(digest, "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=");
}

let (_body_bytes, digest) = body.into_bytes_with_digest(&ContentDigestType::Sha512).await.unwrap();
assert_eq!(
digest,
"WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew=="
);
#[cfg(feature = "digest-sha512")]
{
let (_body_bytes, digest) = body
.into_bytes_with_digest(&ContentDigestType::Sha512)
.await
.unwrap();
assert_eq!(
digest,
"WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew=="
);
}
}

#[cfg(feature = "digest-sha256")]
#[tokio::test]
async fn hyper_request_test() {
let body = Full::new(&b"{\"hello\": \"world\"}"[..]);
Expand All @@ -285,16 +325,28 @@ mod tests {
.header("content-type", "application/json")
.body(body)
.unwrap();
let req = req.set_content_digest(&ContentDigestType::Sha256).await.unwrap();
let req = req
.set_content_digest(&ContentDigestType::Sha256)
.await
.unwrap();

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 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;
assert!(verified.is_ok());
}

#[cfg(feature = "digest-sha256")]
#[tokio::test]
async fn hyper_response_test() {
let body = Full::new(&b"{\"hello\": \"world\"}"[..]);
Expand All @@ -305,16 +357,28 @@ mod tests {
.header("content-type", "application/json")
.body(body)
.unwrap();
let res = res.set_content_digest(&ContentDigestType::Sha256).await.unwrap();
let res = res
.set_content_digest(&ContentDigestType::Sha256)
.await
.unwrap();

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 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;
assert!(verified.is_ok());
}

#[cfg(feature = "digest-sha256")]
#[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
Expand All @@ -327,7 +391,10 @@ mod tests {
.body(body)
.unwrap();

let req = req.set_content_digest(&ContentDigestType::Sha256).await.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
Expand All @@ -344,6 +411,7 @@ mod tests {
}
}

#[cfg(feature = "digest-sha256")]
#[tokio::test]
async fn hyper_response_digest_mismatch_by_header_tamper_should_fail() {
// 1) Create a response and set a correct Content-Digest
Expand All @@ -355,15 +423,20 @@ mod tests {
.body(body)
.unwrap();

let res = res.set_content_digest(&ContentDigestType::Sha256).await.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(),
"sha-256=:Y48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"
.parse()
.unwrap(),
);

let tampered_res = Response::from_parts(parts, body);
Expand Down Expand Up @@ -397,6 +470,7 @@ mod tests {
}
}

#[cfg(feature = "digest-sha256")]
#[tokio::test]
async fn hyper_request_digest_length_mismatch_should_fail() {
// 1) Create a request and attach a valid Content-Digest header
Expand All @@ -409,7 +483,10 @@ mod tests {
.body(body)
.unwrap();

let req = req.set_content_digest(&ContentDigestType::Sha256).await.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.
Expand Down
Loading