Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 100 additions & 45 deletions crates/core/src/auth/identity.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
//! Identity resolution from inbound requests.
//!
//! Parses the SigV4 Authorization header, looks up the credential, verifies
//! the signature, and returns the resolved identity.
//! Parses SigV4 material from either the `Authorization` header or a presigned
//! URL's query string, looks up the credential, verifies the signature, and
//! returns the resolved identity. Everything after parsing is shared between
//! the two request shapes.

use super::sigv4::{constant_time_eq, parse_sigv4_auth, verify_sigv4_signature};
use super::sigv4::{
constant_time_eq, is_presigned, parse_sigv4_auth, parse_sigv4_presigned,
verify_sigv4_presigned, verify_sigv4_signature, SigV4Auth,
};
use super::TemporaryCredentialResolver;
use crate::error::ProxyError;
use crate::registry::CredentialRegistry;
use crate::types::{AuthenticatedIdentity, ResolvedIdentity};
use http::HeaderMap;

/// How the request carried its SigV4 material, plus the mode-specific inputs to
/// signature verification.
enum SigMode {
/// `Authorization` header. Payload hash comes from `x-amz-content-sha256`.
Header { payload_hash: String },
/// Presigned query string. Date comes from the `X-Amz-Date` query param;
/// payload hash is always `UNSIGNED-PAYLOAD`.
Presigned { amz_date: String },
}

/// Resolve the identity of an incoming request.
///
/// Parses the SigV4 Authorization header, looks up the credential, verifies
/// the signature, and returns the resolved identity.
/// Authenticates SigV4 requests signed via the `Authorization` header or via a
/// presigned URL (query-string auth). Unsigned requests resolve to
/// [`ResolvedIdentity::Anonymous`].
pub async fn resolve_identity<C: CredentialRegistry>(
method: &http::Method,
uri_path: &str,
Expand All @@ -22,27 +38,64 @@ pub async fn resolve_identity<C: CredentialRegistry>(
config: &C,
credential_resolver: Option<&dyn TemporaryCredentialResolver>,
) -> Result<ResolvedIdentity, ProxyError> {
let auth_header = match headers.get("authorization").and_then(|v| v.to_str().ok()) {
Some(h) => h,
None => return Ok(ResolvedIdentity::Anonymous),
};
// Parse SigV4 material from the header or, failing that, a presigned query.
// The tail below (resolve → verify → Authenticated) is mode-agnostic.
let (sig, mode, session_token): (SigV4Auth, SigMode, Option<String>) =
if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
let sig = parse_sigv4_auth(auth_header)?;
// For streaming uploads the payload hash is the UNSIGNED-PAYLOAD or
// STREAMING-AWS4-HMAC-SHA256-PAYLOAD sentinel — both are valid
// canonical-request inputs per the SigV4 spec.
let payload_hash = headers
.get("x-amz-content-sha256")
.and_then(|v| v.to_str().ok())
.unwrap_or("UNSIGNED-PAYLOAD")
.to_string();
let token = headers
.get("x-amz-security-token")
.and_then(|v| v.to_str().ok())
.map(String::from);
(sig, SigMode::Header { payload_hash }, token)
} else if is_presigned(query_string) {
let presigned = parse_sigv4_presigned(query_string)?;
check_presigned_expiry(&presigned.amz_date, presigned.expires)?;
(
presigned.auth,
SigMode::Presigned {
amz_date: presigned.amz_date,
},
presigned.session_token,
)
} else {
return Ok(ResolvedIdentity::Anonymous);
};

let sig = parse_sigv4_auth(auth_header)?;

// The payload hash is sent by the client in x-amz-content-sha256.
// For streaming uploads this is the UNSIGNED-PAYLOAD or
// STREAMING-AWS4-HMAC-SHA256-PAYLOAD sentinel — both are valid
// canonical-request inputs per the SigV4 spec.
let payload_hash = headers
.get("x-amz-content-sha256")
.and_then(|v| v.to_str().ok())
.unwrap_or("UNSIGNED-PAYLOAD");
// Verify against a candidate secret, dispatching on how the request was signed.
let verify = |secret: &str| -> Result<bool, ProxyError> {
match &mode {
SigMode::Header { payload_hash } => verify_sigv4_signature(
method,
uri_path,
query_string,
headers,
&sig,
secret,
payload_hash,
),
SigMode::Presigned { amz_date } => verify_sigv4_presigned(
method,
uri_path,
query_string,
headers,
&sig,
secret,
amz_date,
),
}
};

// Temporary credentials: resolve the session token to recover credentials
if let Some(session_token) = headers
.get("x-amz-security-token")
.and_then(|v| v.to_str().ok())
{
// Temporary credentials: resolve the session token to recover credentials.
if let Some(session_token) = session_token.as_deref() {
let resolver = credential_resolver.ok_or_else(|| {
tracing::warn!("session token present but no credential resolver configured");
ProxyError::AccessDenied
Expand All @@ -52,21 +105,13 @@ pub async fn resolve_identity<C: CredentialRegistry>(
Some(creds) => {
if !constant_time_eq(sig.access_key_id.as_bytes(), creds.access_key_id.as_bytes()) {
tracing::warn!(
header_key = %sig.access_key_id,
request_key = %sig.access_key_id,
resolved_key = %creds.access_key_id,
"access key mismatch between auth header and session token"
"access key mismatch between request and session token"
);
return Err(ProxyError::AccessDenied);
}
if !verify_sigv4_signature(
method,
uri_path,
query_string,
headers,
&sig,
&creds.secret_access_key,
payload_hash,
)? {
if !verify(&creds.secret_access_key)? {
return Err(ProxyError::SignatureDoesNotMatch);
}
tracing::debug!(
Expand Down Expand Up @@ -102,16 +147,7 @@ pub async fn resolve_identity<C: CredentialRegistry>(
}
}

// Verify SigV4 signature
if !verify_sigv4_signature(
method,
uri_path,
query_string,
headers,
&sig,
&cred.secret_access_key,
payload_hash,
)? {
if !verify(&cred.secret_access_key)? {
return Err(ProxyError::SignatureDoesNotMatch);
}

Expand All @@ -123,3 +159,22 @@ pub async fn resolve_identity<C: CredentialRegistry>(

Err(ProxyError::AccessDenied)
}

/// Reject a presigned request whose signing window has elapsed.
///
/// The window is `[X-Amz-Date, X-Amz-Date + X-Amz-Expires]`, with a few seconds
/// of clock-skew tolerance on the trailing edge.
fn check_presigned_expiry(amz_date: &str, expires_secs: u64) -> Result<(), ProxyError> {
const SKEW: i64 = 5;

let signed = chrono::NaiveDateTime::parse_from_str(amz_date, "%Y%m%dT%H%M%SZ")
.map_err(|_| ProxyError::InvalidRequest("invalid X-Amz-Date".into()))?
.and_utc();
let expiry = signed + chrono::Duration::seconds(expires_secs as i64 + SKEW);

if chrono::Utc::now() > expiry {
tracing::warn!(%amz_date, expires_secs, "presigned URL expired");
return Err(ProxyError::ExpiredCredentials);
}
Ok(())
}
5 changes: 4 additions & 1 deletion crates/core/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ pub mod sigv4;

pub use authorize::{authorize, key_authorized};
pub use identity::resolve_identity;
pub use sigv4::{parse_sigv4_auth, verify_sigv4_signature, SigV4Auth};
pub use sigv4::{
is_presigned, parse_sigv4_auth, parse_sigv4_presigned, verify_sigv4_presigned,
verify_sigv4_signature, PresignedSig, SigV4Auth,
};

use crate::error::ProxyError;
use crate::maybe_send::{MaybeSend, MaybeSync};
Expand Down
Loading
Loading