//! AWS Signature Version 4 authentication for S3 API //! //! Implements simplified SigV4 authentication compatible with AWS S3 SDKs and aws-cli. //! Integrates with IAM for access key validation. use crate::config::S3AuthConfig; use crate::tenant::TenantContext; use axum::{ body::{Body, Bytes}, extract::Request, http::{HeaderMap, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; use hmac::{Hmac, Mac}; use iam_api::proto::{iam_credential_client::IamCredentialClient, GetSecretKeyRequest}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration as StdDuration, Instant}; use tokio::sync::{Mutex, RwLock}; use tonic::{transport::Channel, Request as TonicRequest}; use tracing::{debug, warn}; use url::form_urlencoded; type HmacSha256 = Hmac; #[derive(Clone, Debug)] pub(crate) struct VerifiedBodyBytes(pub Bytes); #[derive(Clone, Debug)] pub(crate) struct VerifiedPayloadHash(pub String); #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct VerifiedTenantContext(pub TenantContext); fn should_buffer_auth_body(payload_hash_header: Option<&str>) -> bool { payload_hash_header.is_none() } /// SigV4 authentication state #[derive(Clone)] pub struct AuthState { /// IAM client for credential validation (optional for MVP) iam_client: Option>>, /// Enable/disable auth (for testing) pub enabled: bool, /// AWS region for SigV4 (e.g., us-east-1) aws_region: String, /// AWS service name for SigV4 (e.g., s3) aws_service: String, /// Maximum request body size to buffer during auth verification max_auth_body_bytes: usize, } pub struct IamClient { mode: IamClientMode, credential_cache: Arc>>, cache_ttl: StdDuration, } enum IamClientMode { Env { credentials: std::collections::HashMap, default_org_id: Option, default_project_id: Option, }, Grpc { endpoint: String, channel: Arc>>, }, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ResolvedCredential { pub secret_key: String, pub principal_id: String, pub org_id: Option, pub project_id: Option, } struct CachedCredential { credential: ResolvedCredential, cached_at: Instant, } impl IamClient { /// Create a new IAM client. If an endpoint is supplied, use the IAM gRPC API. pub fn new(iam_endpoint: Option) -> Self { Self::new_with_config(iam_endpoint, &S3AuthConfig::default()) } pub fn new_with_config(iam_endpoint: Option, config: &S3AuthConfig) -> Self { let cache_ttl = StdDuration::from_secs(config.iam_cache_ttl_secs); if let Some(endpoint) = iam_endpoint .map(|value| normalize_iam_endpoint(&value)) .filter(|value| !value.is_empty()) { return Self { mode: IamClientMode::Grpc { endpoint, channel: Arc::new(Mutex::new(None)), }, credential_cache: Arc::new(RwLock::new(HashMap::new())), cache_ttl, }; } Self { mode: IamClientMode::Env { credentials: Self::load_env_credentials(), default_org_id: config.default_org_id.clone(), default_project_id: config.default_project_id.clone(), }, credential_cache: Arc::new(RwLock::new(HashMap::new())), cache_ttl, } } /// Load credentials from environment variables for fallback/testing. /// /// Supports two formats: /// 1. Single credential: S3_ACCESS_KEY_ID + S3_SECRET_KEY /// 2. Multiple credentials: S3_CREDENTIALS="key1:secret1,key2:secret2,..." fn load_env_credentials() -> std::collections::HashMap { let mut credentials = std::collections::HashMap::new(); // Option 1: Multiple credentials via S3_CREDENTIALS if let Ok(creds_str) = std::env::var("S3_CREDENTIALS") { for pair in creds_str.split(',') { if let Some((access_key, secret_key)) = pair.split_once(':') { credentials .insert(access_key.trim().to_string(), secret_key.trim().to_string()); } else { warn!("Invalid S3_CREDENTIALS format for pair: {}", pair); } } if !credentials.is_empty() { debug!( "Loaded {} S3 credential(s) from S3_CREDENTIALS", credentials.len() ); } } // Option 2: Single credential via separate env vars (legacy support) if credentials.is_empty() { if let (Ok(access_key_id), Ok(secret_key)) = ( std::env::var("S3_ACCESS_KEY_ID"), std::env::var("S3_SECRET_KEY"), ) { credentials.insert(access_key_id, secret_key); debug!("Loaded S3 credentials from S3_ACCESS_KEY_ID/S3_SECRET_KEY"); } } if credentials.is_empty() { warn!("No S3 credentials configured. Auth will reject all requests."); warn!("Set S3_CREDENTIALS or S3_ACCESS_KEY_ID/S3_SECRET_KEY to enable access."); } credentials } #[cfg(test)] fn env_credentials(&self) -> Option<&std::collections::HashMap> { match &self.mode { IamClientMode::Env { credentials, .. } => Some(credentials), IamClientMode::Grpc { .. } => None, } } fn env_default_tenant( default_org_id: Option, default_project_id: Option, ) -> (Option, Option) { (default_org_id, default_project_id) } /// Validate access key and resolve the credential context. pub async fn get_credential(&self, access_key_id: &str) -> Result { match &self.mode { IamClientMode::Env { credentials, default_org_id, default_project_id, } => { let secret_key = credentials .get(access_key_id) .cloned() .ok_or_else(|| "Access key ID not found".to_string())?; let (org_id, project_id) = Self::env_default_tenant(default_org_id.clone(), default_project_id.clone()); Ok(ResolvedCredential { secret_key, principal_id: access_key_id.to_string(), org_id, project_id, }) } IamClientMode::Grpc { endpoint, channel } => { if let Some(credential) = self.cached_credential(access_key_id).await { return Ok(credential); } let response = self .grpc_get_secret_key(endpoint, channel, access_key_id) .await?; let response = response.into_inner(); let credential = ResolvedCredential { secret_key: response.secret_key, principal_id: response.principal_id, org_id: response.org_id, project_id: response.project_id, }; self.cache_credential(access_key_id, &credential).await; Ok(credential) } } } async fn cached_credential(&self, access_key_id: &str) -> Option { let cache = self.credential_cache.read().await; cache.get(access_key_id).and_then(|entry| { if entry.cached_at.elapsed() <= self.cache_ttl { Some(entry.credential.clone()) } else { None } }) } async fn cache_credential(&self, access_key_id: &str, credential: &ResolvedCredential) { let mut cache = self.credential_cache.write().await; cache.insert( access_key_id.to_string(), CachedCredential { credential: credential.clone(), cached_at: Instant::now(), }, ); } async fn grpc_channel( endpoint: &str, channel: &Arc>>, ) -> Result { let mut cached = channel.lock().await; if let Some(existing) = cached.as_ref() { return Ok(existing.clone()); } let created = Channel::from_shared(endpoint.to_string()) .map_err(|e| format!("failed to parse IAM credential endpoint: {}", e))? .connect() .await .map_err(|e| format!("failed to connect to IAM credential service: {}", e))?; *cached = Some(created.clone()); Ok(created) } async fn invalidate_grpc_channel(channel: &Arc>>) { let mut cached = channel.lock().await; *cached = None; } async fn grpc_get_secret_key( &self, endpoint: &str, channel: &Arc>>, access_key_id: &str, ) -> Result, String> { for attempt in 0..2 { let grpc_channel = Self::grpc_channel(endpoint, channel).await?; let mut client = IamCredentialClient::new(grpc_channel); let mut request = TonicRequest::new(GetSecretKeyRequest { access_key_id: access_key_id.to_string(), }); if let Some(token) = iam_admin_token() { if let Ok(value) = token.parse() { request.metadata_mut().insert("x-iam-admin-token", value); } } match client.get_secret_key(request).await { Ok(response) => return Ok(response), Err(status) if attempt == 0 && matches!( status.code(), tonic::Code::Unavailable | tonic::Code::Cancelled | tonic::Code::Unknown | tonic::Code::DeadlineExceeded | tonic::Code::Internal ) => { Self::invalidate_grpc_channel(channel).await; } Err(status) => return Err(status.message().to_string()), } } Err("IAM credential lookup exhausted retries".to_string()) } } fn normalize_iam_endpoint(endpoint: &str) -> String { if endpoint.starts_with("http://") || endpoint.starts_with("https://") { endpoint.to_string() } else { format!("http://{}", endpoint) } } fn iam_admin_token() -> Option { std::env::var("IAM_ADMIN_TOKEN") .or_else(|_| std::env::var("PHOTON_IAM_ADMIN_TOKEN")) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } impl AuthState { /// Create new auth state with IAM integration pub fn new(iam_endpoint: Option) -> Self { Self::new_with_config(iam_endpoint, &S3AuthConfig::default()) } pub fn new_with_config(iam_endpoint: Option, config: &S3AuthConfig) -> Self { let iam_client = Some(Arc::new(RwLock::new(IamClient::new_with_config( iam_endpoint, config, )))); Self { iam_client, enabled: config.enabled, aws_region: config.aws_region.clone(), aws_service: "s3".to_string(), max_auth_body_bytes: config.max_auth_body_bytes, } } /// Create auth state with auth disabled (for testing) pub fn disabled() -> Self { Self { iam_client: None, enabled: false, aws_region: "us-east-1".to_string(), aws_service: "s3".to_string(), max_auth_body_bytes: S3AuthConfig::default().max_auth_body_bytes, } } } /// SigV4 authentication middleware pub async fn sigv4_auth_middleware( auth_state: Arc, // This is required to read the request body mut request: Request, next: Next, ) -> Response { // Skip auth if disabled if !auth_state.enabled { debug!("S3 auth disabled, allowing request"); return next.run(request).await; } let headers = request.headers().clone(); let method = request.method().to_string(); let uri = request.uri().to_string(); // Extract Authorization header let auth_header = match headers.get("authorization") { Some(header) => match header.to_str() { Ok(s) => s, Err(_) => { return error_response( StatusCode::BAD_REQUEST, "InvalidArgument", "Invalid Authorization header encoding", ); } }, None => { return error_response( StatusCode::FORBIDDEN, "AccessDenied", "Authorization header required", ); } }; let (access_key_id, credential_scope, signed_headers_str, provided_signature) = match parse_auth_header(auth_header) { Ok(val) => val, Err(e) => return error_response(StatusCode::BAD_REQUEST, "InvalidArgument", &e), }; let amz_date = match headers.get("x-amz-date") { Some(header) => match header.to_str() { Ok(s) => s, Err(_) => { return error_response( StatusCode::BAD_REQUEST, "InvalidArgument", "Invalid x-amz-date header encoding", ); } }, None => { return error_response( StatusCode::BAD_REQUEST, "InvalidArgument", "x-amz-date header required", ); } }; // Get secret key from IAM (or use dummy for MVP) let credential = if let Some(ref iam) = auth_state.iam_client { match iam.read().await.get_credential(&access_key_id).await { Ok(credential) => credential, Err(e) => { warn!("IAM credential validation failed: {}", e); return error_response( StatusCode::FORBIDDEN, "InvalidAccessKeyId", "The AWS Access Key Id you provided does not exist in our records", ); } } } else { debug!("No IAM integration, using dummy secret key if IamClient wasn't initialized."); ResolvedCredential { secret_key: "dummy_secret_key_for_mvp".to_string(), principal_id: access_key_id.clone(), org_id: Some("default".to_string()), project_id: Some("default".to_string()), } }; let secret_key = credential.secret_key.as_str(); let payload_hash_header = headers .get("x-amz-content-sha256") .and_then(|value| value.to_str().ok()) .filter(|value| !value.is_empty()) .map(str::to_string); let should_buffer_body = should_buffer_auth_body(payload_hash_header.as_deref()); let body_bytes = if should_buffer_body { let (parts, body) = request.into_parts(); let body_bytes = match axum::body::to_bytes(body, auth_state.max_auth_body_bytes).await { Ok(b) => b, Err(e) => { return error_response( StatusCode::INTERNAL_SERVER_ERROR, "InternalError", &e.to_string(), ) } }; request = Request::from_parts(parts, Body::from(body_bytes.clone())); request .extensions_mut() .insert(VerifiedBodyBytes(body_bytes.clone())); body_bytes } else { if let Some(payload_hash) = payload_hash_header { request .extensions_mut() .insert(VerifiedPayloadHash(payload_hash)); } Bytes::new() }; let (canonical_request, hashed_payload) = match build_canonical_request(&method, &uri, &headers, &body_bytes, &signed_headers_str) { Ok(val) => val, Err(e) => { return error_response(StatusCode::INTERNAL_SERVER_ERROR, "SignatureError", &e) } }; debug!( method = %method, uri = %uri, headers = ?headers, signed_headers_str = %signed_headers_str, hashed_payload = %hashed_payload, canonical_request = %canonical_request, "SigV4 Canonical Request generated" ); let string_to_sign = build_string_to_sign(amz_date, &credential_scope, &canonical_request); debug!( amz_date = %amz_date, credential_scope = %credential_scope, string_to_sign = %string_to_sign, "SigV4 String to Sign generated" ); let expected_signature = match compute_sigv4_signature( secret_key, &method, &uri, &headers, amz_date, &body_bytes, &credential_scope, &signed_headers_str, &auth_state.aws_region, &auth_state.aws_service, ) { Ok(sig) => sig, Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, "SignatureError", &e), }; // Compare signatures if provided_signature != expected_signature { debug!( "Signature mismatch: provided={}, expected={}", provided_signature, expected_signature ); return error_response( StatusCode::FORBIDDEN, "SignatureDoesNotMatch", "The request signature we calculated does not match the signature you provided", ); } match (credential.org_id, credential.project_id) { (Some(org_id), Some(project_id)) => { request .extensions_mut() .insert(VerifiedTenantContext(TenantContext { org_id, project_id })); } _ => { return error_response( StatusCode::FORBIDDEN, "AccessDenied", "S3 credential is missing tenant scope", ); } } // Auth successful debug!("SigV4 auth successful for access_key={}", access_key_id); next.run(request).await } /// Parses the Authorization header to extract relevant SigV4 components. /// Returns (AccessKeyId, CredentialScope, SignedHeaders, Signature) fn parse_auth_header(auth_header: &str) -> Result<(String, String, String, String), String> { let auth_header = auth_header.trim_start_matches("AWS4-HMAC-SHA256 "); let parts: HashMap<&str, &str> = auth_header .split(", ") .filter_map(|s| s.split_once('=')) .collect(); let credential_str = parts .get("Credential") .ok_or("Credential not found in Authorization header")?; let access_key_id = credential_str .split('/') .next() .ok_or("Access Key ID not found in Credential")?; let full_credential_scope = credential_str .splitn(5, '/') .skip(1) .collect::>() .join("/"); // Date/Region/Service/aws4_request let signed_headers = parts .get("SignedHeaders") .ok_or("SignedHeaders not found in Authorization header")?; let signature = parts .get("Signature") .ok_or("Signature not found in Authorization header")?; Ok(( access_key_id.to_string(), full_credential_scope, signed_headers.to_string(), signature.to_string(), )) } /// Compute the full AWS Signature Version 4. #[allow(clippy::too_many_arguments)] fn compute_sigv4_signature( secret_key: &str, method: &str, uri: &str, headers: &HeaderMap, amz_date: &str, body_bytes: &Bytes, credential_scope: &str, signed_headers_str: &str, aws_region: &str, aws_service: &str, ) -> Result { let (canonical_request, _hashed_payload) = build_canonical_request(method, uri, headers, body_bytes, signed_headers_str)?; let string_to_sign = build_string_to_sign(amz_date, credential_scope, &canonical_request); let signing_key = get_signing_key(secret_key, amz_date, aws_region, aws_service)?; let mut mac = HmacSha256::new_from_slice(&signing_key).map_err(|e| e.to_string())?; mac.update(string_to_sign.as_bytes()); let result = mac.finalize(); Ok(hex::encode(result.into_bytes())) } /// Builds the Canonical Request as per AWS SigV4 specification. fn build_canonical_request( method: &str, uri: &str, headers: &HeaderMap, body_bytes: &Bytes, signed_headers_str: &str, ) -> Result<(String, String), String> { // Canonical URI let uri_parts: Vec<&str> = uri.split('?').collect(); let canonical_uri = url_encode_path(uri_parts[0]); // Canonical Query String let canonical_query_string = if uri_parts.len() > 1 { let mut query_params: Vec<(String, String)> = form_urlencoded::parse(uri_parts[1].as_bytes()) .into_owned() .collect(); query_params.sort_by(|(k1, _), (k2, _)| k1.cmp(k2)); query_params .into_iter() .map(|(k, v)| format!("{}={}", url_encode(&k), url_encode(&v))) .collect::>() .join("&") } else { "".to_string() }; // Canonical Headers let mut canonical_headers = String::new(); let mut sorted_signed_headers: Vec = signed_headers_str .split(';') .map(|s| s.trim().to_lowercase()) .collect(); sorted_signed_headers.sort(); for header_name in sorted_signed_headers.iter() { if let Some(header_value) = headers.get(header_name) { let value_str = header_value .to_str() .map_err(|_| format!("Invalid header value for {}", header_name))?; canonical_headers.push_str(&format!( "{}:{} ", header_name, value_str.trim() )); } else { return Err(format!( "Signed header '{}' not found in request", header_name )); } } // Hashed Payload let hashed_payload = if signed_headers_str .split(';') .any(|header| header.trim().eq_ignore_ascii_case("x-amz-content-sha256")) { headers .get("x-amz-content-sha256") .and_then(|value| value.to_str().ok()) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| hex::encode(Sha256::digest(body_bytes))) } else { hex::encode(Sha256::digest(body_bytes)) }; let canonical_request = format!( "{method} {canonical_uri} {canonical_query_string} {canonical_headers} {signed_headers_str} {hashed_payload}", method = method, canonical_uri = canonical_uri, canonical_query_string = canonical_query_string, canonical_headers = canonical_headers, signed_headers_str = signed_headers_str, hashed_payload = hashed_payload ); Ok((canonical_request, hashed_payload)) } /// Builds the StringToSign as per AWS SigV4 specification. fn build_string_to_sign(amz_date: &str, credential_scope: &str, canonical_request: &str) -> String { let hashed_canonical_request = hex::encode(Sha256::digest(canonical_request.as_bytes())); format!( "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashed_canonical_request}", amz_date = amz_date, credential_scope = credential_scope, hashed_canonical_request = hashed_canonical_request ) } /// Derives the signing key as per AWS SigV4 specification. fn get_signing_key( secret_key: &str, amz_date: &str, aws_region: &str, aws_service: &str, ) -> Result, String> { let k_secret = format!("AWS4{}", secret_key); let k_date = hmac_sha256(k_secret.as_bytes(), &amz_date[0..8])?; // Date (YYYYMMDD) let k_region = hmac_sha256(&k_date, aws_region)?; let k_service = hmac_sha256(&k_region, aws_service)?; let k_signing = hmac_sha256(&k_service, "aws4_request")?; Ok(k_signing) } /// Helper for HMAC-SHA256 operations. fn hmac_sha256(key: &[u8], data: &str) -> Result, String> { let mut mac = HmacSha256::new_from_slice(key).map_err(|e| e.to_string())?; mac.update(data.as_bytes()); Ok(mac.finalize().into_bytes().to_vec()) } /// URL-encodes a string for AWS SigV4 (RFC 3986 style). /// Encodes everything except: A-Z, a-z, 0-9, -, _, ., ~ /// Uses uppercase hex digits for percent-encoding. fn aws_uri_encode(s: &str, encode_slash: bool) -> String { let mut encoded = String::new(); for byte in s.as_bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { encoded.push(*byte as char); } b'/' if !encode_slash => { encoded.push('/'); } _ => { encoded.push_str(&format!("%{:02X}", byte)); } } } encoded } /// URL-encodes a string, specifically for query parameters. /// For query parameters, we need to encode more characters including '/' fn url_encode(s: &str) -> String { aws_uri_encode(s, true) } /// URL-encodes a path according to AWS SigV4 specification. /// Preserves slashes (/) but encodes all other special characters. /// Returns "/" for empty paths as per AWS spec. fn url_encode_path(s: &str) -> String { if s.is_empty() || s == "/" { return "/".to_string(); } aws_uri_encode(s, false) } /// Create an S3 XML error response fn error_response(status: StatusCode, code: &str, message: &str) -> Response { let xml = format!( r###" {} {} "###, code, message ); Response::builder() .status(status) .header("Content-Type", "application/xml") .body(Body::from(xml)) .unwrap() .into_response() } #[cfg(test)] mod tests { use super::*; use axum::http::HeaderValue; use iam_api::proto::{ iam_credential_server::{IamCredential, IamCredentialServer}, CreateS3CredentialRequest, CreateS3CredentialResponse, Credential, GetSecretKeyResponse, ListCredentialsRequest, ListCredentialsResponse, RevokeCredentialRequest, RevokeCredentialResponse, }; use std::collections::HashMap; use std::net::SocketAddr; use std::sync::{ atomic::{AtomicUsize, Ordering}, Mutex, }; use tokio::net::TcpListener; use tokio::time::{sleep, Duration}; use tonic::transport::Server; use tonic::{Request as TonicRequest, Response as TonicResponse, Status}; static ENV_LOCK: Mutex<()> = Mutex::new(()); #[derive(Clone, Default)] struct MockIamCredentialService { secrets: Arc>, get_secret_calls: Arc, } #[tonic::async_trait] impl IamCredential for MockIamCredentialService { async fn create_s3_credential( &self, _request: TonicRequest, ) -> Result, Status> { Err(Status::unimplemented("not needed in test")) } async fn get_secret_key( &self, request: TonicRequest, ) -> Result, Status> { let access_key_id = request.into_inner().access_key_id; self.get_secret_calls.fetch_add(1, Ordering::SeqCst); let Some(secret_key) = self.secrets.get(&access_key_id) else { return Err(Status::not_found("access key not found")); }; Ok(TonicResponse::new(GetSecretKeyResponse { secret_key: secret_key.clone(), principal_id: "test-principal".to_string(), expires_at: None, org_id: Some("test-org".to_string()), project_id: Some("test-project".to_string()), principal_kind: iam_api::proto::PrincipalKind::ServiceAccount as i32, })) } async fn list_credentials( &self, _request: TonicRequest, ) -> Result, Status> { Ok(TonicResponse::new(ListCredentialsResponse { credentials: Vec::::new(), })) } async fn revoke_credential( &self, _request: TonicRequest, ) -> Result, Status> { Ok(TonicResponse::new(RevokeCredentialResponse { success: true, })) } } async fn start_mock_iam(secrets: HashMap) -> (SocketAddr, Arc) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let get_secret_calls = Arc::new(AtomicUsize::new(0)); let service = MockIamCredentialService { secrets: Arc::new(secrets), get_secret_calls: get_secret_calls.clone(), }; drop(listener); tokio::spawn(async move { Server::builder() .add_service(IamCredentialServer::new(service)) .serve(addr) .await .unwrap(); }); for _ in 0..20 { if tokio::net::TcpStream::connect(addr).await.is_ok() { return (addr, get_secret_calls); } sleep(Duration::from_millis(25)).await; } panic!("mock IAM server did not start on {}", addr); } #[tokio::test] async fn test_parse_auth_header() { let auth_header = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20231201/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123def456"; let (access_key, credential_scope, signed_headers, signature) = parse_auth_header(auth_header).unwrap(); assert_eq!(access_key, "AKIAIOSFODNN7EXAMPLE"); assert_eq!(credential_scope, "20231201/us-east-1/s3/aws4_request"); assert_eq!(signed_headers, "host;x-amz-date"); assert_eq!(signature, "abc123def456"); } #[test] fn test_hmac_sha256() { let key = b"key"; let data = "data"; // Verified with: echo -n "data" | openssl dgst -sha256 -mac hmac -macopt key:"key" let expected = hex::decode("5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0") .unwrap(); assert_eq!(hmac_sha256(key, data).unwrap(), expected); } #[test] fn test_get_signing_key() { let secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; let amz_date = "20231201T000000Z"; let aws_region = "us-east-1"; let aws_service = "s3"; let key = get_signing_key(secret_key, amz_date, aws_region, aws_service).unwrap(); // Expected signing key for these inputs can be found by external tools or AWS docs // For now, just check it's not empty and has correct length assert!(!key.is_empty()); assert_eq!(key.len(), 32); // SHA256 output length } #[test] fn test_url_encode() { assert_eq!(url_encode("foo"), "foo"); assert_eq!(url_encode("foo bar"), "foo%20bar"); assert_eq!(url_encode("foo/bar"), "foo%2Fbar"); } #[test] fn test_url_encode_path() { assert_eq!(url_encode_path("/foo/bar"), "/foo/bar"); assert_eq!(url_encode_path("/foo bar/baz"), "/foo%20bar/baz"); assert_eq!(url_encode_path("/"), "/"); assert_eq!(url_encode_path(""), "/"); // Empty path should be normalized to / // Test special characters that should be encoded assert_eq!(url_encode_path("/my+bucket"), "/my%2Bbucket"); assert_eq!(url_encode_path("/my=bucket"), "/my%3Dbucket"); // Test unreserved characters that should NOT be encoded assert_eq!( url_encode_path("/my-bucket_test.file~123"), "/my-bucket_test.file~123" ); } #[tokio::test] async fn test_build_canonical_request() { let method = "PUT"; let uri = "/mybucket/myobject?param1=value1¶m2=value2"; let mut headers = HeaderMap::new(); headers.insert("Host", HeaderValue::from_static("example.com")); headers.insert("Content-Type", HeaderValue::from_static("application/xml")); headers.insert("x-amz-date", HeaderValue::from_static("20231201T000000Z")); let body = Bytes::from("some_body"); let signed_headers = "content-type;host;x-amz-date"; let (canonical_request, hashed_payload) = build_canonical_request(method, uri, &headers, &body, signed_headers).unwrap(); // Body hash verified with: echo -n "some_body" | sha256sum let expected_body_hash = "fed42376ceefa4bb65ead687ec9738f6b2329fd78870aaf797bd7194da4228d3"; let expected_canonical_request = format!( "PUT\n/mybucket/myobject\nparam1=value1¶m2=value2\ncontent-type:application/xml\nhost:example.com\nx-amz-date:20231201T000000Z\n\ncontent-type;host;x-amz-date\n{}", expected_body_hash ); assert_eq!(canonical_request, expected_canonical_request); assert_eq!(hashed_payload, expected_body_hash); } #[tokio::test] async fn test_build_canonical_request_prefers_signed_payload_hash_header() { let method = "PUT"; let uri = "/mybucket/myobject"; let mut headers = HeaderMap::new(); headers.insert("host", HeaderValue::from_static("example.com")); headers.insert("x-amz-date", HeaderValue::from_static("20231201T000000Z")); headers.insert( "x-amz-content-sha256", HeaderValue::from_static("signed-payload-hash"), ); let body = Bytes::from("different-body"); let signed_headers = "host;x-amz-content-sha256;x-amz-date"; let (canonical_request, hashed_payload) = build_canonical_request(method, uri, &headers, &body, signed_headers).unwrap(); assert!(canonical_request.ends_with("\nsigned-payload-hash")); assert_eq!(hashed_payload, "signed-payload-hash"); } #[test] fn test_should_buffer_auth_body_only_when_hash_header_missing() { assert!(should_buffer_auth_body(None)); assert!(!should_buffer_auth_body(Some("signed-payload-hash"))); assert!(!should_buffer_auth_body(Some("UNSIGNED-PAYLOAD"))); } #[test] fn test_build_string_to_sign() { let amz_date = "20231201T000000Z"; let credential_scope = "20231201/us-east-1/s3/aws4_request"; let canonical_request = "some_canonical_request"; // Hashed below let string_to_sign = build_string_to_sign(amz_date, credential_scope, canonical_request); let hashed_canonical_request = hex::encode(Sha256::digest(canonical_request.as_bytes())); let expected_string_to_sign = format!( "AWS4-HMAC-SHA256\n{}\n{}\n{}", amz_date, credential_scope, hashed_canonical_request ); assert_eq!(string_to_sign, expected_string_to_sign); } #[test] fn test_iam_client_multi_credentials() { let _guard = ENV_LOCK.lock().unwrap(); // Test parsing S3_CREDENTIALS format std::env::set_var("S3_CREDENTIALS", "key1:secret1,key2:secret2,key3:secret3"); let client = IamClient::new(None); let credentials = client.env_credentials().unwrap(); assert_eq!(credentials.len(), 3); assert_eq!(credentials.get("key1"), Some(&"secret1".to_string())); assert_eq!(credentials.get("key2"), Some(&"secret2".to_string())); assert_eq!(credentials.get("key3"), Some(&"secret3".to_string())); std::env::remove_var("S3_CREDENTIALS"); } #[test] fn test_iam_client_single_credentials() { let _guard = ENV_LOCK.lock().unwrap(); // Test legacy S3_ACCESS_KEY_ID/S3_SECRET_KEY format std::env::remove_var("S3_CREDENTIALS"); std::env::set_var("S3_ACCESS_KEY_ID", "test_key"); std::env::set_var("S3_SECRET_KEY", "test_secret"); let client = IamClient::new(None); let credentials = client.env_credentials().unwrap(); assert_eq!(credentials.len(), 1); assert_eq!( credentials.get("test_key"), Some(&"test_secret".to_string()) ); std::env::remove_var("S3_ACCESS_KEY_ID"); std::env::remove_var("S3_SECRET_KEY"); } #[tokio::test] async fn test_iam_client_grpc_lookup() { let (addr, _calls) = start_mock_iam(HashMap::from([( "grpc_key".to_string(), "grpc_secret".to_string(), )])) .await; let client = IamClient::new(Some(addr.to_string())); let credential = client.get_credential("grpc_key").await.unwrap(); assert_eq!(credential.secret_key, "grpc_secret"); assert_eq!(credential.org_id.as_deref(), Some("test-org")); assert_eq!(credential.project_id.as_deref(), Some("test-project")); assert_eq!( client.get_credential("missing").await.unwrap_err(), "access key not found" ); } #[tokio::test] async fn test_iam_client_grpc_cache_reuses_secret() { let (addr, calls) = start_mock_iam(HashMap::from([( "grpc_key".to_string(), "grpc_secret".to_string(), )])) .await; let client = IamClient::new(Some(addr.to_string())); assert_eq!( client.get_credential("grpc_key").await.unwrap().secret_key, "grpc_secret" ); assert_eq!( client.get_credential("grpc_key").await.unwrap().secret_key, "grpc_secret" ); assert_eq!(calls.load(Ordering::SeqCst), 1); } #[test] fn test_complete_sigv4_signature() { // Test with AWS example credentials (from AWS docs) let secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; let method = "GET"; let uri = "/"; let amz_date = "20150830T123600Z"; let credential_scope = "20150830/us-east-1/s3/aws4_request"; let signed_headers = "host;x-amz-date"; let mut headers = HeaderMap::new(); headers.insert( "host", HeaderValue::from_static("examplebucket.s3.amazonaws.com"), ); headers.insert("x-amz-date", HeaderValue::from_static("20150830T123600Z")); let body = Bytes::new(); // Empty body for GET // Build canonical request let (canonical_request, _) = build_canonical_request(method, uri, &headers, &body, signed_headers).unwrap(); // Build string to sign let _string_to_sign = build_string_to_sign(amz_date, credential_scope, &canonical_request); // Compute signature let signature = compute_sigv4_signature( secret_key, method, uri, &headers, amz_date, &body, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); // Verify signature is deterministic (same inputs = same output) let signature2 = compute_sigv4_signature( secret_key, method, uri, &headers, amz_date, &body, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); assert_eq!(signature, signature2); assert_eq!(signature.len(), 64); // SHA256 hex = 64 chars } // ============================================================================= // Security Tests // ============================================================================= #[test] fn test_security_invalid_auth_header_format() { // Missing Credential field let malformed1 = "AWS4-HMAC-SHA256 SignedHeaders=host, Signature=abc123"; assert!(parse_auth_header(malformed1).is_err()); // Missing SignedHeaders field let malformed2 = "AWS4-HMAC-SHA256 Credential=KEY/scope, Signature=abc123"; assert!(parse_auth_header(malformed2).is_err()); // Missing Signature field let malformed3 = "AWS4-HMAC-SHA256 Credential=KEY/scope, SignedHeaders=host"; assert!(parse_auth_header(malformed3).is_err()); // Wrong algorithm let malformed4 = "AWS4-HMAC-SHA512 Credential=KEY/scope, SignedHeaders=host, Signature=abc"; assert!(parse_auth_header(malformed4).is_err()); // Empty string assert!(parse_auth_header("").is_err()); // Random garbage assert!(parse_auth_header("not-an-auth-header").is_err()); } #[test] fn test_security_signature_changes_with_secret_key() { let method = "GET"; let uri = "/test-bucket/object"; let amz_date = "20231201T000000Z"; let credential_scope = "20231201/us-east-1/s3/aws4_request"; let signed_headers = "host;x-amz-date"; let mut headers = HeaderMap::new(); headers.insert("host", HeaderValue::from_static("s3.amazonaws.com")); headers.insert("x-amz-date", HeaderValue::from_static("20231201T000000Z")); let body = Bytes::new(); // Compute signature with first secret key let sig1 = compute_sigv4_signature( "secret1", method, uri, &headers, amz_date, &body, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); // Compute signature with different secret key let sig2 = compute_sigv4_signature( "secret2", method, uri, &headers, amz_date, &body, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); // Signatures MUST be different assert_ne!( sig1, sig2, "Signatures should differ with different secret keys" ); } #[test] fn test_security_signature_changes_with_body() { let secret_key = "test-secret-key"; let method = "PUT"; let uri = "/test-bucket/object"; let amz_date = "20231201T000000Z"; let credential_scope = "20231201/us-east-1/s3/aws4_request"; let signed_headers = "host;x-amz-date"; let mut headers = HeaderMap::new(); headers.insert("host", HeaderValue::from_static("s3.amazonaws.com")); headers.insert("x-amz-date", HeaderValue::from_static("20231201T000000Z")); // Signature with body1 let body1 = Bytes::from("original content"); let sig1 = compute_sigv4_signature( secret_key, method, uri, &headers, amz_date, &body1, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); // Signature with modified body let body2 = Bytes::from("modified content"); let sig2 = compute_sigv4_signature( secret_key, method, uri, &headers, amz_date, &body2, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); // Signatures MUST be different assert_ne!(sig1, sig2, "Signatures should differ with different bodies"); } #[test] fn test_security_signature_changes_with_uri() { let secret_key = "test-secret-key"; let method = "GET"; let amz_date = "20231201T000000Z"; let credential_scope = "20231201/us-east-1/s3/aws4_request"; let signed_headers = "host;x-amz-date"; let mut headers = HeaderMap::new(); headers.insert("host", HeaderValue::from_static("s3.amazonaws.com")); headers.insert("x-amz-date", HeaderValue::from_static("20231201T000000Z")); let body = Bytes::new(); // Signature with uri1 let sig1 = compute_sigv4_signature( secret_key, method, "/test-bucket/object1", &headers, amz_date, &body, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); // Signature with different URI let sig2 = compute_sigv4_signature( secret_key, method, "/test-bucket/object2", &headers, amz_date, &body, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); // Signatures MUST be different assert_ne!(sig1, sig2, "Signatures should differ with different URIs"); } #[test] fn test_security_signature_changes_with_headers() { let secret_key = "test-secret-key"; let method = "GET"; let uri = "/test-bucket/object"; let amz_date = "20231201T000000Z"; let credential_scope = "20231201/us-east-1/s3/aws4_request"; let signed_headers = "host;x-amz-content-sha256;x-amz-date"; let mut headers1 = HeaderMap::new(); headers1.insert("host", HeaderValue::from_static("s3.amazonaws.com")); headers1.insert("x-amz-date", HeaderValue::from_static("20231201T000000Z")); headers1.insert("x-amz-content-sha256", HeaderValue::from_static("hash1")); let mut headers2 = HeaderMap::new(); headers2.insert("host", HeaderValue::from_static("s3.amazonaws.com")); headers2.insert("x-amz-date", HeaderValue::from_static("20231201T000000Z")); headers2.insert("x-amz-content-sha256", HeaderValue::from_static("hash2")); let body = Bytes::new(); let sig1 = compute_sigv4_signature( secret_key, method, uri, &headers1, amz_date, &body, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); let sig2 = compute_sigv4_signature( secret_key, method, uri, &headers2, amz_date, &body, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); // Signatures MUST be different assert_ne!( sig1, sig2, "Signatures should differ with different header values" ); } #[test] fn test_security_signature_changes_with_query_params() { let secret_key = "test-secret-key"; let method = "GET"; let amz_date = "20231201T000000Z"; let credential_scope = "20231201/us-east-1/s3/aws4_request"; let signed_headers = "host;x-amz-date"; let mut headers = HeaderMap::new(); headers.insert("host", HeaderValue::from_static("s3.amazonaws.com")); headers.insert("x-amz-date", HeaderValue::from_static("20231201T000000Z")); let body = Bytes::new(); // URI with query param let sig1 = compute_sigv4_signature( secret_key, method, "/test-bucket/object?prefix=foo", &headers, amz_date, &body, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); // URI with different query param let sig2 = compute_sigv4_signature( secret_key, method, "/test-bucket/object?prefix=bar", &headers, amz_date, &body, credential_scope, signed_headers, "us-east-1", "s3", ) .unwrap(); // Signatures MUST be different assert_ne!( sig1, sig2, "Signatures should differ with different query parameters" ); } #[test] fn test_security_credential_lookup_unknown_key() { let _guard = ENV_LOCK.lock().unwrap(); // Test that unknown access keys return the correct result std::env::remove_var("S3_CREDENTIALS"); std::env::set_var("S3_ACCESS_KEY_ID", "known_key"); std::env::set_var("S3_SECRET_KEY", "known_secret"); let client = IamClient::new(None); let credentials = client.env_credentials().unwrap(); // Known key should be found in credentials map assert_eq!( credentials.get("known_key"), Some(&"known_secret".to_string()) ); // Unknown key should not be found assert_eq!(credentials.get("unknown_key"), None); std::env::remove_var("S3_ACCESS_KEY_ID"); std::env::remove_var("S3_SECRET_KEY"); } #[test] fn test_security_empty_credentials() { let _guard = ENV_LOCK.lock().unwrap(); // Test that IamClient keeps credentials empty when none provided std::env::remove_var("S3_CREDENTIALS"); std::env::remove_var("S3_ACCESS_KEY_ID"); std::env::remove_var("S3_SECRET_KEY"); let client = IamClient::new(None); // No credentials configured assert!(client.env_credentials().unwrap().is_empty()); } #[test] fn test_security_malformed_s3_credentials_env() { let _guard = ENV_LOCK.lock().unwrap(); // Test that malformed S3_CREDENTIALS are handled gracefully // Missing colon separator std::env::set_var("S3_CREDENTIALS", "key1_secret1,key2:secret2"); let client = IamClient::new(None); let credentials = client.env_credentials().unwrap(); // Should only parse the valid pair (key2:secret2) assert_eq!(credentials.len(), 1); assert!(credentials.contains_key("key2")); // Empty pairs std::env::set_var("S3_CREDENTIALS", "key1:secret1,,key2:secret2"); let client2 = IamClient::new(None); // Should parse both valid pairs, skip empty assert_eq!(client2.env_credentials().unwrap().len(), 2); std::env::remove_var("S3_CREDENTIALS"); } }