From 70ebbc88424224ec2ee8c74812dded254d110318 Mon Sep 17 00:00:00 2001 From: unconsolable Date: Sat, 10 Oct 2026 23:50:18 +0800 Subject: [PATCH] feat(aws): support configurable canonical URI encoding Add explicit Single and Double percent-encoding modes for SigV4 and SigV4a header signing and presigning. Preserve the existing Single behavior and wire URI bytes by default. Cover encoding semantics with fixed SDK comparisons and document explicit opt-in usage. --- services/aws-core/src/lib.rs | 3 + .../aws-core/src/percent_encoding_mode.rs | 40 ++ services/aws-core/src/signing.rs | 135 ++++++- services/aws-v4/README.md | 23 ++ services/aws-v4/src/lib.rs | 1 + services/aws-v4/src/sign_request.rs | 347 ++++++++++++++++- services/aws-v4a/README.md | 21 + services/aws-v4a/src/lib.rs | 1 + services/aws-v4a/src/sign_request.rs | 368 +++++++++++++----- 9 files changed, 829 insertions(+), 110 deletions(-) create mode 100644 services/aws-core/src/percent_encoding_mode.rs 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(()) }