// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use crate::rpc::context_propagation::{inject_request_id_into_http_headers, inject_trace_context_into_http_headers}; use base64::Engine as _; use base64::engine::general_purpose; use hmac::{Hmac, KeyInit, Mac}; use http::{HeaderMap, HeaderValue, Method, Uri}; #[cfg(test)] use rustfs_credentials::{DEFAULT_SECRET_KEY, RPC_SECRET_REQUIRED_MESSAGE}; use rustfs_credentials::{RPC_SECRET_REQUIRED_OPERATOR_MESSAGE, try_get_rpc_token}; use sha2::Sha256; use std::sync::Once; use time::OffsetDateTime; use tracing::error; type HmacSha256 = Hmac; const SIGNATURE_HEADER: &str = "x-rustfs-signature"; const TIMESTAMP_HEADER: &str = "x-rustfs-timestamp"; const SIGNATURE_VALID_DURATION: i64 = 300; // 5 minutes pub const TONIC_RPC_PREFIX: &str = "/node_service.NodeService"; static RPC_SECRET_RESOLUTION_LOG_ONCE: Once = Once::new(); /// Get the shared secret for HMAC signing #[cfg(test)] fn resolve_shared_secret(env_secret: Option<&str>, global_secret: Option<&str>) -> std::io::Result { if let Some(secret) = env_secret.map(str::trim).filter(|secret| !secret.is_empty()) { return (secret != DEFAULT_SECRET_KEY) .then(|| secret.to_string()) .ok_or_else(|| std::io::Error::other(RPC_SECRET_REQUIRED_MESSAGE)); } global_secret .map(str::trim) .filter(|secret| !secret.is_empty() && *secret != DEFAULT_SECRET_KEY) .map(ToOwned::to_owned) .ok_or_else(|| std::io::Error::other(RPC_SECRET_REQUIRED_MESSAGE)) } fn get_shared_secret() -> std::io::Result { try_get_rpc_token().map_err(|err| { RPC_SECRET_RESOLUTION_LOG_ONCE.call_once(|| { error!("RPC auth secret resolution failed: {}; {}", err, RPC_SECRET_REQUIRED_OPERATOR_MESSAGE); }); err }) } /// Build the canonical payload covered by the RPC HMAC. fn signature_payload(url: &str, method: &Method, timestamp: i64) -> String { let uri: Uri = url.parse().expect("Invalid URL"); let path_and_query = uri.path_and_query().unwrap(); let url = path_and_query.to_string(); format!("{url}|{method}|{timestamp}") } /// Generate HMAC-SHA256 signature for the given data fn generate_signature(secret: &str, url: &str, method: &Method, timestamp: i64) -> String { let data = signature_payload(url, method, timestamp); let mut mac = ::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); mac.update(data.as_bytes()); let result = mac.finalize(); general_purpose::STANDARD.encode(result.into_bytes()) } fn verify_signature(secret: &str, url: &str, method: &Method, timestamp: i64, signature: &str) -> bool { let Ok(signature) = general_purpose::STANDARD.decode(signature) else { return false; }; let data = signature_payload(url, method, timestamp); let mut mac = ::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); mac.update(data.as_bytes()); mac.verify_slice(&signature).is_ok() } /// Build headers with authentication signature pub fn build_auth_headers(url: &str, method: &Method, headers: &mut HeaderMap) -> std::io::Result<()> { let auth_headers = gen_signature_headers(url, method)?; headers.extend(auth_headers); inject_trace_context_into_http_headers(headers); inject_request_id_into_http_headers(headers); Ok(()) } pub fn gen_signature_headers(url: &str, method: &Method) -> std::io::Result { let secret = get_shared_secret()?; let timestamp = OffsetDateTime::now_utc().unix_timestamp(); let signature = generate_signature(&secret, url, method, timestamp); let mut headers = HeaderMap::new(); headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).expect("Invalid header value")); headers.insert( TIMESTAMP_HEADER, HeaderValue::from_str(×tamp.to_string()).expect("Invalid header value"), ); Ok(headers) } /// Verify the request signature for RPC requests pub fn verify_rpc_signature(url: &str, method: &Method, headers: &HeaderMap) -> std::io::Result<()> { // Get signature from header let signature = headers .get(SIGNATURE_HEADER) .and_then(|v| v.to_str().ok()) .ok_or_else(|| std::io::Error::other("Missing signature header"))?; // Get timestamp from header let timestamp_str = headers .get(TIMESTAMP_HEADER) .and_then(|v| v.to_str().ok()) .ok_or_else(|| std::io::Error::other("Missing timestamp header"))?; let timestamp: i64 = timestamp_str .parse() .map_err(|_| std::io::Error::other("Invalid timestamp format"))?; // Check timestamp validity (prevent replay attacks) let current_time = OffsetDateTime::now_utc().unix_timestamp(); if current_time.saturating_sub(timestamp) > SIGNATURE_VALID_DURATION || timestamp.saturating_sub(current_time) > SIGNATURE_VALID_DURATION { return Err(std::io::Error::other("Request timestamp expired")); } // Verify signature with constant-time HMAC comparison. let secret = get_shared_secret()?; if !verify_signature(&secret, url, method, timestamp, signature) { error!( "verify_rpc_signature: Invalid signature: url {}, method {}, timestamp {}, signature_len {}", url, method, timestamp, signature.len() ); return Err(std::io::Error::other("Invalid signature")); } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::rpc::context_propagation::REQUEST_ID_HEADER; use crate::runtime_sources; use http::{HeaderMap, Method}; use std::io::{self, Write}; use std::sync::{Arc, Mutex}; use time::OffsetDateTime; use tracing_subscriber::fmt::MakeWriter; #[derive(Clone, Default)] struct CapturedLogs { buffer: Arc>>, } struct CapturedLogWriter { buffer: Arc>>, } impl CapturedLogs { fn contents(&self) -> String { let buffer = self .buffer .lock() .expect("captured logs mutex should not be poisoned") .clone(); String::from_utf8(buffer).expect("captured logs should be valid UTF-8") } } impl Write for CapturedLogWriter { fn write(&mut self, buf: &[u8]) -> io::Result { self.buffer .lock() .expect("captured logs mutex should not be poisoned") .extend_from_slice(buf); Ok(buf.len()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } } impl<'a> MakeWriter<'a> for CapturedLogs { type Writer = CapturedLogWriter; fn make_writer(&'a self) -> Self::Writer { CapturedLogWriter { buffer: Arc::clone(&self.buffer), } } } fn ensure_test_rpc_secret() { runtime_sources::ensure_test_rpc_secret(); } #[test] fn test_resolve_shared_secret_rejects_default_fallback() { let err = resolve_shared_secret(None, None).expect_err("default fallback must be rejected"); assert_eq!(err.to_string(), RPC_SECRET_REQUIRED_MESSAGE); let err = resolve_shared_secret(None, Some(DEFAULT_SECRET_KEY)).expect_err("default global secret must be rejected"); assert_eq!(err.to_string(), RPC_SECRET_REQUIRED_MESSAGE); } #[test] fn test_get_shared_secret() { ensure_test_rpc_secret(); let secret = get_shared_secret().expect("test RPC secret should resolve"); assert!(!secret.is_empty(), "Secret should not be empty"); let url = "http://node1:7000/rustfs/rpc/read_file_stream?disk=http%3A%2F%2Fnode1%3A7000%2Fdata%2Frustfs3&volume=.rustfs.sys&path=pool.bin%2Fdd0fd773-a962-4265-b543-783ce83953e9%2Fpart.1&offset=0&length=44"; let method = Method::GET; let mut headers = HeaderMap::new(); build_auth_headers(url, &method, &mut headers).expect("auth headers should build"); let url = "/rustfs/rpc/read_file_stream?disk=http%3A%2F%2Fnode1%3A7000%2Fdata%2Frustfs3&volume=.rustfs.sys&path=pool.bin%2Fdd0fd773-a962-4265-b543-783ce83953e9%2Fpart.1&offset=0&length=44"; let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_ok(), "Valid signature should pass verification"); } #[test] fn test_generate_signature_deterministic() { let secret = "test-secret"; let url = "http://example.com/api/test"; let method = Method::GET; let timestamp = 1640995200; // Fixed timestamp let signature1 = generate_signature(secret, url, &method, timestamp); let signature2 = generate_signature(secret, url, &method, timestamp); assert_eq!(signature1, signature2, "Same inputs should produce same signature"); assert!(!signature1.is_empty(), "Signature should not be empty"); } #[test] fn test_generate_signature_different_inputs() { let secret = "test-secret"; let url = "http://example.com/api/test"; let method = Method::GET; let timestamp = 1640995200; let signature1 = generate_signature(secret, url, &method, timestamp); let signature2 = generate_signature(secret, "http://different.com/api/test2", &method, timestamp); let signature3 = generate_signature(secret, url, &Method::POST, timestamp); let signature4 = generate_signature(secret, url, &method, timestamp + 1); assert_ne!(signature1, signature2, "Different URLs should produce different signatures"); assert_ne!(signature1, signature3, "Different methods should produce different signatures"); assert_ne!(signature1, signature4, "Different timestamps should produce different signatures"); } #[test] fn test_build_auth_headers() { ensure_test_rpc_secret(); let url = "http://example.com/api/test"; let method = Method::POST; let mut headers = HeaderMap::new(); build_auth_headers(url, &method, &mut headers).expect("auth headers should build"); // Verify headers are present assert!(headers.contains_key(SIGNATURE_HEADER), "Should contain signature header"); assert!(headers.contains_key(TIMESTAMP_HEADER), "Should contain timestamp header"); // Verify header values are not empty let signature = headers.get(SIGNATURE_HEADER).unwrap().to_str().unwrap(); let timestamp_str = headers.get(TIMESTAMP_HEADER).unwrap().to_str().unwrap(); assert!(!signature.is_empty(), "Signature should not be empty"); assert!(!timestamp_str.is_empty(), "Timestamp should not be empty"); // Verify timestamp is a valid integer let timestamp: i64 = timestamp_str.parse().expect("Timestamp should be valid integer"); let current_time = OffsetDateTime::now_utc().unix_timestamp(); // Should be within a reasonable range (within 1 second of current time) assert!((current_time - timestamp).abs() <= 1, "Timestamp should be close to current time"); } #[test] fn test_build_auth_headers_preserves_existing_request_id() { ensure_test_rpc_secret(); let url = "http://example.com/api/test"; let method = Method::GET; let mut headers = HeaderMap::new(); headers.insert(REQUEST_ID_HEADER, HeaderValue::from_static("req-upstream-123")); build_auth_headers(url, &method, &mut headers).expect("auth headers should build"); assert_eq!(headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()), Some("req-upstream-123")); } #[test] fn test_build_auth_headers_may_set_request_id_from_trace_id() { ensure_test_rpc_secret(); let url = "http://example.com/api/test"; let method = Method::GET; let mut headers = HeaderMap::new(); let span = tracing::info_span!("rpc-test-span"); let _guard = span.enter(); build_auth_headers(url, &method, &mut headers).expect("auth headers should build"); if let Some(value) = headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()) { assert!(!value.is_empty(), "request id should not be empty"); } } #[test] fn test_verify_rpc_signature_success() { ensure_test_rpc_secret(); let url = "http://example.com/api/test"; let method = Method::GET; let mut headers = HeaderMap::new(); // Build headers with valid signature build_auth_headers(url, &method, &mut headers).expect("auth headers should build"); // Verify should succeed let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_ok(), "Valid signature should pass verification"); } #[test] fn test_verify_rpc_signature_invalid_signature() { ensure_test_rpc_secret(); let url = "http://example.com/api/test"; let method = Method::GET; let mut headers = HeaderMap::new(); // Build headers with valid signature first build_auth_headers(url, &method, &mut headers).expect("auth headers should build"); // Tamper with the signature headers.insert(SIGNATURE_HEADER, HeaderValue::from_str("invalid-signature").unwrap()); // Verify should fail let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_err(), "Invalid signature should fail verification"); let error = result.unwrap_err(); assert_eq!(error.to_string(), "Invalid signature"); } #[test] fn test_verify_signature_uses_hmac_verification() { let secret = "test-secret"; let url = "http://example.com/api/test"; let method = Method::GET; let timestamp = 1640995200; let signature = generate_signature(secret, url, &method, timestamp); let mut tampered = general_purpose::STANDARD.decode(&signature).unwrap(); tampered[0] ^= 1; let tampered_signature = general_purpose::STANDARD.encode(tampered); assert!(verify_signature(secret, url, &method, timestamp, &signature)); assert!(!verify_signature(secret, url, &method, timestamp, &tampered_signature)); assert!(!verify_signature(secret, url, &method, timestamp, "invalid-signature")); } #[test] fn test_invalid_signature_log_contract_excludes_secrets() { ensure_test_rpc_secret(); let url = "http://example.com/api/test"; let method = Method::GET; let timestamp = OffsetDateTime::now_utc().unix_timestamp(); let secret = get_shared_secret().expect("test RPC secret should resolve"); let expected_signature = generate_signature(&secret, url, &method, timestamp); let invalid_signature = "invalid-signature"; let logs = CapturedLogs::default(); let subscriber = tracing_subscriber::fmt() .with_max_level(tracing::Level::ERROR) .with_writer(logs.clone()) .with_ansi(false) .without_time() .finish(); let mut headers = HeaderMap::new(); headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(invalid_signature).unwrap()); headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(×tamp.to_string()).unwrap()); tracing::subscriber::with_default(subscriber, || { let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_err(), "Invalid signature should fail verification"); }); let captured = logs.contents(); assert!(captured.contains("Invalid signature")); assert!(!captured.contains(&secret)); assert!(!captured.contains(&expected_signature)); assert!(!captured.contains(invalid_signature)); } #[test] fn test_verify_rpc_signature_expired_timestamp() { ensure_test_rpc_secret(); let url = "http://example.com/api/test"; let method = Method::GET; let mut headers = HeaderMap::new(); // Set expired timestamp (older than SIGNATURE_VALID_DURATION) let expired_timestamp = OffsetDateTime::now_utc().unix_timestamp() - SIGNATURE_VALID_DURATION - 10; let secret = get_shared_secret().expect("test RPC secret should resolve"); let signature = generate_signature(&secret, url, &method, expired_timestamp); headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).unwrap()); headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(&expired_timestamp.to_string()).unwrap()); // Verify should fail due to expired timestamp let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_err(), "Expired timestamp should fail verification"); let error = result.unwrap_err(); assert_eq!(error.to_string(), "Request timestamp expired"); } #[test] fn test_verify_rpc_signature_future_timestamp_outside_window() { ensure_test_rpc_secret(); let url = "http://example.com/api/test"; let method = Method::GET; let mut headers = HeaderMap::new(); let future_timestamp = OffsetDateTime::now_utc().unix_timestamp() + SIGNATURE_VALID_DURATION + 10; let secret = get_shared_secret().expect("test RPC secret should resolve"); let signature = generate_signature(&secret, url, &method, future_timestamp); headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).unwrap()); headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(&future_timestamp.to_string()).unwrap()); let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_err(), "Future timestamp outside valid window should fail verification"); let error = result.unwrap_err(); assert_eq!(error.to_string(), "Request timestamp expired"); } #[test] fn test_verify_rpc_signature_missing_signature_header() { let url = "http://example.com/api/test"; let method = Method::GET; let mut headers = HeaderMap::new(); // Add only timestamp header, missing signature let timestamp = OffsetDateTime::now_utc().unix_timestamp(); headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(×tamp.to_string()).unwrap()); // Verify should fail let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_err(), "Missing signature header should fail verification"); let error = result.unwrap_err(); assert_eq!(error.to_string(), "Missing signature header"); } #[test] fn test_verify_rpc_signature_missing_timestamp_header() { let url = "http://example.com/api/test"; let method = Method::GET; let mut headers = HeaderMap::new(); // Add only signature header, missing timestamp headers.insert(SIGNATURE_HEADER, HeaderValue::from_str("some-signature").unwrap()); // Verify should fail let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_err(), "Missing timestamp header should fail verification"); let error = result.unwrap_err(); assert_eq!(error.to_string(), "Missing timestamp header"); } #[test] fn test_verify_rpc_signature_invalid_timestamp_format() { let url = "http://example.com/api/test"; let method = Method::GET; let mut headers = HeaderMap::new(); headers.insert(SIGNATURE_HEADER, HeaderValue::from_str("some-signature").unwrap()); headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str("invalid-timestamp").unwrap()); // Verify should fail let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_err(), "Invalid timestamp format should fail verification"); let error = result.unwrap_err(); assert_eq!(error.to_string(), "Invalid timestamp format"); } #[test] fn test_verify_rpc_signature_url_mismatch() { ensure_test_rpc_secret(); let original_url = "http://example.com/api/test"; let different_url = "http://example.com/api/different"; let method = Method::GET; let mut headers = HeaderMap::new(); // Build headers for one URL build_auth_headers(original_url, &method, &mut headers).expect("auth headers should build"); // Try to verify with a different URL let result = verify_rpc_signature(different_url, &method, &headers); assert!(result.is_err(), "URL mismatch should fail verification"); let error = result.unwrap_err(); assert_eq!(error.to_string(), "Invalid signature"); } #[test] fn test_verify_rpc_signature_method_mismatch() { ensure_test_rpc_secret(); let url = "http://example.com/api/test"; let original_method = Method::GET; let different_method = Method::POST; let mut headers = HeaderMap::new(); // Build headers for one method build_auth_headers(url, &original_method, &mut headers).expect("auth headers should build"); // Try to verify with a different method let result = verify_rpc_signature(url, &different_method, &headers); assert!(result.is_err(), "Method mismatch should fail verification"); let error = result.unwrap_err(); assert_eq!(error.to_string(), "Invalid signature"); } #[test] fn test_signature_valid_duration_boundary() { ensure_test_rpc_secret(); let url = "http://example.com/api/test"; let method = Method::GET; let secret = get_shared_secret().expect("test RPC secret should resolve"); let mut headers = HeaderMap::new(); let current_time = OffsetDateTime::now_utc().unix_timestamp(); // Test timestamp just within valid duration let valid_timestamp = current_time - SIGNATURE_VALID_DURATION + 1; let signature = generate_signature(&secret, url, &method, valid_timestamp); headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).unwrap()); headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(&valid_timestamp.to_string()).unwrap()); let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_ok(), "Timestamp within valid duration should pass"); // Test timestamp just outside valid duration let mut headers = HeaderMap::new(); let invalid_timestamp = current_time - SIGNATURE_VALID_DURATION - 15; let signature = generate_signature(&secret, url, &method, invalid_timestamp); headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).unwrap()); headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(&invalid_timestamp.to_string()).unwrap()); let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_err(), "Timestamp outside valid duration should fail"); } #[test] fn test_round_trip_authentication() { ensure_test_rpc_secret(); let test_cases = vec![ ("http://example.com/api/test", Method::GET), ("https://api.rustfs.com/v1/bucket", Method::POST), ("http://localhost:9000/admin/info", Method::PUT), ("https://storage.example.com/path/to/object?query=param", Method::DELETE), ]; for (url, method) in test_cases { let mut headers = HeaderMap::new(); // Build authentication headers build_auth_headers(url, &method, &mut headers).expect("auth headers should build"); // Verify the signature should succeed let result = verify_rpc_signature(url, &method, &headers); assert!(result.is_ok(), "Round-trip test failed for {method} {url}"); } } }