diff --git a/services/aws-core/src/lib.rs b/services/aws-core/src/lib.rs index ec6db1bf..44ea8b82 100644 --- a/services/aws-core/src/lib.rs +++ b/services/aws-core/src/lib.rs @@ -29,6 +29,9 @@ pub use region::IMDSv2RegionProvider; pub mod assume_role; pub use assume_role::AssumeRoleGrant; +mod percent_encoding_mode; +pub use percent_encoding_mode::PercentEncodingMode; + mod credential; pub use credential::Credential; diff --git a/services/aws-core/src/percent_encoding_mode.rs b/services/aws-core/src/percent_encoding_mode.rs new file mode 100644 index 00000000..097b1538 --- /dev/null +++ b/services/aws-core/src/percent_encoding_mode.rs @@ -0,0 +1,40 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/// Percent-encoding applied to the URI path in an AWS canonical request. +/// +/// Requests must already contain a wire-ready URI. This setting affects only the +/// canonical URI used for signing; it does not rewrite the request URI, normalize +/// path segments, or change query encoding. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum PercentEncodingMode { + /// Keep the existing single-encoding behavior, suitable for Amazon S3. + /// + /// Each path segment is percent-decoded as UTF-8, then encoded using AWS's + /// unreserved character set. For example, `/a%20b` stays `/a%20b`, `%2f` + /// becomes `%2F`, and `%7E` becomes `~`. Invalid UTF-8 escapes are rejected. + /// This remains the default for all services for backwards compatibility. + #[default] + Single, + /// Apply one additional encoding pass to the caller's wire path. + /// + /// Existing escapes are encoded without decoding them first: `/a%20b` + /// becomes `/a%2520b`, `%2f` becomes `%252f`, and `%FF` becomes `%25FF`. + /// Literal `/` separators remain unchanged; literal `@` and `=` become + /// `%40` and `%3D`. Use this for services that require double URI encoding. + Double, +} diff --git a/services/aws-core/src/signing.rs b/services/aws-core/src/signing.rs index 355b6f51..64a0bab2 100644 --- a/services/aws-core/src/signing.rs +++ b/services/aws-core/src/signing.rs @@ -19,28 +19,44 @@ use std::fmt::Write; use std::time::Duration; use http::{HeaderValue, Uri, header}; -use percent_encoding::{percent_decode_str, utf8_percent_encode}; +use percent_encoding::{AsciiSet, percent_decode_str, utf8_percent_encode}; use reqsign_core::time::Timestamp; use reqsign_core::{Result, SigningRequest}; -use crate::Credential; use crate::constants::{ AWS_QUERY_ENCODE_SET, AWS_URI_ENCODE_SET, X_AMZ_CONTENT_SHA_256, X_AMZ_DATE, X_AMZ_S3_SESSION_TOKEN, X_AMZ_SECURITY_TOKEN, }; +use crate::{Credential, PercentEncodingMode}; + +static AWS_URI_DOUBLE_ENCODE_SET: AsciiSet = AWS_URI_ENCODE_SET.remove(b'/'); /// Build the canonical request shared by AWS SigV4-family algorithms. pub fn canonical_request_string( request: &SigningRequest, canonical_query: &[(String, String)], +) -> Result { + canonical_request_string_with_encoding(request, canonical_query, PercentEncodingMode::Single) +} + +/// Build an AWS canonical request with the selected URI percent-encoding mode. +/// +/// The mode only affects the canonical URI, not the request's wire URI or query. +pub fn canonical_request_string_with_encoding( + request: &SigningRequest, + canonical_query: &[(String, String)], + percent_encoding_mode: PercentEncodingMode, ) -> Result { let mut output = String::with_capacity(256); writeln!(output, "{}", request.method) .map_err(|e| reqsign_core::Error::unexpected(format!("failed to write method: {e}")))?; - writeln!(output, "{}", canonical_uri(&request.path)?).map_err(|e| { - reqsign_core::Error::unexpected(format!("failed to write encoded path: {e}")) - })?; + writeln!( + output, + "{}", + canonical_uri_with_encoding(&request.path, percent_encoding_mode)? + ) + .map_err(|e| reqsign_core::Error::unexpected(format!("failed to write encoded path: {e}")))?; writeln!( output, "{}", @@ -194,8 +210,26 @@ pub fn canonicalize_query( query } -/// Encode a wire-ready path for use in an AWS canonical request. +/// Encode a wire-ready path with the default single-encoding behavior. pub fn canonical_uri(path: &str) -> Result { + canonical_uri_with_encoding(path, PercentEncodingMode::Single) +} + +/// Encode a wire-ready path for use in an AWS canonical request. +/// +/// This does not normalize path segments or modify the original wire path. +pub fn canonical_uri_with_encoding( + path: &str, + percent_encoding_mode: PercentEncodingMode, +) -> Result { + if percent_encoding_mode == PercentEncodingMode::Double { + // The input is already the caller's encoded wire path. Do not decode it: + // doing so would lose percent-escape spelling and reject non-UTF-8 bytes. + return Ok(utf8_percent_encode(path, &AWS_URI_DOUBLE_ENCODE_SET).to_string()); + } + + // Keep the existing single-encoding behavior, including escape normalization + // and rejection of percent-encoded non-UTF-8 bytes, for S3 compatibility. path.split('/') .map(|segment| { let decoded = percent_decode_str(segment).decode_utf8().map_err(|e| { @@ -246,3 +280,92 @@ pub fn append_query_fragment(uri: &Uri, fragment: &str) -> Result { reqsign_core::Error::request_invalid("failed to append signing query").with_source(e) }) } + +#[cfg(test)] +mod tests { + use super::*; + use http::Request; + + #[test] + fn canonical_uri_encoding_modes() -> Result<()> { + let cases = [ + ("/", "/", "/"), + ("/a%25b", "/a%25b", "/a%2525b"), + ("/a%2Fb", "/a%2Fb", "/a%252Fb"), + ("/a%20b", "/a%20b", "/a%2520b"), + ("/a%40b", "/a%40b", "/a%2540b"), + ("/a%3Db", "/a%3Db", "/a%253Db"), + ("/a@b=c", "/a%40b%3Dc", "/a%40b%3Dc"), + ("/a%2fb%3d", "/a%2Fb%3D", "/a%252fb%253d"), + ("/%7E/%41", "/~/A", "/%257E/%2541"), + ( + "/%E4%B8%AD%E6%96%87", + "/%E4%B8%AD%E6%96%87", + "/%25E4%25B8%25AD%25E6%2596%2587", + ), + ("/a//./b/../c/", "/a//./b/../c/", "/a//./b/../c/"), + ("/-._~AZaz09", "/-._~AZaz09", "/-._~AZaz09"), + ]; + + assert_eq!(PercentEncodingMode::default(), PercentEncodingMode::Single); + for (wire_path, single, double) in cases { + assert_eq!(canonical_uri(wire_path)?, single, "default: {wire_path}"); + assert_eq!( + canonical_uri_with_encoding(wire_path, PercentEncodingMode::Single)?, + single, + "single: {wire_path}" + ); + assert_eq!( + canonical_uri_with_encoding(wire_path, PercentEncodingMode::Double)?, + double, + "double: {wire_path}" + ); + } + Ok(()) + } + + #[test] + fn double_encoding_does_not_decode_non_utf8_escapes() -> Result<()> { + for (wire_path, double) in [("/%FF", "/%25FF"), ("/%C3%28", "/%25C3%2528")] { + assert!(canonical_uri(wire_path).is_err()); + assert!(canonical_uri_with_encoding(wire_path, PercentEncodingMode::Single).is_err()); + assert_eq!( + canonical_uri_with_encoding(wire_path, PercentEncodingMode::Double)?, + double + ); + } + Ok(()) + } + + #[test] + fn encoding_changes_only_the_canonical_uri() -> Result<()> { + let mut parts = Request::get("https://example.com/a%2Fb?key=%25") + .header("x-amz-content-sha256", "UNSIGNED-PAYLOAD") + .body(()) + .expect("request must be valid") + .into_parts() + .0; + let original_uri = parts.uri.clone(); + let request = SigningRequest::build(&mut parts)?; + let canonical_query = canonicalize_query(&request, &[]); + let default = canonical_request_string(&request, &canonical_query)?; + let single = canonical_request_string_with_encoding( + &request, + &canonical_query, + PercentEncodingMode::Single, + )?; + let double = canonical_request_string_with_encoding( + &request, + &canonical_query, + PercentEncodingMode::Double, + )?; + + assert_eq!(default, single); + assert_eq!(single.lines().nth(1), Some("/a%2Fb")); + assert_eq!(double.lines().nth(1), Some("/a%252Fb")); + assert_eq!(double, single.replacen("/a%2Fb", "/a%252Fb", 1)); + assert_eq!(request.path, "/a%2Fb"); + assert_eq!(parts.uri, original_uri); + Ok(()) + } +} diff --git a/services/aws-v4/README.md b/services/aws-v4/README.md index a318748c..fb57f958 100644 --- a/services/aws-v4/README.md +++ b/services/aws-v4/README.md @@ -34,6 +34,29 @@ async fn main() -> anyhow::Result<()> { } ``` +## Canonical URI Encoding + +The signer defaults to `PercentEncodingMode::Single`, preserving existing S3 +behavior. For a service that requires double URI encoding, select it explicitly: + +```rust +use reqsign_aws_v4::{PercentEncodingMode, RequestSigner}; + +let signer = RequestSigner::new("execute-api", "us-east-1") + .with_percent_encoding_mode(PercentEncodingMode::Double); +``` + +Both header signing and presigning use this setting. Supply the final wire-ready +URI: a path of `/a%20b` is signed as `/a%2520b` in Double mode, while the outgoing +path remains `/a%20b`. Double encodes the original path without decoding it; a +literal `@` becomes `%40`, while an existing `%40` becomes `%2540`. It preserves +literal `/` separators and does not normalize paths or change payload hashing. +The service name and endpoint do not automatically select a mode. + +Single retains Reqsign's existing per-segment decode/re-encode behavior, including +normalizing escape spelling and rejecting percent-encoded invalid UTF-8. It is +not a raw-path pass-through for noncanonical wire paths. + ## Default Credential Chain `DefaultCredentialProvider::new()` builds the documented AWS default chain: diff --git a/services/aws-v4/src/lib.rs b/services/aws-v4/src/lib.rs index fb3cb5ce..a5d68bdd 100644 --- a/services/aws-v4/src/lib.rs +++ b/services/aws-v4/src/lib.rs @@ -166,6 +166,7 @@ pub use provide_credential::{ S3ExpressSessionGranter, S3ExpressSessionMode, S3ExpressSessionPartition, S3ExpressSessionProvider, }; +pub use reqsign_aws_core::PercentEncodingMode; pub use reqsign_aws_core::constants; pub use reqsign_aws_core::{ AssumeRoleCredentialProvider, AssumeRoleGrant, AssumeRoleWithWebIdentityCredentialProvider, diff --git a/services/aws-v4/src/sign_request.rs b/services/aws-v4/src/sign_request.rs index 0552cc5b..1e1f2e55 100644 --- a/services/aws-v4/src/sign_request.rs +++ b/services/aws-v4/src/sign_request.rs @@ -15,13 +15,13 @@ // specific language governing permissions and limitations // under the License. -use crate::Credential; +use crate::{Credential, PercentEncodingMode}; use http::request::Parts; use http::{HeaderValue, header}; use log::debug; use reqsign_aws_core::signing::{ - append_query_fragment, append_query_pairs, canonical_request_string, canonicalize_headers, - canonicalize_headers_with_standard_session_token, canonicalize_query, + append_query_fragment, append_query_pairs, canonical_request_string_with_encoding, + canonicalize_headers, canonicalize_headers_with_standard_session_token, canonicalize_query, }; use reqsign_core::hash::{hex_hmac_sha256, hex_sha256, hmac_sha256}; use reqsign_core::time::Timestamp; @@ -38,6 +38,7 @@ const CREDENTIAL_OPERATION_HEADROOM: Duration = Duration::from_secs(10); pub struct RequestSigner { service: String, region: String, + percent_encoding_mode: PercentEncodingMode, use_standard_session_token: bool, time: Option, @@ -49,6 +50,7 @@ impl RequestSigner { Self { service: service.into(), region: region.into(), + percent_encoding_mode: PercentEncodingMode::Single, use_standard_session_token: false, time: None, @@ -90,6 +92,27 @@ impl RequestSigner { self } + /// Set the canonical URI percent-encoding mode for header and query signing. + /// + /// The default is [`PercentEncodingMode::Single`] for compatibility with S3 + /// and existing callers. Select [`PercentEncodingMode::Double`] when the + /// target service requires another encoding pass over the wire-ready path. + /// This does not modify the outgoing URI, normalize paths, or change payload + /// hashing. The mode is not inferred from the service name or endpoint. + /// + /// # Example + /// + /// ``` + /// use reqsign_aws_v4::{PercentEncodingMode, RequestSigner}; + /// + /// let signer = RequestSigner::new("execute-api", "us-east-1") + /// .with_percent_encoding_mode(PercentEncodingMode::Double); + /// ``` + pub fn with_percent_encoding_mode(mut self, mode: PercentEncodingMode) -> Self { + self.percent_encoding_mode = mode; + self + } + /// Specify the signing time. /// /// # Note @@ -170,7 +193,11 @@ impl SignRequest for RequestSigner { let canonical_query = canonicalize_query(&signed_req, &authentication_query); // build canonical request and string to sign. - let creq = canonical_request_string(&signed_req, &canonical_query)?; + let creq = canonical_request_string_with_encoding( + &signed_req, + &canonical_query, + self.percent_encoding_mode, + )?; let encoded_req = hex_sha256(creq.as_bytes()); // Scope: "20220313///aws4_request" @@ -306,20 +333,22 @@ fn generate_signing_key(secret: &str, time: Timestamp, region: &str, service: &s #[cfg(test)] mod tests { use super::*; - use crate::StaticCredentialProvider; + use crate::{PercentEncodingMode, StaticCredentialProvider}; use anyhow::Result; use aws_credential_types::Credentials; use aws_sigv4::http_request::PayloadChecksumKind; - use aws_sigv4::http_request::PercentEncodingMode; + use aws_sigv4::http_request::PercentEncodingMode as AwsPercentEncodingMode; + use aws_sigv4::http_request::SessionTokenMode; use aws_sigv4::http_request::SignableBody; use aws_sigv4::http_request::SignableRequest; use aws_sigv4::http_request::SignatureLocation; use aws_sigv4::http_request::SigningSettings; + use aws_sigv4::http_request::UriPathNormalizationMode; use aws_sigv4::sign::v4; use http::Request; use http::header; use reqsign_aws_core::constants::X_AMZ_CONTENT_SHA_256; - use reqsign_aws_core::signing::canonical_uri; + use reqsign_aws_core::signing::{canonical_request_string, canonical_uri}; use reqsign_core::{ErrorKind, ProvideCredential, Signer}; use reqsign_file_read_tokio::TokioFileRead; use reqsign_http_send_reqwest::ReqwestHttpSend; @@ -351,7 +380,7 @@ mod tests { session_token_name_override: Option<&'static str>, ) -> Result> { let mut settings = SigningSettings::default(); - settings.percent_encoding_mode = PercentEncodingMode::Double; + settings.percent_encoding_mode = AwsPercentEncodingMode::Double; settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; settings.session_token_name_override = session_token_name_override; if presigned { @@ -951,6 +980,300 @@ mod tests { assert_eq!(format_query(l), format_query(r), "{name} query mismatch"); } + fn encoding_request(uri: &str) -> Result> { + Ok(Request::get(uri) + .header("x-custom", "encoding-test") + .body("")?) + } + + fn aws_signed_encoding_request( + uri: &str, + now: Timestamp, + mode: PercentEncodingMode, + presigned: bool, + ) -> Result> { + let mut request = encoding_request(uri)?; + let mut settings = SigningSettings::default(); + settings.percent_encoding_mode = match mode { + PercentEncodingMode::Single => AwsPercentEncodingMode::Single, + PercentEncodingMode::Double => AwsPercentEncodingMode::Double, + }; + // Match reqsign's other signing choices so only URI encoding varies. + settings.uri_path_normalization_mode = UriPathNormalizationMode::Disabled; + settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; + settings.session_token_mode = SessionTokenMode::Include; + settings.session_token_name_override = None; + settings.excluded_headers = None; + settings.signature_location = if presigned { + SignatureLocation::QueryParams + } else { + SignatureLocation::Headers + }; + settings.expires_in = presigned.then_some(Duration::from_secs(60)); + + let identity = Credentials::new( + TEST_ACCESS_KEY, + TEST_SECRET_KEY, + Some(TEST_SESSION_TOKEN.to_string()), + None, + "hardcoded-credentials", + ) + .into(); + let params = v4::SigningParams::builder() + .identity(&identity) + .region("us-east-1") + .name("execute-api") + .time(now.as_system_time()) + .settings(settings) + .build() + .expect("signing params must be valid"); + let output = aws_sigv4::http_request::sign( + SignableRequest::new( + request.method().as_str(), + request.uri().to_string(), + request + .headers() + .iter() + .map(|(name, value)| (name.as_str(), value.to_str().unwrap())), + SignableBody::UnsignedPayload, + )?, + ¶ms.into(), + )?; + let (instructions, _) = output.into_parts(); + instructions.apply_to_request_http1x(&mut request); + Ok(request) + } + + async fn reqsign_signed_encoding_request( + uri: &str, + now: Timestamp, + mode: Option, + presigned: bool, + ) -> Result> { + let credential = Credential { + access_key_id: TEST_ACCESS_KEY.to_string(), + secret_access_key: TEST_SECRET_KEY.to_string(), + session_token: Some(TEST_SESSION_TOKEN.to_string()), + expires_in: Some(now + Duration::from_secs(3600)), + }; + let mut signer = RequestSigner::new("execute-api", "us-east-1").with_time(now); + if let Some(mode) = mode { + signer = signer.with_percent_encoding_mode(mode); + } + let (mut parts, body) = encoding_request(uri)?.into_parts(); + signer + .sign_request( + &Context::new(), + &mut parts, + Some(&credential), + presigned.then_some(Duration::from_secs(60)), + ) + .await?; + Ok(Request::from_parts(parts, body)) + } + + async fn check_encoding_signatures_match_aws_sigv4(presigned: bool) -> Result<()> { + let now: Timestamp = "2026-07-22T00:00:00Z".parse()?; + // Both implementations agree on Single for canonically encoded wire + // paths. Noncanonical inputs are covered separately below. + let encoded_paths = [ + "/", + "/percent%25", + "/encoded%2Fslash", + "/space%20here", + "/user%40example.com", + "/key%3Dvalue", + "/%E4%BD%A0%E5%A5%BD/%C3%A9", + "/literal%252Fescape", + "/unreserved-._~/a//b/./c/../d/", + ]; + let double_only_paths = [ + "/user@example.com/key=value", + "/lowercase%2fslash/%c3%a9", + "/encoded%7Etilde", + "/invalid-utf8%FF", + "/a//b/./c/../d/", + ]; + const QUERY: &str = "z=last&slash=%2f&dup=two&dup=one&empty=&flag&double=%252F"; + + for mode in [PercentEncodingMode::Single, PercentEncodingMode::Double] { + let mut paths = encoded_paths.to_vec(); + if mode == PercentEncodingMode::Double { + paths.extend(double_only_paths); + } + for path in paths { + let uri = format!("https://example.com{path}?{QUERY}"); + let expected = aws_signed_encoding_request(&uri, now, mode, presigned)?; + let actual = + reqsign_signed_encoding_request(&uri, now, Some(mode), presigned).await?; + let name = format!("{mode:?}, presigned={presigned}, path={path}"); + compare_request(&name, &expected, &actual); + assert_eq!(actual.uri().path(), path, "{name} changed wire path"); + if presigned { + assert!( + actual.uri().to_string().starts_with(&format!("{uri}&")), + "{name} changed existing wire query" + ); + assert!(actual.uri().query().unwrap().contains("X-Amz-Signature=")); + } else { + assert_eq!(actual.uri().to_string(), uri, "{name} changed wire URI"); + } + } + } + Ok(()) + } + + #[tokio::test] + async fn canonical_uri_encoding_header_signatures_match_aws_sigv4() -> Result<()> { + check_encoding_signatures_match_aws_sigv4(false).await + } + + #[tokio::test] + async fn canonical_uri_encoding_query_signatures_match_aws_sigv4() -> Result<()> { + check_encoding_signatures_match_aws_sigv4(true).await + } + + #[tokio::test] + async fn canonical_uri_encoding_defaults_to_single_and_preserves_wire_bytes() -> Result<()> { + let now: Timestamp = "2026-07-22T00:00:00Z".parse()?; + let path = "/a//b/./c/../user@example.com/%2f/%7E/key=value/%25"; + let uri = format!("https://example.com{path}?{RAW_QUERY}"); + + for presigned in [false, true] { + let default = reqsign_signed_encoding_request(&uri, now, None, presigned).await?; + let single = reqsign_signed_encoding_request( + &uri, + now, + Some(PercentEncodingMode::Single), + presigned, + ) + .await?; + let double = reqsign_signed_encoding_request( + &uri, + now, + Some(PercentEncodingMode::Double), + presigned, + ) + .await?; + assert_eq!(default.headers(), single.headers()); + assert_eq!(default.uri(), single.uri()); + if presigned { + assert_ne!(single.uri(), double.uri()); + } else { + assert_ne!( + single.headers()[header::AUTHORIZATION], + double.headers()[header::AUTHORIZATION] + ); + } + for request in [&default, &single, &double] { + assert_eq!(request.uri().path(), path); + assert_eq!(request.headers()["x-custom"], "encoding-test"); + if presigned { + // RAW_QUERY ends with '&'; the existing fragment, including + // duplicate keys, literal '+', and empty pairs, stays exact. + assert!(request.uri().to_string().starts_with(&uri)); + assert!( + request + .uri() + .query() + .unwrap() + .starts_with(&format!("{RAW_QUERY}X-Amz-Algorithm=")) + ); + } else { + assert_eq!(request.uri().to_string(), uri); + } + } + } + Ok(()) + } + + #[tokio::test] + async fn single_encoding_retains_legacy_canonicalization_differences() -> Result<()> { + let now: Timestamp = "2026-07-22T00:00:00Z".parse()?; + for (wire_path, legacy_path) in [ + ( + "/user@example.com/key=value", + "/user%40example.com/key%3Dvalue", + ), + ("/lowercase%2fslash/%c3%a9", "/lowercase%2Fslash/%C3%A9"), + ("/encoded%7Etilde", "/encoded~tilde"), + ] { + assert_eq!(canonical_uri(wire_path)?, legacy_path); + let uri = format!("https://example.com{wire_path}"); + let legacy_uri = format!("https://example.com{legacy_path}"); + for presigned in [false, true] { + let actual = reqsign_signed_encoding_request( + &uri, + now, + Some(PercentEncodingMode::Single), + presigned, + ) + .await?; + // SDK Single leaves the path verbatim. Legacy reqsign Single + // decodes and re-encodes each segment, so compare its signature + // against the SDK signing that explicitly canonicalized path. + let expected = aws_signed_encoding_request( + &legacy_uri, + now, + PercentEncodingMode::Single, + presigned, + )?; + compare_request(wire_path, &expected, &actual); + assert_eq!(actual.uri().path(), wire_path); + + let sdk_wire = + aws_signed_encoding_request(&uri, now, PercentEncodingMode::Single, presigned)?; + if presigned { + let signature = |request: &Request<&str>| { + form_urlencoded::parse(request.uri().query().unwrap().as_bytes()) + .find(|(name, _)| name == "X-Amz-Signature") + .expect("presigned signature must exist") + .1 + .into_owned() + }; + assert_ne!(signature(&actual), signature(&sdk_wire)); + } else { + assert_ne!( + actual.headers()[header::AUTHORIZATION], + sdk_wire.headers()[header::AUTHORIZATION] + ); + } + } + } + Ok(()) + } + + #[tokio::test] + async fn single_encoding_retains_invalid_utf8_rejection() -> Result<()> { + let now: Timestamp = "2026-07-22T00:00:00Z".parse()?; + let credential = s3_express_credential(now); + for mode in [None, Some(PercentEncodingMode::Single)] { + for presigned in [false, true] { + let mut signer = RequestSigner::new("execute-api", "us-east-1").with_time(now); + if let Some(mode) = mode { + signer = signer.with_percent_encoding_mode(mode); + } + let mut parts = encoding_request("https://example.com/invalid%FF?raw=%2f")? + .into_parts() + .0; + let original = parts.clone(); + let error = signer + .sign_request( + &Context::new(), + &mut parts, + Some(&credential), + presigned.then_some(Duration::from_secs(60)), + ) + .await + .expect_err("Single must retain its legacy UTF-8 validation"); + assert_eq!(error.kind(), ErrorKind::RequestInvalid); + assert_eq!(parts.uri, original.uri); + assert_eq!(parts.headers, original.headers); + } + } + Ok(()) + } + #[tokio::test] async fn canonicalization_preserves_wire_uri() -> Result<()> { let credential = Credential { @@ -1058,7 +1381,7 @@ mod tests { let now = Timestamp::now(); let mut ss = SigningSettings::default(); - ss.percent_encoding_mode = PercentEncodingMode::Double; + ss.percent_encoding_mode = AwsPercentEncodingMode::Double; ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; let id = Credentials::new( "access_key_id", @@ -1133,7 +1456,7 @@ mod tests { let now = Timestamp::now(); let mut ss = SigningSettings::default(); - ss.percent_encoding_mode = PercentEncodingMode::Double; + ss.percent_encoding_mode = AwsPercentEncodingMode::Double; ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; ss.signature_location = SignatureLocation::QueryParams; ss.expires_in = Some(Duration::from_secs(3600)); @@ -1215,7 +1538,7 @@ mod tests { let now = Timestamp::now(); let mut ss = SigningSettings::default(); - ss.percent_encoding_mode = PercentEncodingMode::Double; + ss.percent_encoding_mode = AwsPercentEncodingMode::Double; ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; let id = Credentials::new( "access_key_id", @@ -1293,7 +1616,7 @@ mod tests { let now = Timestamp::now(); let mut ss = SigningSettings::default(); - ss.percent_encoding_mode = PercentEncodingMode::Double; + ss.percent_encoding_mode = AwsPercentEncodingMode::Double; ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; ss.signature_location = SignatureLocation::QueryParams; ss.expires_in = Some(Duration::from_secs(3600)); diff --git a/services/aws-v4a/README.md b/services/aws-v4a/README.md index 6e775b7e..37143b5e 100644 --- a/services/aws-v4a/README.md +++ b/services/aws-v4a/README.md @@ -17,3 +17,24 @@ let signer = RequestSigner::new("s3", region_set); Credential providers and shared AWS types are re-exported from `reqsign-aws-core`. + +## Canonical URI Encoding + +`RequestSigner` defaults to `PercentEncodingMode::Single` for compatibility with +existing S3 callers. Both header signing and presigning can opt into Double: + +```rust +use reqsign_aws_v4a::{PercentEncodingMode, RequestSigner, SigningRegionSet}; + +let signer = RequestSigner::new("execute-api", SigningRegionSet::new("*")?) + .with_percent_encoding_mode(PercentEncodingMode::Double); +# Ok::<(), reqsign_core::Error>(()) +``` + +The input URI must already be wire-ready. Double applies one additional encoding +pass to its original path without decoding it: `/a%20b` becomes `/a%2520b` only in +the canonical request. The outgoing path is unchanged, literal `/` separators are +preserved, and path normalization and payload hashing are unaffected. Single +keeps the existing per-segment decode/re-encode behavior, including escape +normalization and rejection of percent-encoded invalid UTF-8. Neither the service +name nor the endpoint automatically selects a mode. diff --git a/services/aws-v4a/src/lib.rs b/services/aws-v4a/src/lib.rs index 0523cf8f..dba101ce 100644 --- a/services/aws-v4a/src/lib.rs +++ b/services/aws-v4a/src/lib.rs @@ -21,6 +21,7 @@ mod region_set; mod sign_request; pub use region_set::SigningRegionSet; +pub use reqsign_aws_core::PercentEncodingMode; pub use reqsign_aws_core::constants; pub use reqsign_aws_core::{ AssumeRoleCredentialProvider, AssumeRoleWithWebIdentityCredentialProvider, diff --git a/services/aws-v4a/src/sign_request.rs b/services/aws-v4a/src/sign_request.rs index a645c203..58de978d 100644 --- a/services/aws-v4a/src/sign_request.rs +++ b/services/aws-v4a/src/sign_request.rs @@ -25,15 +25,15 @@ use p256::ecdsa::signature::Signer as _; use p256::ecdsa::{DerSignature, SigningKey}; use reqsign_aws_core::Credential; use reqsign_aws_core::signing::{ - append_query_fragment, append_query_pairs, canonical_request_string, canonicalize_headers, - canonicalize_query, + append_query_fragment, append_query_pairs, canonical_request_string_with_encoding, + canonicalize_headers, canonicalize_query, }; use reqsign_core::hash::{hex_sha256, hmac_sha256}; use reqsign_core::time::Timestamp; use reqsign_core::{Context, Error, Result, SignRequest, SigningCredential, SigningRequest}; use zeroize::Zeroizing; -use crate::SigningRegionSet; +use crate::{PercentEncodingMode, SigningRegionSet}; const ALGORITHM: &str = "AWS4-ECDSA-P256-SHA256"; const X_AMZ_REGION_SET: &str = "x-amz-region-set"; @@ -45,6 +45,7 @@ const CREDENTIAL_OPERATION_HEADROOM: Duration = Duration::from_secs(10); pub struct RequestSigner { service: String, region_set: SigningRegionSet, + percent_encoding_mode: PercentEncodingMode, time: Option, } @@ -54,10 +55,33 @@ impl RequestSigner { Self { service: service.to_string(), region_set, + percent_encoding_mode: PercentEncodingMode::Single, time: None, } } + /// Set the canonical URI percent-encoding mode for header and query signing. + /// + /// The default is [`PercentEncodingMode::Single`] for compatibility with S3 + /// and existing callers. Select [`PercentEncodingMode::Double`] when the + /// target service requires another encoding pass over the wire-ready path. + /// This does not modify the outgoing URI, normalize paths, or change payload + /// hashing. The mode is not inferred from the service name or endpoint. + /// + /// # Example + /// + /// ``` + /// use reqsign_aws_v4a::{PercentEncodingMode, RequestSigner, SigningRegionSet}; + /// + /// let signer = RequestSigner::new("execute-api", SigningRegionSet::new("*")?) + /// .with_percent_encoding_mode(PercentEncodingMode::Double); + /// # Ok::<(), reqsign_core::Error>(()) + /// ``` + pub fn with_percent_encoding_mode(mut self, mode: PercentEncodingMode) -> Self { + self.percent_encoding_mode = mode; + self + } + /// Specify the signing time. /// /// This method is only available to tests because production signing must @@ -132,7 +156,11 @@ impl SignRequest for RequestSigner { &self.region_set, ); let canonical_query = canonicalize_query(&signing_request, &authentication_query); - let canonical_request = canonical_request_string(&signing_request, &canonical_query)?; + let canonical_request = canonical_request_string_with_encoding( + &signing_request, + &canonical_query, + self.percent_encoding_mode, + )?; let encoded_request = hex_sha256(canonical_request.as_bytes()); let scope = format!("{}/{}/aws4_request", now.format_date(), self.service); @@ -284,8 +312,9 @@ mod tests { use anyhow::Result as AnyResult; use aws_credential_types::Credentials; use aws_sigv4::http_request::{ - PayloadChecksumKind, PercentEncodingMode, SignableBody, SignableRequest, SignatureLocation, - SigningSettings, + PayloadChecksumKind, PercentEncodingMode as AwsPercentEncodingMode, SessionTokenMode, + SignableBody, SignableRequest, SignatureLocation, SigningSettings, + UriPathNormalizationMode, }; use aws_sigv4::sign::v4a; use http::Request; @@ -294,12 +323,16 @@ mod tests { use pretty_assertions::assert_eq; use reqsign_core::ErrorKind; + use crate::PercentEncodingMode; + // AWS CRT SigV4a get-vanilla test vector. const ACCESS_KEY_ID: &str = "AKIDEXAMPLE"; const SECRET_ACCESS_KEY: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"; const HEADER_STRING_TO_SIGN: &str = "AWS4-ECDSA-P256-SHA256\n20150830T123600Z\n20150830/service/aws4_request\ncf59db423e841c8b7e3444158185aa261b724a5c27cbe762676f3eed19f4dc02"; const RAW_QUERY: &str = "slash=%2F&literal-plus=+&double=%252F&dup=first&dup=second&empty=&flag&"; + const ENCODING_QUERY: &str = "key=value&slash=%2F&empty=&dup=first&dup=second"; + const CANONICAL_ENCODING_QUERY: &str = "dup=first&dup=second&empty=&key=value&slash=%2F"; fn credential() -> Credential { Credential { @@ -435,102 +468,253 @@ mod tests { Ok(()) } - #[tokio::test] - async fn matches_aws_sdk_request_fields() -> AnyResult<()> { + // Assemble the expected canonical request independently of the production + // canonicalization helpers so a shared encoding bug cannot mask a regression. + fn encoding_string_to_sign(canonical_path: &str, expires_in: Option) -> String { + let canonical_request = if let Some(expires_in) = expires_in { + format!( + "GET\n{canonical_path}\n\ + X-Amz-Algorithm=AWS4-ECDSA-P256-SHA256\ + &X-Amz-Credential=AKIDEXAMPLE%2F20150830%2Fservice%2Faws4_request\ + &X-Amz-Date=20150830T123600Z&X-Amz-Expires={}\ + &X-Amz-Region-Set=us-east-1&X-Amz-SignedHeaders=host\ + &{CANONICAL_ENCODING_QUERY}\n\ + host:example.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD", + expires_in.as_secs(), + ) + } else { + format!( + "GET\n{canonical_path}\n{CANONICAL_ENCODING_QUERY}\n\ + host:example.amazonaws.com\n\ + x-amz-content-sha256:UNSIGNED-PAYLOAD\n\ + x-amz-date:20150830T123600Z\n\ + x-amz-region-set:us-east-1\n\n\ + host;x-amz-content-sha256;x-amz-date;x-amz-region-set\nUNSIGNED-PAYLOAD" + ) + }; + format!( + "AWS4-ECDSA-P256-SHA256\n20150830T123600Z\n20150830/service/aws4_request\n{}", + hex_sha256(canonical_request.as_bytes()), + ) + } + + fn verify_encoding_signature(request: &Request<()>, string_to_sign: &str) -> AnyResult<()> { + let signature = if let Some(authorization) = request.headers().get(header::AUTHORIZATION) { + authorization + .to_str()? + .rsplit_once(", Signature=") + .expect("authorization must contain signature") + .1 + .to_string() + } else { + form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes()) + .find(|(key, _)| key == "X-Amz-Signature") + .expect("presigned request must contain signature") + .1 + .into_owned() + }; + let signature_bytes = hex::decode(signature)?; + let signature = DerSignature::try_from(signature_bytes.as_slice()) + .expect("signature must use DER encoding"); + let key = generate_signing_key(ACCESS_KEY_ID, SECRET_ACCESS_KEY)?; + assert!( + key.verifying_key() + .verify(string_to_sign.as_bytes(), &signature) + .is_ok(), + "signature must verify the expected canonical request for {}", + request.uri(), + ); + Ok(()) + } + + fn sign_with_aws_sdk( + uri: &str, + mode: AwsPercentEncodingMode, + expires_in: Option, + ) -> AnyResult> { let now: Timestamp = "2015-08-30T12:36:00Z".parse()?; + let mut request = Request::get(uri).body(())?; + let mut settings = SigningSettings::default(); + settings.percent_encoding_mode = mode; + settings.uri_path_normalization_mode = UriPathNormalizationMode::Disabled; + settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; + settings.session_token_mode = SessionTokenMode::Include; + settings.excluded_headers = None; + settings.expires_in = expires_in; + settings.signature_location = if expires_in.is_some() { + SignatureLocation::QueryParams + } else { + SignatureLocation::Headers + }; + + let identity = Credentials::new( + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + None, + None, + "hardcoded-credentials", + ) + .into(); + let params = v4a::SigningParams::builder() + .identity(&identity) + .region_set("us-east-1") + .name("service") + .time(now.as_system_time()) + .settings(settings) + .build()?; + let output = aws_sigv4::http_request::sign( + SignableRequest::new( + request.method().as_str(), + request.uri().to_string(), + request.headers().iter().map(|(name, value)| { + ( + name.as_str(), + std::str::from_utf8(value.as_bytes()).expect("header must be valid UTF-8"), + ) + }), + SignableBody::UnsignedPayload, + )?, + ¶ms.into(), + )?; + output.into_parts().0.apply_to_request_http1x(&mut request); + Ok(request) + } + + #[tokio::test] + async fn encoding_modes_match_aws_sdk_signatures() -> AnyResult<()> { + // SDK Single preserves the wire path verbatim, so compare using already + // canonical escapes. Reqsign's legacy decode/re-encode cases are below. + for (path, double_path) in [ + ("/object", "/object"), + ("/percent%25name", "/percent%2525name"), + ("/slash%2Fname", "/slash%252Fname"), + ("/space%20name", "/space%2520name"), + ("/at%40name", "/at%2540name"), + ("/equal%3Dname", "/equal%253Dname"), + ("/unicode%E4%B8%AD", "/unicode%25E4%25B8%25AD"), + ("//a/./b/../c/", "//a/./b/../c/"), + ] { + for (mode, sdk_mode, canonical_path) in [ + (None, AwsPercentEncodingMode::Single, path), + ( + Some(PercentEncodingMode::Single), + AwsPercentEncodingMode::Single, + path, + ), + ( + Some(PercentEncodingMode::Double), + AwsPercentEncodingMode::Double, + double_path, + ), + ] { + for expires_in in [None, Some(Duration::from_secs(3600))] { + let uri = format!("https://example.amazonaws.com{path}?{ENCODING_QUERY}"); + let expected = sign_with_aws_sdk(&uri, sdk_mode, expires_in)?; + let (mut parts, body) = Request::get(&uri).body(())?.into_parts(); + let request_signer = match mode { + Some(mode) => signer().with_percent_encoding_mode(mode), + None => signer(), + }; + request_signer + .sign_request(&Context::new(), &mut parts, Some(&credential()), expires_in) + .await?; + let actual = Request::from_parts(parts, body); + assert_eq!(comparable_headers(&actual), comparable_headers(&expected)); + assert_eq!(comparable_query(&actual), comparable_query(&expected)); + assert_eq!(actual.uri().path(), path); + if expires_in.is_some() { + assert!( + actual + .uri() + .query() + .expect("presigned request has query") + .starts_with(&format!("{ENCODING_QUERY}&")) + ); + } else { + assert_eq!(actual.uri().to_string(), uri); + } - for expires_in in [None, Some(Duration::from_secs(3600))] { - let mut expected = - Request::get("https://example.amazonaws.com/object?key=value").body(())?; - let mut settings = SigningSettings::default(); - settings.percent_encoding_mode = PercentEncodingMode::Double; - settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; - if let Some(expires_in) = expires_in { - settings.signature_location = SignatureLocation::QueryParams; - settings.expires_in = Some(expires_in); + // ECDSA signatures may differ. Both must verify against the + // same literal canonical path, not merely have equal fields. + let string_to_sign = encoding_string_to_sign(canonical_path, expires_in); + verify_encoding_signature(&expected, &string_to_sign)?; + verify_encoding_signature(&actual, &string_to_sign)?; + } } + } + Ok(()) + } - let identity = Credentials::new( - ACCESS_KEY_ID, - SECRET_ACCESS_KEY, - None, - None, - "hardcoded-credentials", - ) - .into(); - let params = v4a::SigningParams::builder() - .identity(&identity) - .region_set("us-east-1") - .name("service") - .time(now.as_system_time()) - .settings(settings) - .build()?; - let output = aws_sigv4::http_request::sign( - SignableRequest::new( - expected.method().as_str(), - expected.uri().to_string(), - expected.headers().iter().map(|(name, value)| { - ( - name.as_str(), - std::str::from_utf8(value.as_bytes()) - .expect("header must be valid UTF-8"), - ) - }), - SignableBody::UnsignedPayload, - )?, - ¶ms.into(), - )?; - output.into_parts().0.apply_to_request_http1x(&mut expected); - - let (mut actual_parts, actual_body) = - Request::get("https://example.amazonaws.com/object?key=value") - .body(())? - .into_parts(); - signer() - .sign_request( - &Context::new(), - &mut actual_parts, - Some(&credential()), - expires_in, - ) - .await?; - let actual = Request::from_parts(actual_parts, actual_body); - - assert_eq!(comparable_headers(&actual), comparable_headers(&expected)); - assert_eq!(comparable_query(&actual), comparable_query(&expected)); + #[tokio::test] + async fn encoding_modes_preserve_their_path_semantics() -> AnyResult<()> { + for (mode, path, canonical_path) in [ + (None, "/raw@/a=b/%7E/%2f", "/raw%40/a%3Db/~/%2F"), + ( + Some(PercentEncodingMode::Single), + "/raw@/a=b/%7E/%2f", + "/raw%40/a%3Db/~/%2F", + ), + ( + Some(PercentEncodingMode::Double), + "/raw@/a=b/%7E/%2f/%FF//a/./b/../", + "/raw%40/a%3Db/%257E/%252f/%25FF//a/./b/../", + ), + ] { + for expires_in in [None, Some(Duration::from_secs(3600))] { + let uri = format!("https://example.amazonaws.com{path}?{ENCODING_QUERY}"); + let (mut parts, body) = Request::get(&uri).body(())?.into_parts(); + let request_signer = match mode { + Some(mode) => signer().with_percent_encoding_mode(mode), + None => signer(), + }; + request_signer + .sign_request(&Context::new(), &mut parts, Some(&credential()), expires_in) + .await?; + let actual = Request::from_parts(parts, body); + assert_eq!(actual.uri().path(), path); + if expires_in.is_some() { + assert!( + actual + .uri() + .query() + .expect("presigned request has query") + .starts_with(&format!("{ENCODING_QUERY}&")) + ); + } else { + assert_eq!(actual.uri().to_string(), uri); + } + verify_encoding_signature( + &actual, + &encoding_string_to_sign(canonical_path, expires_in), + )?; + } } Ok(()) } #[tokio::test] - async fn preserves_existing_wire_query() -> AnyResult<()> { + async fn preserves_existing_wire_query_in_both_encoding_modes() -> AnyResult<()> { let original_uri = format!("https://example.amazonaws.com/object%2Fname?{RAW_QUERY}"); - let mut header_parts = Request::get(&original_uri).body(())?.into_parts().0; - signer() - .sign_request( - &Context::new(), - &mut header_parts, - Some(&credential()), - None, - ) - .await?; - assert_eq!(header_parts.uri.to_string(), original_uri); - - let mut query_parts = Request::get(&original_uri).body(())?.into_parts().0; - signer() - .sign_request( - &Context::new(), - &mut query_parts, - Some(&credential()), - Some(Duration::from_secs(60)), - ) - .await?; - assert!( - query_parts - .uri - .to_string() - .starts_with(&format!("{original_uri}X-Amz-Algorithm=")) - ); + for mode in [PercentEncodingMode::Single, PercentEncodingMode::Double] { + for expires_in in [None, Some(Duration::from_secs(60))] { + let mut parts = Request::get(&original_uri).body(())?.into_parts().0; + signer() + .with_percent_encoding_mode(mode) + .sign_request(&Context::new(), &mut parts, Some(&credential()), expires_in) + .await?; + assert_eq!(parts.uri.path(), "/object%2Fname"); + if expires_in.is_some() { + assert!( + parts + .uri + .to_string() + .starts_with(&format!("{original_uri}X-Amz-Algorithm=")) + ); + } else { + assert_eq!(parts.uri.to_string(), original_uri); + } + } + } Ok(()) }