diff options
author | Alex Auvolat <alex@adnab.me> | 2024-03-01 12:35:08 +0100 |
---|---|---|
committer | Alex Auvolat <alex@adnab.me> | 2024-03-01 12:35:08 +0100 |
commit | 11d59d88480b49e7ccf478afc52f426e6a1682d6 (patch) | |
tree | 6130a7e53d8c3df44cad8b7481362a467bc14dab | |
parent | f01883794e475f5dae3d2d4f621b020e1134fa47 (diff) | |
download | garage-11d59d88480b49e7ccf478afc52f426e6a1682d6.tar.gz garage-11d59d88480b49e7ccf478afc52f426e6a1682d6.zip |
[backport-735-v0.8.x] backport AWS signature verification refactoring
-rw-r--r-- | src/api/k2v/api_server.rs | 15 | ||||
-rw-r--r-- | src/api/s3/api_server.rs | 15 | ||||
-rw-r--r-- | src/api/s3/post_object.rs | 26 | ||||
-rw-r--r-- | src/api/signature/mod.rs | 25 | ||||
-rw-r--r-- | src/api/signature/payload.rs | 603 | ||||
-rw-r--r-- | src/garage/tests/s3/streaming_signature.rs | 2 |
6 files changed, 431 insertions, 255 deletions
diff --git a/src/api/k2v/api_server.rs b/src/api/k2v/api_server.rs index 3a032aba..1c6d0b9e 100644 --- a/src/api/k2v/api_server.rs +++ b/src/api/k2v/api_server.rs @@ -15,8 +15,7 @@ use garage_model::garage::Garage; use crate::generic_server::*; use crate::k2v::error::*; -use crate::signature::payload::check_payload_signature; -use crate::signature::streaming::*; +use crate::signature::verify_request; use crate::helpers::*; use crate::k2v::batch::*; @@ -82,17 +81,7 @@ impl ApiHandler for K2VApiServer { .ok_or_bad_request("Error handling OPTIONS")?); } - let (api_key, mut content_sha256) = check_payload_signature(&garage, "k2v", &req).await?; - let api_key = api_key - .ok_or_else(|| Error::forbidden("Garage does not support anonymous access yet"))?; - - let req = parse_streaming_body( - &api_key, - req, - &mut content_sha256, - &garage.config.s3_api.s3_region, - "k2v", - )?; + let (req, api_key, _content_shaa256) = verify_request(&garage, req, "k2v").await?; let bucket_id = garage .bucket_helper() diff --git a/src/api/s3/api_server.rs b/src/api/s3/api_server.rs index ecfb48b6..c266b036 100644 --- a/src/api/s3/api_server.rs +++ b/src/api/s3/api_server.rs @@ -17,8 +17,7 @@ use garage_model::key_table::Key; use crate::generic_server::*; use crate::s3::error::*; -use crate::signature::payload::check_payload_signature; -use crate::signature::streaming::*; +use crate::signature::verify_request; use crate::helpers::*; use crate::s3::bucket::*; @@ -119,17 +118,7 @@ impl ApiHandler for S3ApiServer { return handle_options_s3api(garage, &req, bucket_name).await; } - let (api_key, mut content_sha256) = check_payload_signature(&garage, "s3", &req).await?; - let api_key = api_key - .ok_or_else(|| Error::forbidden("Garage does not support anonymous access yet"))?; - - let req = parse_streaming_body( - &api_key, - req, - &mut content_sha256, - &garage.config.s3_api.s3_region, - "s3", - )?; + let (req, api_key, content_sha256) = verify_request(&garage, req, "s3").await?; let bucket_name = match bucket_name { None => { diff --git a/src/api/s3/post_object.rs b/src/api/s3/post_object.rs index f9eccb7f..1f2fac59 100644 --- a/src/api/s3/post_object.rs +++ b/src/api/s3/post_object.rs @@ -19,7 +19,7 @@ use crate::s3::cors::*; use crate::s3::error::*; use crate::s3::put::{get_headers, save_stream}; use crate::s3::xml as s3_xml; -use crate::signature::payload::{parse_date, verify_v4}; +use crate::signature::payload::{verify_v4, Authorization}; pub async fn handle_post_object( garage: Arc<Garage>, @@ -85,22 +85,11 @@ pub async fn handle_post_object( .get("key") .ok_or_bad_request("No key was provided")? .to_str()?; - let credential = params - .get("x-amz-credential") - .ok_or_else(|| Error::forbidden("Garage does not support anonymous access yet"))? - .to_str()?; let policy = params .get("policy") .ok_or_bad_request("No policy was provided")? .to_str()?; - let signature = params - .get("x-amz-signature") - .ok_or_bad_request("No signature was provided")? - .to_str()?; - let date = params - .get("x-amz-date") - .ok_or_bad_request("No date was provided")? - .to_str()?; + let authorization = Authorization::parse_form(¶ms)?; let key = if key.contains("${filename}") { // if no filename is provided, don't replace. This matches the behavior of AWS. @@ -113,16 +102,7 @@ pub async fn handle_post_object( key.to_owned() }; - let date = parse_date(date)?; - let api_key = verify_v4( - &garage, - "s3", - credential, - &date, - signature, - policy.as_bytes(), - ) - .await?; + let api_key = verify_v4(&garage, "s3", &authorization, policy.as_bytes()).await?; let bucket_id = garage .bucket_helper() diff --git a/src/api/signature/mod.rs b/src/api/signature/mod.rs index 4b8b990f..e189aeca 100644 --- a/src/api/signature/mod.rs +++ b/src/api/signature/mod.rs @@ -2,6 +2,10 @@ use chrono::{DateTime, Utc}; use hmac::{Hmac, Mac}; use sha2::Sha256; +use hyper::{body::Body, Request}; + +use garage_model::garage::Garage; +use garage_model::key_table::Key; use garage_util::data::{sha256sum, Hash}; pub mod error; @@ -15,6 +19,27 @@ pub const LONG_DATETIME: &str = "%Y%m%dT%H%M%SZ"; type HmacSha256 = Hmac<Sha256>; +pub async fn verify_request( + garage: &Garage, + mut req: Request<Body>, + service: &'static str, +) -> Result<(Request<Body>, Key, Option<Hash>), 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 4a84610c..49bb0d1e 100644 --- a/src/api/signature/payload.rs +++ b/src/api/signature/payload.rs @@ -1,8 +1,10 @@ use std::collections::HashMap; +use std::convert::TryFrom; use chrono::{DateTime, Duration, NaiveDateTime, Utc}; use hmac::Mac; -use hyper::{Body, Method, Request}; +use hyper::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE, HOST}; +use hyper::{body::Body, Method, Request}; use sha2::{Digest, Sha256}; use garage_table::*; @@ -17,66 +19,91 @@ 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<String, String>; + pub async fn check_payload_signature( garage: &Garage, + request: &mut Request<Body>, service: &'static str, - request: &Request<Body>, ) -> Result<(Option<Key>, Option<Hash>), 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 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 query = parse_query_map(request.uri())?; + + 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 { - let content_sha256 = headers.get("x-amz-content-sha256"); - if let Some(content_sha256) = content_sha256.filter(|c| "UNSIGNED-PAYLOAD" != c.as_str()) { + // 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")?; - return Ok((None, Some(sha256))); + Ok((None, Some(sha256))) } else { - return Ok((None, None)); + Ok((None, None)) } - }; + } +} + +async fn check_standard_signature( + garage: &Garage, + service: &'static str, + request: &Request<Body>, + query: QueryMap, +) -> Result<(Option<Key>, Option<Hash>), Error> { + 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 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)?; + verify_signed_headers(request.headers(), &signed_headers, &[CONTENT_TYPE])?; 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,124 +115,96 @@ 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<Utc>, -} - -fn parse_authorization( - authorization: &str, - headers: &HashMap<String, String>, -) -> Result<Authorization, Error> { - 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")); - } - - 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 content_sha256 = headers - .get("x-amz-content-sha256") - .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) - .and_then(|d| parse_date(d))?; +async fn check_presigned_signature( + garage: &Garage, + service: &'static str, + request: &mut Request<Body>, + mut query: QueryMap, +) -> Result<(Option<Key>, Option<Hash>), Error> { + let algorithm = query.get(X_AMZ_ALGORITHM.as_str()).unwrap(); + let authorization = Authorization::parse_presigned(algorithm, &query)?; + + // Verify that all necessary request headers are included in signed_headers + // For AWSv4 pre-signed URLs, the following must be incldued: + // - the Host header (mandatory) + // - all x-amz-* headers used in the request + let signed_headers = split_signed_headers(&authorization)?; + 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. + // 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, + 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, + ); - if Utc::now() - date > Duration::hours(24) { - return Err(Error::bad_request("Date is too old".to_string())); + 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?; + + // 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 = + 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 + // that 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")?, + ); + } } - 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) + // Presigned URLs always use UNSIGNED-PAYLOAD, + // so there is no sha256 hash to return. + Ok((Some(key), None)) } -fn parse_query_authorization( - algorithm: &str, - headers: &HashMap<String, String>, -) -> Result<Authorization, Error> { - 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<QueryMap, Error> { + 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> { @@ -219,11 +218,43 @@ fn parse_credential(cred: &str) -> Result<(String, String), Error> { )) } +fn split_signed_headers(authorization: &Authorization) -> Result<Vec<HeaderName>, Error> { + let mut signed_headers = authorization + .signed_headers + .split(';') + .map(HeaderName::try_from) + .collect::<Result<Vec<HeaderName>, _>>() + .ok_or_bad_request("invalid header name")?; + signed_headers.sort_by(|h1, h2| h1.as_str().cmp(h2.as_str())); + 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<Utc>, 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 +265,12 @@ pub fn string_to_sign(datetime: &DateTime<Utc>, scope_string: &str, canonical_re pub fn canonical_request( service: &'static str, method: &Method, - uri: &hyper::Uri, - headers: &HashMap<String, String>, - signed_headers: &str, + canonical_uri: &str, + query: &QueryMap, + headers: &HeaderMap, + signed_headers: &[HeaderName], content_sha256: &str, -) -> String { +) -> Result<String, Error> { // 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 +300,46 @@ 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<str> = if service != "s3" { - uri_encode(uri.path(), false).into() + let canonical_uri: std::borrow::Cow<str> = 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<String, String>, signed_headers: &str) -> String { - let signed_headers_vec = signed_headers.split(';').collect::<Vec<_>>(); - let mut items = headers - .iter() - .filter(|(key, _)| signed_headers_vec.contains(&key.as_str())) - .collect::<Vec<_>>(); - items.sort_by(|(k1, _), (k2, _)| k1.cmp(k2)); - items - .iter() - .map(|(key, value)| key.to_lowercase() + ":" + value.trim()) - .collect::<Vec<_>>() - .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::<Vec<_>>(); + // 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 + 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()?; + Ok(format!("{}:{}", name.as_str(), value.trim())) + }) + .collect::<Result<Vec<String>, Error>>()? + .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<DateTime<Utc>, Error> { @@ -322,38 +351,202 @@ pub fn parse_date(date: &str) -> Result<DateTime<Utc>, Error> { pub async fn verify_v4( garage: &Garage, service: &str, - credential: &str, - date: &DateTime<Utc>, - signature: &str, + auth: &Authorization, payload: &[u8], ) -> Result<Key, Error> { - 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, ) .ok_or_internal_error("Unable to build signing HMAC")?; hmac.update(payload); - let signature = hex::decode(&signature).map_err(|_| Error::forbidden("Invalid signature"))?; + let signature = + hex::decode(&auth.signature).map_err(|_| Error::forbidden("Invalid signature"))?; if hmac.verify_slice(&signature).is_err() { return Err(Error::forbidden("Invalid signature")); } 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<Utc>, +} + +impl Authorization { + fn parse_header(headers: &HeaderMap) -> Result<Self, Error> { + 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<Self, Error> { + 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<Self, Error> { + 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"))? + .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) + } +} diff --git a/src/garage/tests/s3/streaming_signature.rs b/src/garage/tests/s3/streaming_signature.rs index b7a1acae..968f7189 100644 --- a/src/garage/tests/s3/streaming_signature.rs +++ b/src/garage/tests/s3/streaming_signature.rs @@ -26,7 +26,7 @@ async fn test_putobject_streaming() { .builder(bucket.clone()) .method(Method::PUT) .path(STD_KEY.to_owned()) - .unsigned_headers(headers) + .signed_headers(headers) .vhost_style(true) .body(vec![]) .body_signature(BodySignature::Streaming(10)) |