From a8cb8e8a8b0507a9035083d64eb46cde7d39005d Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 27 Feb 2024 17:56:57 +0100 Subject: [fix-presigned] split presigned/normal signature verification --- src/api/signature/payload.rs | 560 +++++++++++++++++++++++++++---------------- 1 file changed, 347 insertions(+), 213 deletions(-) (limited to 'src/api/signature') diff --git a/src/api/signature/payload.rs b/src/api/signature/payload.rs index 423aad93..29ed7081 100644 --- a/src/api/signature/payload.rs +++ b/src/api/signature/payload.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; +use std::convert::TryFrom; use chrono::{DateTime, Duration, NaiveDateTime, TimeZone, Utc}; use hmac::Mac; +use hyper::header::{HeaderMap, HeaderName, AUTHORIZATION, CONTENT_TYPE, HOST}; use hyper::{body::Incoming as IncomingBody, Method, Request}; use sha2::{Digest, Sha256}; @@ -17,66 +19,87 @@ use super::{compute_scope, signing_hmac}; use crate::encoding::uri_encode; use crate::signature::error::*; +pub const X_AMZ_ALGORITHM: HeaderName = HeaderName::from_static("x-amz-algorithm"); +pub const X_AMZ_CREDENTIAL: HeaderName = HeaderName::from_static("x-amz-credential"); +pub const X_AMZ_DATE: HeaderName = HeaderName::from_static("x-amz-date"); +pub const X_AMZ_EXPIRES: HeaderName = HeaderName::from_static("x-amz-expires"); +pub const X_AMZ_SIGNEDHEADERS: HeaderName = HeaderName::from_static("x-amz-signedheaders"); +pub const X_AMZ_SIGNATURE: HeaderName = HeaderName::from_static("x-amz-signature"); +pub const X_AMZ_CONTENT_SH256: HeaderName = HeaderName::from_static("x-amz-content-sha256"); + +pub const AWS4_HMAC_SHA256: &str = "AWS4-HMAC-SHA256"; +pub const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; +pub const STREAMING_AWS4_HMAC_SHA256_PAYLOAD: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"; + +pub type QueryMap = HashMap; + pub async fn check_payload_signature( garage: &Garage, service: &'static str, request: &Request, ) -> Result<(Option, Option), Error> { - let mut headers = HashMap::new(); - for (key, val) in request.headers() { - headers.insert(key.to_string(), val.to_str()?.to_string()); - } - if let Some(query) = request.uri().query() { - let query_pairs = url::form_urlencoded::parse(query.as_bytes()); - for (key, val) in query_pairs { - headers.insert(key.to_lowercase(), val.to_string()); - } - } + let query = parse_query_map(request.uri())?; - let authorization = if let Some(authorization) = headers.get("authorization") { - parse_authorization(authorization, &headers)? - } else if let Some(algorithm) = headers.get("x-amz-algorithm") { - parse_query_authorization(algorithm, &headers)? + let res = if query.contains_key(X_AMZ_ALGORITHM.as_str()) { + check_presigned_signature(garage, service, request, query).await } else { - let content_sha256 = headers.get("x-amz-content-sha256"); - if let Some(content_sha256) = content_sha256.filter(|c| "UNSIGNED-PAYLOAD" != c.as_str()) { - let sha256 = hex::decode(content_sha256) - .ok() - .and_then(|bytes| Hash::try_from(&bytes)) - .ok_or_bad_request("Invalid content sha256 hash")?; - return Ok((None, Some(sha256))); - } else { - return Ok((None, None)); - } + check_standard_signature(garage, service, request, query).await }; + if let Err(e) = &res { + error!("ERROR IN SIGNATURE\n{:?}\n{}", request, e); + } + + res +} + +async fn check_standard_signature( + garage: &Garage, + service: &'static str, + request: &Request, + query: QueryMap, +) -> Result<(Option, Option), Error> { + let authorization = Authorization::parse(request.headers())?; + + // Verify that all necessary request headers are signed + let signed_headers = split_signed_headers(&authorization)?; + for (name, _) in request.headers().iter() { + if name.as_str().starts_with("x-amz-") || name == CONTENT_TYPE { + if !signed_headers.contains(name) { + return Err(Error::bad_request(format!( + "Header `{}` should be signed", + name + ))); + } + } + } + if !signed_headers.contains(&HOST) { + return Err(Error::bad_request("Header `Host` should be signed")); + } + let canonical_request = canonical_request( service, request.method(), - request.uri(), - &headers, - &authorization.signed_headers, + request.uri().path(), + &query, + request.headers(), + signed_headers, &authorization.content_sha256, + )?; + let string_to_sign = string_to_sign( + &authorization.date, + &authorization.scope, + &canonical_request, ); - let (_, scope) = parse_credential(&authorization.credential)?; - let string_to_sign = string_to_sign(&authorization.date, &scope, &canonical_request); trace!("canonical request:\n{}", canonical_request); trace!("string to sign:\n{}", string_to_sign); - let key = verify_v4( - garage, - service, - &authorization.credential, - &authorization.date, - &authorization.signature, - string_to_sign.as_bytes(), - ) - .await?; + let key = verify_v4(garage, service, &authorization, string_to_sign.as_bytes()).await?; - let content_sha256 = if authorization.content_sha256 == "UNSIGNED-PAYLOAD" { + let content_sha256 = if authorization.content_sha256 == UNSIGNED_PAYLOAD { None - } else if authorization.content_sha256 == "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" { + } else if authorization.content_sha256 == STREAMING_AWS4_HMAC_SHA256_PAYLOAD { let bytes = hex::decode(authorization.signature).ok_or_bad_request("Invalid signature")?; Some(Hash::try_from(&bytes).ok_or_bad_request("Invalid signature")?) } else { @@ -88,142 +111,86 @@ pub async fn check_payload_signature( Ok((Some(key), content_sha256)) } -struct Authorization { - credential: String, - signed_headers: String, - signature: String, - content_sha256: String, - date: DateTime, -} - -fn parse_authorization( - authorization: &str, - headers: &HashMap, -) -> Result { - let first_space = authorization - .find(' ') - .ok_or_bad_request("Authorization field to short")?; - let (auth_kind, rest) = authorization.split_at(first_space); - - if auth_kind != "AWS4-HMAC-SHA256" { - return Err(Error::bad_request("Unsupported authorization method")); +async fn check_presigned_signature( + garage: &Garage, + service: &'static str, + request: &Request, + mut query: QueryMap, +) -> Result<(Option, Option), Error> { + let algorithm = query.get(X_AMZ_ALGORITHM.as_str()).unwrap(); + let authorization = Authorization::parse_presigned(algorithm, &query)?; + + // Check that all mandatory signed headers are included + let signed_headers = split_signed_headers(&authorization)?; + for (name, _) in request.headers().iter() { + if name.as_str().starts_with("x-amz-") { + if !signed_headers.contains(name) { + return Err(Error::bad_request(format!( + "Header `{}` should be signed", + name + ))); + } + } } - - let mut auth_params = HashMap::new(); - for auth_part in rest.split(',') { - let auth_part = auth_part.trim(); - let eq = auth_part - .find('=') - .ok_or_bad_request("Field without value in authorization header")?; - let (key, value) = auth_part.split_at(eq); - auth_params.insert(key.to_string(), value.trim_start_matches('=').to_string()); + if !signed_headers.contains(&HOST) { + return Err(Error::bad_request("Header `Host` should be signed")); } - let cred = auth_params - .get("Credential") - .ok_or_bad_request("Could not find Credential in Authorization field")?; - - let content_sha256 = headers - .get("x-amz-content-sha256") - .ok_or_bad_request("Missing X-Amz-Content-Sha256 field")?; + query.remove(X_AMZ_SIGNATURE.as_str()); + let canonical_request = canonical_request( + service, + request.method(), + request.uri().path(), + &query, + request.headers(), + signed_headers, + &authorization.content_sha256, + )?; + let string_to_sign = string_to_sign( + &authorization.date, + &authorization.scope, + &canonical_request, + ); - let date = headers - .get("x-amz-date") - .ok_or_bad_request("Missing X-Amz-Date field") - .map_err(Error::from) - .and_then(|d| parse_date(d))?; + trace!("canonical request:\n{}", canonical_request); + trace!("string to sign:\n{}", string_to_sign); - if Utc::now() - date > Duration::hours(24) { - return Err(Error::bad_request("Date is too old".to_string())); - } + let key = verify_v4(garage, service, &authorization, string_to_sign.as_bytes()).await?; - let auth = Authorization { - credential: cred.to_string(), - signed_headers: auth_params - .get("SignedHeaders") - .ok_or_bad_request("Could not find SignedHeaders in Authorization field")? - .to_string(), - signature: auth_params - .get("Signature") - .ok_or_bad_request("Could not find Signature in Authorization field")? - .to_string(), - content_sha256: content_sha256.to_string(), - date, - }; - Ok(auth) + Ok((Some(key), None)) } -fn parse_query_authorization( - algorithm: &str, - headers: &HashMap, -) -> Result { - if algorithm != "AWS4-HMAC-SHA256" { - return Err(Error::bad_request( - "Unsupported authorization method".to_string(), - )); - } - - let cred = headers - .get("x-amz-credential") - .ok_or_bad_request("X-Amz-Credential not found in query parameters")?; - let signed_headers = headers - .get("x-amz-signedheaders") - .ok_or_bad_request("X-Amz-SignedHeaders not found in query parameters")?; - let signature = headers - .get("x-amz-signature") - .ok_or_bad_request("X-Amz-Signature not found in query parameters")?; - let content_sha256 = headers - .get("x-amz-content-sha256") - .map(|x| x.as_str()) - .unwrap_or("UNSIGNED-PAYLOAD"); - - let duration = headers - .get("x-amz-expires") - .ok_or_bad_request("X-Amz-Expires not found in query parameters")? - .parse() - .map_err(|_| Error::bad_request("X-Amz-Expires is not a number".to_string()))?; - - if duration > 7 * 24 * 3600 { - return Err(Error::bad_request( - "X-Amz-Expires may not exceed a week".to_string(), - )); - } - - let date = headers - .get("x-amz-date") - .ok_or_bad_request("Missing X-Amz-Date field") - .map_err(Error::from) - .and_then(|d| parse_date(d))?; - - if Utc::now() - date > Duration::seconds(duration) { - return Err(Error::bad_request("Date is too old".to_string())); +pub fn parse_query_map(uri: &http::uri::Uri) -> Result { + let mut query = QueryMap::new(); + if let Some(query_str) = uri.query() { + let query_pairs = url::form_urlencoded::parse(query_str.as_bytes()); + for (key, val) in query_pairs { + if query.insert(key.to_string(), val.into_owned()).is_some() { + return Err(Error::bad_request(format!( + "duplicate query parameter: `{}`", + key + ))); + } + } } - - Ok(Authorization { - credential: cred.to_string(), - signed_headers: signed_headers.to_string(), - signature: signature.to_string(), - content_sha256: content_sha256.to_string(), - date, - }) + Ok(query) } -fn parse_credential(cred: &str) -> Result<(String, String), Error> { - let first_slash = cred - .find('/') - .ok_or_bad_request("Credentials does not contain '/' in authorization field")?; - let (key_id, scope) = cred.split_at(first_slash); - Ok(( - key_id.to_string(), - scope.trim_start_matches('/').to_string(), - )) +fn split_signed_headers(authorization: &Authorization) -> Result, Error> { + let signed_headers = authorization + .signed_headers + .split(';') + .map(HeaderName::try_from) + .collect::, _>>() + .ok_or_bad_request("invalid header name")?; + Ok(signed_headers) } pub fn string_to_sign(datetime: &DateTime, scope_string: &str, canonical_req: &str) -> String { let mut hasher = Sha256::default(); hasher.update(canonical_req.as_bytes()); [ - "AWS4-HMAC-SHA256", + AWS4_HMAC_SHA256, &datetime.format(LONG_DATETIME).to_string(), scope_string, &hex::encode(hasher.finalize().as_slice()), @@ -234,11 +201,12 @@ pub fn string_to_sign(datetime: &DateTime, scope_string: &str, canonical_re pub fn canonical_request( service: &'static str, method: &Method, - uri: &hyper::Uri, - headers: &HashMap, - signed_headers: &str, + canonical_uri: &str, + query: &QueryMap, + headers: &HeaderMap, + mut signed_headers: Vec, content_sha256: &str, -) -> String { +) -> Result { // There seems to be evidence that in AWSv4 signatures, the path component is url-encoded // a second time when building the canonical request, as specified in this documentation page: // -> https://docs.aws.amazon.com/rolesanywhere/latest/userguide/authentication-sign-process.html @@ -268,49 +236,51 @@ pub fn canonical_request( // it mentions it in the comments (same link to the souce code as above). // We make the explicit choice of NOT normalizing paths in the K2V API because doing so // would make non-normalized paths invalid K2V partition keys, and we don't want that. - let path: std::borrow::Cow = if service != "s3" { - uri_encode(uri.path(), false).into() + let canonical_uri: std::borrow::Cow = if service != "s3" { + uri_encode(canonical_uri, false).into() } else { - uri.path().into() + canonical_uri.into() }; - [ - method.as_str(), - &path, - &canonical_query_string(uri), - &canonical_header_string(headers, signed_headers), - "", - signed_headers, - content_sha256, - ] - .join("\n") -} - -fn canonical_header_string(headers: &HashMap, signed_headers: &str) -> String { - let signed_headers_vec = signed_headers.split(';').collect::>(); - let mut items = headers - .iter() - .filter(|(key, _)| signed_headers_vec.contains(&key.as_str())) - .collect::>(); - items.sort_by(|(k1, _), (k2, _)| k1.cmp(k2)); - items - .iter() - .map(|(key, value)| key.to_lowercase() + ":" + value.trim()) - .collect::>() - .join("\n") -} -fn canonical_query_string(uri: &hyper::Uri) -> String { - if let Some(query) = uri.query() { - let query_pairs = url::form_urlencoded::parse(query.as_bytes()); - let mut items = query_pairs - .filter(|(key, _)| key != "X-Amz-Signature") - .map(|(key, value)| uri_encode(&key, true) + "=" + &uri_encode(&value, true)) - .collect::>(); + // Canonical query string from passed HeaderMap + let canonical_query_string = { + let mut items = Vec::with_capacity(query.len()); + for (key, value) in query.iter() { + items.push(uri_encode(&key, true) + "=" + &uri_encode(&value, true)); + } items.sort(); items.join("&") - } else { - "".to_string() - } + }; + + // Canonical header string calculated from signed headers + signed_headers.sort_by(|h1, h2| h1.as_str().cmp(h2.as_str())); + let canonical_header_string = { + let mut items = Vec::with_capacity(signed_headers.len()); + for name in signed_headers.iter() { + let value = headers + .get(name) + .ok_or_bad_request(format!("signed header `{}` is not present", name))? + .to_str()?; + items.push((name, value)); + } + items + .iter() + .map(|(key, value)| format!("{}:{}", key.as_str(), value.trim())) + .collect::>() + .join("\n") + }; + let signed_headers = signed_headers.join(";"); + + let list = [ + method.as_str(), + &canonical_uri, + &canonical_query_string, + &canonical_header_string, + "", + &signed_headers, + content_sha256, + ]; + Ok(list.join("\n")) } pub fn parse_date(date: &str) -> Result, Error> { @@ -322,28 +292,24 @@ pub fn parse_date(date: &str) -> Result, Error> { pub async fn verify_v4( garage: &Garage, service: &str, - credential: &str, - date: &DateTime, - signature: &str, + auth: &Authorization, payload: &[u8], ) -> Result { - let (key_id, scope) = parse_credential(credential)?; - - let scope_expected = compute_scope(date, &garage.config.s3_api.s3_region, service); - if scope != scope_expected { - return Err(Error::AuthorizationHeaderMalformed(scope.to_string())); + let scope_expected = compute_scope(&auth.date, &garage.config.s3_api.s3_region, service); + if auth.scope != scope_expected { + return Err(Error::AuthorizationHeaderMalformed(auth.scope.to_string())); } let key = garage .key_table - .get(&EmptyKey, &key_id) + .get(&EmptyKey, &auth.key_id) .await? .filter(|k| !k.state.is_deleted()) - .ok_or_else(|| Error::forbidden(format!("No such key: {}", &key_id)))?; + .ok_or_else(|| Error::forbidden(format!("No such key: {}", &auth.key_id)))?; let key_p = key.params().unwrap(); let mut hmac = signing_hmac( - date, + &auth.date, &key_p.secret_key, &garage.config.s3_api.s3_region, service, @@ -351,9 +317,177 @@ pub async fn verify_v4( .ok_or_internal_error("Unable to build signing HMAC")?; hmac.update(payload); let our_signature = hex::encode(hmac.finalize().into_bytes()); - if signature != our_signature { + if auth.signature != our_signature { return Err(Error::forbidden("Invalid signature".to_string())); } Ok(key) } + +// ============ Authorization header, or X-Amz-* query params ========= + +pub struct Authorization { + key_id: String, + scope: String, + signed_headers: String, + signature: String, + content_sha256: String, + date: DateTime, +} + +impl Authorization { + fn parse(headers: &HeaderMap) -> Result { + let authorization = headers + .get(AUTHORIZATION) + .ok_or_bad_request("Missing authorization header")? + .to_str()?; + + let (auth_kind, rest) = authorization + .split_once(' ') + .ok_or_bad_request("Authorization field to short")?; + + if auth_kind != AWS4_HMAC_SHA256 { + return Err(Error::bad_request("Unsupported authorization method")); + } + + let mut auth_params = HashMap::new(); + for auth_part in rest.split(',') { + let auth_part = auth_part.trim(); + let eq = auth_part + .find('=') + .ok_or_bad_request("Field without value in authorization header")?; + let (key, value) = auth_part.split_at(eq); + auth_params.insert(key.to_string(), value.trim_start_matches('=').to_string()); + } + + let cred = auth_params + .get("Credential") + .ok_or_bad_request("Could not find Credential in Authorization field")?; + let signed_headers = auth_params + .get("SignedHeaders") + .ok_or_bad_request("Could not find SignedHeaders in Authorization field")? + .to_string(); + let signature = auth_params + .get("Signature") + .ok_or_bad_request("Could not find Signature in Authorization field")? + .to_string(); + + let content_sha256 = headers + .get(X_AMZ_CONTENT_SH256) + .ok_or_bad_request("Missing X-Amz-Content-Sha256 field")?; + + let date = headers + .get(X_AMZ_DATE) + .ok_or_bad_request("Missing X-Amz-Date field") + .map_err(Error::from)? + .to_str()?; + let date = parse_date(date)?; + + if Utc::now() - date > Duration::hours(24) { + return Err(Error::bad_request("Date is too old".to_string())); + } + + let (key_id, scope) = parse_credential(cred)?; + let auth = Authorization { + key_id, + scope, + signed_headers, + signature, + content_sha256: content_sha256.to_str()?.to_string(), + date, + }; + Ok(auth) + } + + fn parse_presigned(algorithm: &str, query: &QueryMap) -> Result { + if algorithm != AWS4_HMAC_SHA256 { + return Err(Error::bad_request( + "Unsupported authorization method".to_string(), + )); + } + + let cred = query + .get(X_AMZ_CREDENTIAL.as_str()) + .ok_or_bad_request("X-Amz-Credential not found in query parameters")?; + let signed_headers = query + .get(X_AMZ_SIGNEDHEADERS.as_str()) + .ok_or_bad_request("X-Amz-SignedHeaders not found in query parameters")?; + let signature = query + .get(X_AMZ_SIGNATURE.as_str()) + .ok_or_bad_request("X-Amz-Signature not found in query parameters")?; + + let duration = query + .get(X_AMZ_EXPIRES.as_str()) + .ok_or_bad_request("X-Amz-Expires not found in query parameters")? + .parse() + .map_err(|_| Error::bad_request("X-Amz-Expires is not a number".to_string()))?; + + if duration > 7 * 24 * 3600 { + return Err(Error::bad_request( + "X-Amz-Expires may not exceed a week".to_string(), + )); + } + + let date = query + .get(X_AMZ_DATE.as_str()) + .ok_or_bad_request("Missing X-Amz-Date field")?; + let date = parse_date(date)?; + + if Utc::now() - date > Duration::seconds(duration) { + return Err(Error::bad_request("Date is too old".to_string())); + } + + let (key_id, scope) = parse_credential(cred)?; + Ok(Authorization { + key_id, + scope, + signed_headers: signed_headers.to_string(), + signature: signature.to_string(), + content_sha256: UNSIGNED_PAYLOAD.to_string(), + date, + }) + } + + pub(crate) fn parse_form(params: &HeaderMap) -> Result { + let credential = params + .get(X_AMZ_CREDENTIAL) + .ok_or_else(|| Error::forbidden("Garage does not support anonymous access yet"))? + .to_str()?; + let signature = params + .get(X_AMZ_SIGNATURE) + .ok_or_bad_request("No signature was provided")? + .to_str()? + .to_string(); + let date = params + .get(X_AMZ_DATE) + .ok_or_bad_request("No date was provided")? + .to_str()?; + let date = parse_date(date)?; + + if Utc::now() - date > Duration::hours(24) { + return Err(Error::bad_request("Date is too old".to_string())); + } + + let (key_id, scope) = parse_credential(credential)?; + let auth = Authorization { + key_id, + scope, + signed_headers: "".to_string(), + signature, + content_sha256: UNSIGNED_PAYLOAD.to_string(), + date, + }; + Ok(auth) + } +} + +fn parse_credential(cred: &str) -> Result<(String, String), Error> { + let first_slash = cred + .find('/') + .ok_or_bad_request("Credentials does not contain '/' in authorization field")?; + let (key_id, scope) = cred.split_at(first_slash); + Ok(( + key_id.to_string(), + scope.trim_start_matches('/').to_string(), + )) +} -- cgit v1.2.3 From 2efa9c5a1a568e28e41af790750f224d334d4e3d Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 27 Feb 2024 22:59:30 +0100 Subject: [fix-presigned] PostObject: verify X-Amz-Algorithm --- src/api/signature/payload.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'src/api/signature') diff --git a/src/api/signature/payload.rs b/src/api/signature/payload.rs index 29ed7081..8841a5e5 100644 --- a/src/api/signature/payload.rs +++ b/src/api/signature/payload.rs @@ -449,6 +449,16 @@ impl Authorization { } pub(crate) fn parse_form(params: &HeaderMap) -> Result { + let algorithm = params + .get(X_AMZ_ALGORITHM) + .ok_or_bad_request("Missing X-Amz-Algorithm header")? + .to_str()?; + if algorithm != AWS4_HMAC_SHA256 { + return Err(Error::bad_request( + "Unsupported authorization method".to_string(), + )); + } + let credential = params .get(X_AMZ_CREDENTIAL) .ok_or_else(|| Error::forbidden("Garage does not support anonymous access yet"))? -- cgit v1.2.3 From 4c1d42cc5fcaa69818ec177f19577ad57952a117 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 27 Feb 2024 23:33:26 +0100 Subject: [fix-presigned] add back anonymous request code path + refactoring --- src/api/signature/payload.rs | 57 +++++++++++++++++++++++------------------- src/api/signature/streaming.rs | 13 +++++++--- 2 files changed, 40 insertions(+), 30 deletions(-) (limited to 'src/api/signature') diff --git a/src/api/signature/payload.rs b/src/api/signature/payload.rs index 8841a5e5..c88bb144 100644 --- a/src/api/signature/payload.rs +++ b/src/api/signature/payload.rs @@ -40,17 +40,26 @@ pub async fn check_payload_signature( ) -> Result<(Option, Option), Error> { let query = parse_query_map(request.uri())?; - let res = if query.contains_key(X_AMZ_ALGORITHM.as_str()) { + if query.contains_key(X_AMZ_ALGORITHM.as_str()) { check_presigned_signature(garage, service, request, query).await - } else { + } else if request.headers().contains_key(AUTHORIZATION) { check_standard_signature(garage, service, request, query).await - }; - - if let Err(e) = &res { - error!("ERROR IN SIGNATURE\n{:?}\n{}", request, e); + } else { + // Unsigned (anonymous) request + let content_sha256 = request + .headers() + .get("x-amz-content-sha256") + .filter(|c| c.as_bytes() != UNSIGNED_PAYLOAD.as_bytes()); + if let Some(content_sha256) = content_sha256 { + let sha256 = hex::decode(content_sha256) + .ok() + .and_then(|bytes| Hash::try_from(&bytes)) + .ok_or_bad_request("Invalid content sha256 hash")?; + Ok((None, Some(sha256))) + } else { + Ok((None, None)) + } } - - res } async fn check_standard_signature( @@ -63,8 +72,11 @@ async fn check_standard_signature( // Verify that all necessary request headers are signed let signed_headers = split_signed_headers(&authorization)?; + if !signed_headers.contains(&HOST) { + return Err(Error::bad_request("Header `Host` should be signed")); + } for (name, _) in request.headers().iter() { - if name.as_str().starts_with("x-amz-") || name == CONTENT_TYPE { + if name == CONTENT_TYPE || name.as_str().starts_with("x-amz-") { if !signed_headers.contains(name) { return Err(Error::bad_request(format!( "Header `{}` should be signed", @@ -73,9 +85,6 @@ async fn check_standard_signature( } } } - if !signed_headers.contains(&HOST) { - return Err(Error::bad_request("Header `Host` should be signed")); - } let canonical_request = canonical_request( service, @@ -122,6 +131,9 @@ async fn check_presigned_signature( // Check that all mandatory signed headers are included let signed_headers = split_signed_headers(&authorization)?; + if !signed_headers.contains(&HOST) { + return Err(Error::bad_request("Header `Host` should be signed")); + } for (name, _) in request.headers().iter() { if name.as_str().starts_with("x-amz-") { if !signed_headers.contains(name) { @@ -132,9 +144,6 @@ async fn check_presigned_signature( } } } - if !signed_headers.contains(&HOST) { - return Err(Error::bad_request("Header `Host` should be signed")); - } query.remove(X_AMZ_SIGNATURE.as_str()); let canonical_request = canonical_request( @@ -254,21 +263,17 @@ pub fn canonical_request( // Canonical header string calculated from signed headers signed_headers.sort_by(|h1, h2| h1.as_str().cmp(h2.as_str())); - let canonical_header_string = { - let mut items = Vec::with_capacity(signed_headers.len()); - for name in signed_headers.iter() { + let canonical_header_string = signed_headers + .iter() + .map(|name| { let value = headers .get(name) .ok_or_bad_request(format!("signed header `{}` is not present", name))? .to_str()?; - items.push((name, value)); - } - items - .iter() - .map(|(key, value)| format!("{}:{}", key.as_str(), value.trim())) - .collect::>() - .join("\n") - }; + Ok(format!("{}:{}", name.as_str(), value.trim())) + }) + .collect::, Error>>()? + .join("\n"); let signed_headers = signed_headers.join(";"); let list = [ diff --git a/src/api/signature/streaming.rs b/src/api/signature/streaming.rs index a2a71f6b..e223d1b1 100644 --- a/src/api/signature/streaming.rs +++ b/src/api/signature/streaming.rs @@ -15,6 +15,11 @@ use super::{compute_scope, sha256sum, HmacSha256, LONG_DATETIME}; use crate::helpers::*; use crate::signature::error::*; +use crate::signature::payload::{ + STREAMING_AWS4_HMAC_SHA256_PAYLOAD, X_AMZ_CONTENT_SH256, X_AMZ_DATE, +}; + +pub const AWS4_HMAC_SHA256_PAYLOAD: &str = "AWS4-HMAC-SHA256-PAYLOAD"; pub type ReqBody = BoxBody; @@ -25,8 +30,8 @@ pub fn parse_streaming_body( region: &str, service: &str, ) -> Result, Error> { - match req.headers().get("x-amz-content-sha256") { - Some(header) if header == "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" => { + match req.headers().get(X_AMZ_CONTENT_SH256) { + Some(header) if header == STREAMING_AWS4_HMAC_SHA256_PAYLOAD => { let signature = content_sha256 .take() .ok_or_bad_request("No signature provided")?; @@ -39,7 +44,7 @@ pub fn parse_streaming_body( let date = req .headers() - .get("x-amz-date") + .get(X_AMZ_DATE) .ok_or_bad_request("Missing X-Amz-Date field")? .to_str()?; let date: NaiveDateTime = NaiveDateTime::parse_from_str(date, LONG_DATETIME) @@ -75,7 +80,7 @@ fn compute_streaming_payload_signature( content_sha256: Hash, ) -> Result { let string_to_sign = [ - "AWS4-HMAC-SHA256-PAYLOAD", + AWS4_HMAC_SHA256_PAYLOAD, &date.format(LONG_DATETIME).to_string(), scope, &hex::encode(previous_signature), -- cgit v1.2.3 From a5e4bfeae9d705e0c8a56dfd8268e1309999c5cd Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 27 Feb 2024 23:46:49 +0100 Subject: [fix-presigned] write comments --- src/api/signature/payload.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) (limited to 'src/api/signature') diff --git a/src/api/signature/payload.rs b/src/api/signature/payload.rs index c88bb144..8a7a4341 100644 --- a/src/api/signature/payload.rs +++ b/src/api/signature/payload.rs @@ -40,10 +40,10 @@ pub async fn check_payload_signature( ) -> Result<(Option, Option), Error> { let query = parse_query_map(request.uri())?; - if query.contains_key(X_AMZ_ALGORITHM.as_str()) { - check_presigned_signature(garage, service, request, query).await - } else if request.headers().contains_key(AUTHORIZATION) { + if request.headers().contains_key(AUTHORIZATION) { check_standard_signature(garage, service, request, query).await + } else if query.contains_key(X_AMZ_ALGORITHM.as_str()) { + check_presigned_signature(garage, service, request, query).await } else { // Unsigned (anonymous) request let content_sha256 = request @@ -70,7 +70,11 @@ async fn check_standard_signature( ) -> Result<(Option, Option), Error> { let authorization = Authorization::parse(request.headers())?; - // Verify that all necessary request headers are signed + // Verify that all necessary request headers are included in signed_headers + // For standard AWSv4 signatures, the following must be incldued: + // - the Host header (in all cases) + // - the Content-Type header, if it is used in the request + // - all x-amz-* headers used in the request let signed_headers = split_signed_headers(&authorization)?; if !signed_headers.contains(&HOST) { return Err(Error::bad_request("Header `Host` should be signed")); @@ -129,7 +133,10 @@ async fn check_presigned_signature( let algorithm = query.get(X_AMZ_ALGORITHM.as_str()).unwrap(); let authorization = Authorization::parse_presigned(algorithm, &query)?; - // Check that all mandatory signed headers are included + // Verify that all necessary request headers are included in signed_headers + // For AWSv4 pre-signed URLs, the following must be incldued: + // - the Host header (in all cases) + // - all x-amz-* headers used in the request let signed_headers = split_signed_headers(&authorization)?; if !signed_headers.contains(&HOST) { return Err(Error::bad_request("Header `Host` should be signed")); @@ -145,6 +152,10 @@ async fn check_presigned_signature( } } + // The X-Amz-Signature value is passed as a query parameter, + // but the signature cannot be computed from a string that contains itself. + // AWS specifies that all query params except X-Amz-Signature are included + // in the canonical request. query.remove(X_AMZ_SIGNATURE.as_str()); let canonical_request = canonical_request( service, -- cgit v1.2.3 From e9f759d4cb9be28584ab511a0a2dc78b579475c8 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 28 Feb 2024 00:27:54 +0100 Subject: [fix-presigned] presigned requests: allow x-amz-* query parameters to stand in for equivalent headers --- src/api/signature/payload.rs | 45 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 8 deletions(-) (limited to 'src/api/signature') diff --git a/src/api/signature/payload.rs b/src/api/signature/payload.rs index 8a7a4341..435b4206 100644 --- a/src/api/signature/payload.rs +++ b/src/api/signature/payload.rs @@ -3,7 +3,7 @@ use std::convert::TryFrom; use chrono::{DateTime, Duration, NaiveDateTime, TimeZone, Utc}; use hmac::Mac; -use hyper::header::{HeaderMap, HeaderName, AUTHORIZATION, CONTENT_TYPE, HOST}; +use hyper::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE, HOST}; use hyper::{body::Incoming as IncomingBody, Method, Request}; use sha2::{Digest, Sha256}; @@ -36,7 +36,7 @@ pub type QueryMap = HashMap; pub async fn check_payload_signature( garage: &Garage, service: &'static str, - request: &Request, + request: &mut Request, ) -> Result<(Option, Option), Error> { let query = parse_query_map(request.uri())?; @@ -96,7 +96,7 @@ async fn check_standard_signature( request.uri().path(), &query, request.headers(), - signed_headers, + &signed_headers, &authorization.content_sha256, )?; let string_to_sign = string_to_sign( @@ -127,7 +127,7 @@ async fn check_standard_signature( async fn check_presigned_signature( garage: &Garage, service: &'static str, - request: &Request, + request: &mut Request, mut query: QueryMap, ) -> Result<(Option, Option), Error> { let algorithm = query.get(X_AMZ_ALGORITHM.as_str()).unwrap(); @@ -163,7 +163,7 @@ async fn check_presigned_signature( request.uri().path(), &query, request.headers(), - signed_headers, + &signed_headers, &authorization.content_sha256, )?; let string_to_sign = string_to_sign( @@ -177,6 +177,35 @@ async fn check_presigned_signature( let key = verify_v4(garage, service, &authorization, string_to_sign.as_bytes()).await?; + // AWS specifies that if a signed query parameter and a signed header of the same name + // have different values, then an InvalidRequest error is raised. + let headers_mut = request.headers_mut(); + for (name, value) in query.iter() { + let name = + HeaderName::from_bytes(name.as_bytes()).ok_or_bad_request("Invalid header name")?; + if let Some(existing) = headers_mut.get(&name) { + if signed_headers.contains(&name) && existing.as_bytes() != value.as_bytes() { + return Err(Error::bad_request(format!( + "Conflicting values for `{}` in query parameters and request headers", + name + ))); + } + } + if name.as_str().starts_with("x-amz-") { + // Query parameters that start by x-amz- are actually intended to stand in for + // headers that can't be added at the time the request is made. + // What we do is just add them to the Request object as regular headers, + // that will be handled downstream as if they were included like in a normal request. + // (Here we allow such query parameters to override headers with the same name + // if they are not signed, however there is not much reason that this would happen) + headers_mut.insert( + name, + HeaderValue::from_bytes(value.as_bytes()) + .ok_or_bad_request("invalid query parameter value")?, + ); + } + } + Ok((Some(key), None)) } @@ -197,12 +226,13 @@ pub fn parse_query_map(uri: &http::uri::Uri) -> Result { } fn split_signed_headers(authorization: &Authorization) -> Result, Error> { - let signed_headers = authorization + let mut signed_headers = authorization .signed_headers .split(';') .map(HeaderName::try_from) .collect::, _>>() .ok_or_bad_request("invalid header name")?; + signed_headers.sort_by(|h1, h2| h1.as_str().cmp(h2.as_str())); Ok(signed_headers) } @@ -224,7 +254,7 @@ pub fn canonical_request( canonical_uri: &str, query: &QueryMap, headers: &HeaderMap, - mut signed_headers: Vec, + signed_headers: &[HeaderName], content_sha256: &str, ) -> Result { // There seems to be evidence that in AWSv4 signatures, the path component is url-encoded @@ -273,7 +303,6 @@ pub fn canonical_request( }; // Canonical header string calculated from signed headers - signed_headers.sort_by(|h1, h2| h1.as_str().cmp(h2.as_str())); let canonical_header_string = signed_headers .iter() .map(|name| { -- cgit v1.2.3 From 90cab5b8f2b5212668975bf445a1e86f638fe1c7 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 28 Feb 2024 10:51:08 +0100 Subject: [fix-presigned] add comments and reorganize --- src/api/signature/mod.rs | 29 +++++++++++- src/api/signature/payload.rs | 105 ++++++++++++++++++++++--------------------- 2 files changed, 81 insertions(+), 53 deletions(-) (limited to 'src/api/signature') diff --git a/src/api/signature/mod.rs b/src/api/signature/mod.rs index 4b8b990f..6514da43 100644 --- a/src/api/signature/mod.rs +++ b/src/api/signature/mod.rs @@ -2,19 +2,44 @@ use chrono::{DateTime, Utc}; use hmac::{Hmac, Mac}; use sha2::Sha256; +use hyper::{body::Incoming as IncomingBody, Request}; + +use garage_model::garage::Garage; +use garage_model::key_table::Key; use garage_util::data::{sha256sum, Hash}; +use error::*; + pub mod error; pub mod payload; pub mod streaming; -use error::*; - pub const SHORT_DATE: &str = "%Y%m%d"; pub const LONG_DATETIME: &str = "%Y%m%dT%H%M%SZ"; type HmacSha256 = Hmac; +pub async fn verify_request( + garage: &Garage, + mut req: Request, + service: &'static str, +) -> Result<(Request, Key, Option), Error> { + let (api_key, mut content_sha256) = + payload::check_payload_signature(&garage, &mut req, service).await?; + let api_key = + api_key.ok_or_else(|| Error::forbidden("Garage does not support anonymous access yet"))?; + + let req = streaming::parse_streaming_body( + &api_key, + req, + &mut content_sha256, + &garage.config.s3_api.s3_region, + service, + )?; + + Ok((req, api_key, content_sha256)) +} + pub fn verify_signed_content(expected_sha256: Hash, body: &[u8]) -> Result<(), Error> { if expected_sha256 != sha256sum(body) { return Err(Error::bad_request( diff --git a/src/api/signature/payload.rs b/src/api/signature/payload.rs index 435b4206..949da601 100644 --- a/src/api/signature/payload.rs +++ b/src/api/signature/payload.rs @@ -35,15 +35,18 @@ pub type QueryMap = HashMap; pub async fn check_payload_signature( garage: &Garage, - service: &'static str, request: &mut Request, + service: &'static str, ) -> Result<(Option, Option), Error> { let query = parse_query_map(request.uri())?; - if request.headers().contains_key(AUTHORIZATION) { - check_standard_signature(garage, service, request, query).await - } else if query.contains_key(X_AMZ_ALGORITHM.as_str()) { + if query.contains_key(X_AMZ_ALGORITHM.as_str()) { + // We check for presigned-URL-style authentification first, because + // the browser or someting else could inject an Authorization header + // that is totally unrelated to AWS signatures. check_presigned_signature(garage, service, request, query).await + } else if request.headers().contains_key(AUTHORIZATION) { + check_standard_signature(garage, service, request, query).await } else { // Unsigned (anonymous) request let content_sha256 = request @@ -68,27 +71,15 @@ async fn check_standard_signature( request: &Request, query: QueryMap, ) -> Result<(Option, Option), Error> { - let authorization = Authorization::parse(request.headers())?; + let authorization = Authorization::parse_header(request.headers())?; // Verify that all necessary request headers are included in signed_headers - // For standard AWSv4 signatures, the following must be incldued: - // - the Host header (in all cases) + // For standard AWSv4 signatures, the following must be included: + // - the Host header (mandatory) // - the Content-Type header, if it is used in the request // - all x-amz-* headers used in the request let signed_headers = split_signed_headers(&authorization)?; - if !signed_headers.contains(&HOST) { - return Err(Error::bad_request("Header `Host` should be signed")); - } - for (name, _) in request.headers().iter() { - if name == CONTENT_TYPE || name.as_str().starts_with("x-amz-") { - if !signed_headers.contains(name) { - return Err(Error::bad_request(format!( - "Header `{}` should be signed", - name - ))); - } - } - } + verify_signed_headers(request.headers(), &signed_headers, &[CONTENT_TYPE])?; let canonical_request = canonical_request( service, @@ -135,22 +126,10 @@ async fn check_presigned_signature( // Verify that all necessary request headers are included in signed_headers // For AWSv4 pre-signed URLs, the following must be incldued: - // - the Host header (in all cases) + // - the Host header (mandatory) // - all x-amz-* headers used in the request let signed_headers = split_signed_headers(&authorization)?; - if !signed_headers.contains(&HOST) { - return Err(Error::bad_request("Header `Host` should be signed")); - } - for (name, _) in request.headers().iter() { - if name.as_str().starts_with("x-amz-") { - if !signed_headers.contains(name) { - return Err(Error::bad_request(format!( - "Header `{}` should be signed", - name - ))); - } - } - } + verify_signed_headers(request.headers(), &signed_headers, &[])?; // The X-Amz-Signature value is passed as a query parameter, // but the signature cannot be computed from a string that contains itself. @@ -172,13 +151,14 @@ async fn check_presigned_signature( &canonical_request, ); - trace!("canonical request:\n{}", canonical_request); - trace!("string to sign:\n{}", string_to_sign); + trace!("canonical request (presigned url):\n{}", canonical_request); + trace!("string to sign (presigned url):\n{}", string_to_sign); let key = verify_v4(garage, service, &authorization, string_to_sign.as_bytes()).await?; - // AWS specifies that if a signed query parameter and a signed header of the same name - // have different values, then an InvalidRequest error is raised. + // In the page on presigned URLs, AWS specifies that if a signed query + // parameter and a signed header of the same name have different values, + // then an InvalidRequest error is raised. let headers_mut = request.headers_mut(); for (name, value) in query.iter() { let name = @@ -197,7 +177,7 @@ async fn check_presigned_signature( // What we do is just add them to the Request object as regular headers, // that will be handled downstream as if they were included like in a normal request. // (Here we allow such query parameters to override headers with the same name - // if they are not signed, however there is not much reason that this would happen) + // that are not signed, however there is not much reason that this would happen) headers_mut.insert( name, HeaderValue::from_bytes(value.as_bytes()) @@ -206,6 +186,8 @@ async fn check_presigned_signature( } } + // Presigned URLs always use UNSIGNED-PAYLOAD, + // so there is no sha256 hash to return. Ok((Some(key), None)) } @@ -225,6 +207,17 @@ pub fn parse_query_map(uri: &http::uri::Uri) -> Result { Ok(query) } +fn parse_credential(cred: &str) -> Result<(String, String), Error> { + let first_slash = cred + .find('/') + .ok_or_bad_request("Credentials does not contain '/' in authorization field")?; + let (key_id, scope) = cred.split_at(first_slash); + Ok(( + key_id.to_string(), + scope.trim_start_matches('/').to_string(), + )) +} + fn split_signed_headers(authorization: &Authorization) -> Result, Error> { let mut signed_headers = authorization .signed_headers @@ -236,6 +229,27 @@ fn split_signed_headers(authorization: &Authorization) -> Result Ok(signed_headers) } +fn verify_signed_headers( + headers: &HeaderMap, + signed_headers: &[HeaderName], + extra_headers: &[HeaderName], +) -> Result<(), Error> { + if !signed_headers.contains(&HOST) { + return Err(Error::bad_request("Header `Host` should be signed")); + } + for (name, _) in headers.iter() { + if name.as_str().starts_with("x-amz-") || extra_headers.contains(name) { + if !signed_headers.contains(name) { + return Err(Error::bad_request(format!( + "Header `{}` should be signed", + name + ))); + } + } + } + Ok(()) +} + pub fn string_to_sign(datetime: &DateTime, scope_string: &str, canonical_req: &str) -> String { let mut hasher = Sha256::default(); hasher.update(canonical_req.as_bytes()); @@ -381,7 +395,7 @@ pub struct Authorization { } impl Authorization { - fn parse(headers: &HeaderMap) -> Result { + fn parse_header(headers: &HeaderMap) -> Result { let authorization = headers .get(AUTHORIZATION) .ok_or_bad_request("Missing authorization header")? @@ -535,14 +549,3 @@ impl Authorization { Ok(auth) } } - -fn parse_credential(cred: &str) -> Result<(String, String), Error> { - let first_slash = cred - .find('/') - .ok_or_bad_request("Credentials does not contain '/' in authorization field")?; - let (key_id, scope) = cred.split_at(first_slash); - Ok(( - key_id.to_string(), - scope.trim_start_matches('/').to_string(), - )) -} -- cgit v1.2.3