This is an automated email from the ASF dual-hosted git repository. xuanwo pushed a commit to branch xuanwo/azure-service-sas in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
commit 5cd9c339003c837776921c39170cbb8db65d3db7 Author: Xuanwo <[email protected]> AuthorDate: Fri Dec 26 19:00:36 2025 +0800 feat(azure): Add scoped SAS support --- services/azure-storage/src/account_sas.rs | 2 + services/azure-storage/src/lib.rs | 4 + services/azure-storage/src/service_sas.rs | 254 ++++++++++++++++ services/azure-storage/src/sign_request.rs | 414 ++++++++++++++++++++++++-- services/azure-storage/src/user_delegation.rs | 239 +++++++++++++++ 5 files changed, 891 insertions(+), 22 deletions(-) diff --git a/services/azure-storage/src/account_sas.rs b/services/azure-storage/src/account_sas.rs index 916c336..7918e62 100644 --- a/services/azure-storage/src/account_sas.rs +++ b/services/azure-storage/src/account_sas.rs @@ -15,6 +15,8 @@ // specific language governing permissions and limitations // under the License. +#![allow(dead_code)] + use reqsign_core::Result; use reqsign_core::hash; diff --git a/services/azure-storage/src/lib.rs b/services/azure-storage/src/lib.rs index 9d757a3..6ac7f3c 100644 --- a/services/azure-storage/src/lib.rs +++ b/services/azure-storage/src/lib.rs @@ -158,10 +158,14 @@ mod account_sas; mod constants; +mod service_sas; +mod user_delegation; mod credential; pub use credential::Credential; +pub use service_sas::{ServiceSasResource, ServiceSharedAccessSignature}; + mod sign_request; pub use sign_request::RequestSigner; diff --git a/services/azure-storage/src/service_sas.rs b/services/azure-storage/src/service_sas.rs new file mode 100644 index 0000000..8a082d4 --- /dev/null +++ b/services/azure-storage/src/service_sas.rs @@ -0,0 +1,254 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 reqsign_core::Result; +use reqsign_core::hash; +use reqsign_core::time::Timestamp; + +const SERVICE_SAS_VERSION: &str = "2020-12-06"; +const BLOB_SERVICE: &str = "blob"; + +/// Resource level for Azure Storage Service SAS. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ServiceSasResource { + /// A container resource. + Container { container: String }, + /// A blob resource. + Blob { container: String, blob: String }, +} + +impl ServiceSasResource { + /// Build a resource from a request path. + /// + /// The input path must be percent-decoded. + pub fn from_path_percent_decoded(path: &str) -> Result<Self> { + let path = path.strip_prefix('/').unwrap_or(path); + let mut segments = path.split('/').filter(|v| !v.is_empty()); + + let container = segments + .next() + .ok_or_else(|| reqsign_core::Error::request_invalid("missing container in path"))? + .to_string(); + + let rest = segments.collect::<Vec<_>>(); + if rest.is_empty() { + Ok(ServiceSasResource::Container { container }) + } else { + Ok(ServiceSasResource::Blob { + container, + blob: rest.join("/"), + }) + } + } + + pub(crate) fn signed_resource(&self) -> &'static str { + match self { + ServiceSasResource::Container { .. } => "c", + ServiceSasResource::Blob { .. } => "b", + } + } + + pub(crate) fn canonicalized_resource(&self, account: &str) -> String { + match self { + ServiceSasResource::Container { container } => { + format!("/{BLOB_SERVICE}/{account}/{container}") + } + ServiceSasResource::Blob { container, blob } => { + format!("/{BLOB_SERVICE}/{account}/{container}/{blob}") + } + } + } +} + +/// Service SAS generator using Shared Key. +/// +/// Reference: <https://learn.microsoft.com/en-us/rest/api/storageservices/create-service-sas> +pub struct ServiceSharedAccessSignature { + account: String, + key: String, + + resource: ServiceSasResource, + permissions: String, + expiry: Timestamp, + start: Option<Timestamp>, + ip: Option<String>, + protocol: Option<String>, + version: String, +} + +impl ServiceSharedAccessSignature { + /// Create a Service SAS signer. + pub fn new( + account: String, + key: String, + resource: ServiceSasResource, + permissions: String, + expiry: Timestamp, + ) -> Self { + Self { + account, + key, + resource, + permissions, + expiry, + start: None, + ip: None, + protocol: None, + version: SERVICE_SAS_VERSION.to_string(), + } + } + + /// Set the start time. + pub fn with_start(mut self, start: Timestamp) -> Self { + self.start = Some(start); + self + } + + /// Set the IP restriction. + pub fn with_ip(mut self, ip: impl Into<String>) -> Self { + self.ip = Some(ip.into()); + self + } + + /// Set the allowed protocol. + pub fn with_protocol(mut self, protocol: impl Into<String>) -> Self { + self.protocol = Some(protocol.into()); + self + } + + /// Set the service version. + pub fn with_version(mut self, version: impl Into<String>) -> Self { + self.version = version.into(); + self + } + + fn signature(&self) -> Result<String> { + let canonicalized_resource = self.resource.canonicalized_resource(&self.account); + + // Signed identifier (si), snapshot time, encryption scope, response headers are not + // supported for now. Keep them empty. + let string_to_sign = format!( + "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + self.permissions, + self.start + .as_ref() + .map_or("".to_string(), |v| v.format_rfc3339_zulu()), + self.expiry.format_rfc3339_zulu(), + canonicalized_resource, + "", // si + self.ip.clone().unwrap_or_default(), // sip + self.protocol.clone().unwrap_or_default(), // spr + &self.version, // sv + self.resource.signed_resource(), // sr + "", // snapshot time + "", // encryption scope + "", // rscc + "", // rscd + "", // rsce + "", // rscl + "", // rsct + ); + + let decoded_key = hash::base64_decode(&self.key)?; + Ok(hash::base64_hmac_sha256(&decoded_key, string_to_sign.as_bytes())) + } + + /// Generate SAS query parameters. + pub fn token(&self) -> Result<Vec<(String, String)>> { + let mut elements: Vec<(String, String)> = vec![ + ("sv".to_string(), self.version.to_string()), + ("se".to_string(), self.expiry.format_rfc3339_zulu()), + ("sp".to_string(), self.permissions.to_string()), + ("sr".to_string(), self.resource.signed_resource().to_string()), + ]; + + if let Some(start) = &self.start { + elements.push(("st".to_string(), start.format_rfc3339_zulu())) + } + if let Some(ip) = &self.ip { + elements.push(("sip".to_string(), ip.to_string())) + } + if let Some(protocol) = &self.protocol { + elements.push(("spr".to_string(), protocol.to_string())) + } + + let sig = self.signature()?; + elements.push(("sig".to_string(), sig)); + + Ok(elements) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + use std::time::Duration; + + fn test_time() -> Timestamp { + Timestamp::from_str("2022-03-01T08:12:34Z").unwrap() + } + + #[test] + fn test_can_generate_service_sas_token_for_blob() { + let key = hash::base64_encode("key".as_bytes()); + let expiry = test_time() + Duration::from_secs(300); + + let resource = ServiceSasResource::Blob { + container: "container".to_string(), + blob: "path/to/blob.txt".to_string(), + }; + + let sign = ServiceSharedAccessSignature::new( + "account".to_string(), + key, + resource, + "r".to_string(), + expiry, + ); + + let token_content = sign.token().expect("token generation failed"); + let token = token_content + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::<Vec<String>>() + .join("&"); + + assert_eq!( + token, + "sv=2020-12-06&se=2022-03-01T08:17:34Z&sp=r&sr=b&sig=CP9a2LIrR9zeG4I4jZjqPetJSXWJ77QeUA7c3GMypyM=" + ); + } + + #[test] + fn test_service_sas_resource_from_path() { + assert_eq!( + ServiceSasResource::from_path_percent_decoded("/container").unwrap(), + ServiceSasResource::Container { + container: "container".to_string() + } + ); + + assert_eq!( + ServiceSasResource::from_path_percent_decoded("/container/blob").unwrap(), + ServiceSasResource::Blob { + container: "container".to_string(), + blob: "blob".to_string() + } + ); + } +} diff --git a/services/azure-storage/src/sign_request.rs b/services/azure-storage/src/sign_request.rs index 47384a4..3fed5a9 100644 --- a/services/azure-storage/src/sign_request.rs +++ b/services/azure-storage/src/sign_request.rs @@ -26,20 +26,141 @@ use reqsign_core::hash::{base64_decode, base64_hmac_sha256}; use reqsign_core::time::Timestamp; use reqsign_core::{Context, Result, SignRequest, SigningMethod, SigningRequest}; use std::fmt::Write; +use std::sync::Mutex; use std::time::Duration; +/// Resource kind required by SAS generation. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum SasResourceKind { + /// Container SAS. + Container, + /// Blob SAS. + Blob, +} + /// RequestSigner that implement Azure Storage Shared Key Authorization. /// /// - [Authorize with Shared Key](https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key) #[derive(Debug)] pub struct RequestSigner { time: Option<Timestamp>, + service_sas_permissions: Option<String>, + service_sas_start: Option<Timestamp>, + service_sas_ip: Option<String>, + service_sas_protocol: Option<String>, + service_sas_version: Option<String>, + + user_delegation_presign: Option<UserDelegationPresignConfig>, + user_delegation_key_cache: Mutex<Option<crate::user_delegation::UserDelegationKey>>, +} + +#[derive(Clone, Debug)] +struct UserDelegationPresignConfig { + resource: SasResourceKind, + permissions: String, + start: Option<Timestamp>, + ip: Option<String>, + protocol: Option<String>, + version: Option<String>, } impl RequestSigner { /// Create a new builder for Azure Storage signer. pub fn new() -> Self { - Self { time: None } + Self { + time: None, + service_sas_permissions: None, + service_sas_start: None, + service_sas_ip: None, + service_sas_protocol: None, + service_sas_version: None, + + user_delegation_presign: None, + user_delegation_key_cache: Mutex::new(None), + } + } + + /// Configure Service SAS presign permissions for Shared Key query signing. + /// + /// This setting is required when `expires_in` is provided and credential is `SharedKey`. + pub fn with_service_sas_permissions(mut self, permissions: &str) -> Self { + self.service_sas_permissions = Some(permissions.to_string()); + self + } + + /// Configure Service SAS presign start time for Shared Key query signing. + pub fn with_service_sas_start(mut self, start: Timestamp) -> Self { + self.service_sas_start = Some(start); + self + } + + /// Configure Service SAS presign allowed IP range. + pub fn with_service_sas_ip(mut self, ip: &str) -> Self { + self.service_sas_ip = Some(ip.to_string()); + self + } + + /// Configure Service SAS presign allowed protocol, e.g. `https` or `https,http`. + pub fn with_service_sas_protocol(mut self, protocol: &str) -> Self { + self.service_sas_protocol = Some(protocol.to_string()); + self + } + + /// Configure Service SAS presign service version. + pub fn with_service_sas_version(mut self, version: &str) -> Self { + self.service_sas_version = Some(version.to_string()); + self + } + + /// Enable User Delegation SAS presign for Bearer Token query signing. + /// + /// This is only used when `expires_in` is provided and credential is `BearerToken`. + pub fn with_user_delegation_presign( + mut self, + resource: SasResourceKind, + permissions: &str, + ) -> Self { + self.user_delegation_presign = Some(UserDelegationPresignConfig { + resource, + permissions: permissions.to_string(), + start: None, + ip: None, + protocol: None, + version: None, + }); + self + } + + /// Configure User Delegation SAS presign start time. + pub fn with_user_delegation_start(mut self, start: Timestamp) -> Self { + if let Some(cfg) = self.user_delegation_presign.as_mut() { + cfg.start = Some(start); + } + self + } + + /// Configure User Delegation SAS presign allowed IP range. + pub fn with_user_delegation_ip(mut self, ip: &str) -> Self { + if let Some(cfg) = self.user_delegation_presign.as_mut() { + cfg.ip = Some(ip.to_string()); + } + self + } + + /// Configure User Delegation SAS presign allowed protocol, e.g. `https` or `https,http`. + pub fn with_user_delegation_protocol(mut self, protocol: &str) -> Self { + if let Some(cfg) = self.user_delegation_presign.as_mut() { + cfg.protocol = Some(protocol.to_string()); + } + self + } + + /// Configure User Delegation SAS presign service version. + pub fn with_user_delegation_version(mut self, version: &str) -> Self { + if let Some(cfg) = self.user_delegation_presign.as_mut() { + cfg.version = Some(version.to_string()); + } + self } /// Specify the signing time. @@ -67,7 +188,7 @@ impl SignRequest for RequestSigner { async fn sign_request( &self, - _: &Context, + context: &Context, req: &mut Parts, credential: Option<&Self::Credential>, expires_in: Option<Duration>, @@ -82,31 +203,131 @@ impl SignRequest for RequestSigner { SigningMethod::Header }; - let mut ctx = SigningRequest::build(req)?; + let mut sctx = SigningRequest::build(req)?; // Handle different credential types match cred { Credential::SasToken { token } => { // SAS token authentication - ctx.query_append(token); + sctx.query_append(token); } Credential::BearerToken { token, .. } => { // Bearer token authentication match method { - SigningMethod::Query(_) => { - return Err(reqsign_core::Error::request_invalid( - "BearerToken can't be used in query string", - )); + SigningMethod::Query(d) => { + let Some(cfg) = &self.user_delegation_presign else { + return Err(reqsign_core::Error::request_invalid( + "BearerToken can't be used in query string", + )); + }; + + let now_time = self.time.unwrap_or_else(Timestamp::now); + let expiry = now_time + d; + + let resource = + crate::service_sas::ServiceSasResource::from_path_percent_decoded( + sctx.path_percent_decoded().as_ref(), + )?; + match (cfg.resource, &resource) { + (SasResourceKind::Container, crate::service_sas::ServiceSasResource::Container { .. }) => {} + (SasResourceKind::Blob, crate::service_sas::ServiceSasResource::Blob { .. }) => {} + _ => { + return Err(reqsign_core::Error::request_invalid( + "request resource doesn't match configured SAS resource kind", + )); + } + } + + let account = infer_account_name(sctx.authority.as_str())?; + + let key = { + let cached = self + .user_delegation_key_cache + .lock() + .expect("lock poisoned") + .clone(); + if let Some(cached) = cached { + if cached.signed_expiry > expiry + Duration::from_secs(20) { + cached + } else { + let version = cfg.version.as_deref().unwrap_or("2020-12-06"); + let fetched = crate::user_delegation::get_user_delegation_key( + context, + sctx.scheme.as_str(), + sctx.authority.as_str(), + token, + now_time, + expiry, + version, + now_time, + ) + .await?; + *self + .user_delegation_key_cache + .lock() + .expect("lock poisoned") = Some(fetched.clone()); + fetched + } + } else { + let version = cfg.version.as_deref().unwrap_or("2020-12-06"); + let fetched = crate::user_delegation::get_user_delegation_key( + context, + sctx.scheme.as_str(), + sctx.authority.as_str(), + token, + now_time, + expiry, + version, + now_time, + ) + .await?; + *self + .user_delegation_key_cache + .lock() + .expect("lock poisoned") = Some(fetched.clone()); + fetched + } + }; + + let mut signer = crate::user_delegation::UserDelegationSharedAccessSignature::new( + account, + key, + resource, + cfg.permissions.to_string(), + expiry, + ); + if let Some(start) = cfg.start { + signer = signer.with_start(start); + } + if let Some(ip) = &cfg.ip { + signer = signer.with_ip(ip); + } + if let Some(protocol) = &cfg.protocol { + signer = signer.with_protocol(protocol); + } + if let Some(version) = &cfg.version { + signer = signer.with_version(version); + } + + let signer_token = signer.token().map_err(|e| { + reqsign_core::Error::unexpected( + "failed to generate user delegation SAS token", + ) + .with_source(e) + })?; + signer_token + .into_iter() + .for_each(|(k, v)| sctx.query_push(k, v)); } SigningMethod::Header => { - ctx.headers.insert( + sctx.headers.insert( X_MS_DATE, Timestamp::now().format_http_date().parse().map_err(|e| { reqsign_core::Error::unexpected("failed to parse date header") .with_source(e) })?, ); - ctx.headers.insert(header::AUTHORIZATION, { + sctx.headers.insert(header::AUTHORIZATION, { let mut value: HeaderValue = format!("Bearer {token}").parse().map_err(|e| { reqsign_core::Error::unexpected( @@ -127,23 +348,49 @@ impl SignRequest for RequestSigner { // Shared key authentication match method { SigningMethod::Query(d) => { - // try sign request use account_sas token - let signer = crate::account_sas::AccountSharedAccessSignature::new( + let now_time = self.time.unwrap_or_else(Timestamp::now); + let Some(permissions) = &self.service_sas_permissions else { + return Err(reqsign_core::Error::request_invalid( + "Service SAS permissions are required for presign", + )); + }; + + let resource = crate::service_sas::ServiceSasResource::from_path_percent_decoded( + sctx.path_percent_decoded().as_ref(), + )?; + + let mut signer = crate::service_sas::ServiceSharedAccessSignature::new( account_name.clone(), account_key.clone(), - Timestamp::now() + d, + resource, + permissions.to_string(), + now_time + d, ); + if let Some(start) = self.service_sas_start { + signer = signer.with_start(start); + } + if let Some(ip) = &self.service_sas_ip { + signer = signer.with_ip(ip); + } + if let Some(protocol) = &self.service_sas_protocol { + signer = signer.with_protocol(protocol); + } + if let Some(version) = &self.service_sas_version { + signer = signer.with_version(version); + } + let signer_token = signer.token().map_err(|e| { - reqsign_core::Error::unexpected("failed to generate account SAS token") + reqsign_core::Error::unexpected("failed to generate service SAS token") .with_source(e) })?; - signer_token.iter().for_each(|(k, v)| { - ctx.query_push(k, v); - }); + signer_token + .into_iter() + .for_each(|(k, v)| sctx.query_push(k, v)); } SigningMethod::Header => { let now_time = self.time.unwrap_or_else(Timestamp::now); - let string_to_sign = string_to_sign(&mut ctx, account_name, now_time)?; + let string_to_sign = + string_to_sign(&mut sctx, account_name, now_time)?; let decode_content = base64_decode(account_key).map_err(|e| { reqsign_core::Error::unexpected("failed to decode account key") .with_source(e) @@ -151,7 +398,7 @@ impl SignRequest for RequestSigner { let signature = base64_hmac_sha256(&decode_content, string_to_sign.as_bytes()); - ctx.headers.insert(header::AUTHORIZATION, { + sctx.headers.insert(header::AUTHORIZATION, { let mut value: HeaderValue = format!("SharedKey {account_name}:{signature}") .parse() @@ -170,14 +417,25 @@ impl SignRequest for RequestSigner { } // Apply percent encoding for query parameters - for (_, v) in ctx.query.iter_mut() { + for (_, v) in sctx.query.iter_mut() { *v = percent_encode(v.as_bytes(), &AZURE_QUERY_ENCODE_SET).to_string(); } - ctx.apply(req) + sctx.apply(req) } } +fn infer_account_name(authority: &str) -> Result<String> { + let host = authority.split('@').last().unwrap_or(authority); + let host = host.split(':').next().unwrap_or(host); + let Some((account, _)) = host.split_once('.') else { + return Err(reqsign_core::Error::request_invalid( + "failed to infer account name from authority", + )); + }; + Ok(account.to_string()) +} + /// Construct string to sign /// /// ## Format @@ -408,11 +666,14 @@ fn canonicalize_resource(ctx: &mut SigningRequest, account_name: &str) -> String #[cfg(test)] mod tests { use super::*; + use async_trait::async_trait; + use bytes::Bytes; use http::Request; - use reqsign_core::{Context, OsEnv}; + use reqsign_core::{Context, HttpSend, OsEnv}; use reqsign_file_read_tokio::TokioFileRead; use reqsign_http_send_reqwest::ReqwestHttpSend; use std::time::Duration; + use std::str::FromStr; #[tokio::test] async fn test_sas_token() { @@ -494,4 +755,113 @@ mod tests { .is_err() ); } + + #[tokio::test] + async fn test_shared_key_presign_service_sas() { + let ctx = Context::new() + .with_file_read(TokioFileRead) + .with_http_send(ReqwestHttpSend::default()) + .with_env(OsEnv); + + let now = Timestamp::from_str("2022-03-01T08:12:34Z").unwrap(); + let key = reqsign_core::hash::base64_encode("key".as_bytes()); + let cred = Credential::with_shared_key("account", &key); + + let builder = RequestSigner::new() + .with_time(now) + .with_service_sas_permissions("r"); + + let req = Request::builder() + .uri("https://account.blob.core.windows.net/container/path/to/blob.txt") + .body(()) + .unwrap(); + let (mut parts, _) = req.into_parts(); + + builder + .sign_request(&ctx, &mut parts, Some(&cred), Some(Duration::from_secs(300))) + .await + .unwrap(); + + assert_eq!( + parts.uri.to_string(), + "https://account.blob.core.windows.net/container/path/to/blob.txt?sv=2020-12-06&se=2022-03-01T08%3A17%3A34Z&sp=r&sr=b&sig=CP9a2LIrR9zeG4I4jZjqPetJSXWJ77QeUA7c3GMypyM%3D" + ); + } + + #[derive(Debug)] + struct MockUserDelegationHttpSend; + + #[async_trait] + impl HttpSend for MockUserDelegationHttpSend { + async fn http_send( + &self, + req: http::Request<Bytes>, + ) -> reqsign_core::Result<http::Response<Bytes>> { + let uri = req.uri().to_string(); + if uri != "https://account.blob.core.windows.net/?restype=service&comp=userdelegationkey" + { + return Err(reqsign_core::Error::unexpected("unexpected request uri") + .with_context(uri)); + } + + let auth = req + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + if auth != "Bearer token" { + return Err(reqsign_core::Error::unexpected("unexpected authorization header") + .with_context(auth)); + } + + let body = r#" +<UserDelegationKey> + <SignedOid>oid</SignedOid> + <SignedTid>tid</SignedTid> + <SignedStart>2022-03-01T08:12:34Z</SignedStart> + <SignedExpiry>2022-03-01T09:12:34Z</SignedExpiry> + <SignedService>b</SignedService> + <SignedVersion>2020-12-06</SignedVersion> + <Value>a2V5</Value> +</UserDelegationKey> +"#; + + Ok(http::Response::builder() + .status(200) + .body(Bytes::from(body)) + .unwrap()) + } + } + + #[tokio::test] + async fn test_bearer_token_presign_user_delegation_sas() { + let now = Timestamp::from_str("2022-03-01T08:12:34Z").unwrap(); + + let ctx = Context::new() + .with_file_read(TokioFileRead) + .with_http_send(MockUserDelegationHttpSend) + .with_env(OsEnv); + + let cred = Credential::with_bearer_token("token", None); + let builder = RequestSigner::new() + .with_time(now) + .with_user_delegation_presign(SasResourceKind::Blob, "r"); + + let req = Request::builder() + .uri("https://account.blob.core.windows.net/container/path/to/blob.txt") + .body(()) + .unwrap(); + let (mut parts, _) = req.into_parts(); + + builder + .sign_request(&ctx, &mut parts, Some(&cred), Some(Duration::from_secs(300))) + .await + .unwrap(); + + assert_eq!( + parts.uri.to_string(), + "https://account.blob.core.windows.net/container/path/to/blob.txt?sv=2020-12-06&se=2022-03-01T08%3A17%3A34Z&sp=r&sr=b&skoid=oid&sktid=tid&skt=2022-03-01T08%3A12%3A34Z&ske=2022-03-01T09%3A12%3A34Z&sks=b&skv=2020-12-06&sig=VkI3h/LWkD6qcDzshjQzCuCdMPDCFA3tMEbxM%2BED5Nc%3D" + ); + } } diff --git a/services/azure-storage/src/user_delegation.rs b/services/azure-storage/src/user_delegation.rs new file mode 100644 index 0000000..74090d2 --- /dev/null +++ b/services/azure-storage/src/user_delegation.rs @@ -0,0 +1,239 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 bytes::Bytes; +use http::header; +use reqsign_core::Context; +use reqsign_core::Result; +use reqsign_core::hash; +use reqsign_core::time::Timestamp; + +use crate::service_sas::ServiceSasResource; + +const DEFAULT_USER_DELEGATION_SAS_VERSION: &str = "2020-12-06"; + +#[derive(Clone, Debug)] +pub(crate) struct UserDelegationKey { + pub signed_oid: String, + pub signed_tid: String, + pub signed_start: Timestamp, + pub signed_expiry: Timestamp, + pub signed_service: String, + pub signed_version: String, + pub value: String, +} + +pub(crate) async fn get_user_delegation_key( + ctx: &Context, + scheme: &str, + authority: &str, + bearer_token: &str, + start: Timestamp, + expiry: Timestamp, + service_version: &str, + now: Timestamp, +) -> Result<UserDelegationKey> { + let uri: http::Uri = format!( + "{scheme}://{authority}/?restype=service&comp=userdelegationkey" + ) + .parse() + .map_err(|e| reqsign_core::Error::request_invalid("invalid user delegation key URI").with_source(e))?; + + let body = format!( + "<UserDelegationKey><SignedStart>{}</SignedStart><SignedExpiry>{}</SignedExpiry></UserDelegationKey>", + start.format_rfc3339_zulu(), + expiry.format_rfc3339_zulu(), + ); + + let req = http::Request::post(uri) + .header("x-ms-version", service_version) + .header("x-ms-date", now.format_http_date()) + .header(header::CONTENT_TYPE, "application/xml") + .header(header::AUTHORIZATION, format!("Bearer {bearer_token}")) + .body(Bytes::from(body)) + .map_err(|e| reqsign_core::Error::unexpected("failed to build user delegation key request").with_source(e))?; + + let resp = ctx.http_send(req).await?; + let (parts, body) = resp.into_parts(); + if !parts.status.is_success() { + return Err(reqsign_core::Error::unexpected("user delegation key request failed") + .with_context(format!("status: {}", parts.status))); + } + + let xml = String::from_utf8_lossy(&body).to_string(); + + let signed_oid = extract_tag(&xml, "SignedOid")?; + let signed_tid = extract_tag(&xml, "SignedTid")?; + let signed_start = parse_timestamp(&extract_tag(&xml, "SignedStart")?)?; + let signed_expiry = parse_timestamp(&extract_tag(&xml, "SignedExpiry")?)?; + let signed_service = extract_tag(&xml, "SignedService")?; + let signed_version = extract_tag(&xml, "SignedVersion")?; + let value = extract_tag(&xml, "Value")?; + + Ok(UserDelegationKey { + signed_oid, + signed_tid, + signed_start, + signed_expiry, + signed_service, + signed_version, + value, + }) +} + +fn parse_timestamp(s: &str) -> Result<Timestamp> { + s.parse::<Timestamp>() + .map_err(|e| reqsign_core::Error::request_invalid("invalid timestamp").with_source(e)) +} + +fn extract_tag(xml: &str, tag: &str) -> Result<String> { + let open = format!("<{tag}>"); + let close = format!("</{tag}>"); + + let start = xml + .find(&open) + .ok_or_else(|| reqsign_core::Error::unexpected("missing xml tag").with_context(tag))? + + open.len(); + let end = xml[start..] + .find(&close) + .ok_or_else(|| reqsign_core::Error::unexpected("missing xml end tag").with_context(tag))? + + start; + + Ok(xml[start..end].trim().to_string()) +} + +pub(crate) struct UserDelegationSharedAccessSignature { + account: String, + key: UserDelegationKey, + + resource: ServiceSasResource, + permissions: String, + expiry: Timestamp, + start: Option<Timestamp>, + ip: Option<String>, + protocol: Option<String>, + version: String, +} + +impl UserDelegationSharedAccessSignature { + pub(crate) fn new( + account: String, + key: UserDelegationKey, + resource: ServiceSasResource, + permissions: String, + expiry: Timestamp, + ) -> Self { + Self { + account, + key, + resource, + permissions, + expiry, + start: None, + ip: None, + protocol: None, + version: DEFAULT_USER_DELEGATION_SAS_VERSION.to_string(), + } + } + + pub(crate) fn with_start(mut self, start: Timestamp) -> Self { + self.start = Some(start); + self + } + + pub(crate) fn with_ip(mut self, ip: impl Into<String>) -> Self { + self.ip = Some(ip.into()); + self + } + + pub(crate) fn with_protocol(mut self, protocol: impl Into<String>) -> Self { + self.protocol = Some(protocol.into()); + self + } + + pub(crate) fn with_version(mut self, version: impl Into<String>) -> Self { + self.version = version.into(); + self + } + + fn signature(&self) -> Result<String> { + let canonicalized_resource = self.resource.canonicalized_resource(&self.account); + + let string_to_sign = format!( + "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + self.permissions, + self.start + .as_ref() + .map_or("".to_string(), |v| v.format_rfc3339_zulu()), + self.expiry.format_rfc3339_zulu(), + canonicalized_resource, + self.key.signed_oid, + self.key.signed_tid, + self.key.signed_start.format_rfc3339_zulu(), + self.key.signed_expiry.format_rfc3339_zulu(), + self.key.signed_service, + self.key.signed_version, + self.ip.clone().unwrap_or_default(), + self.protocol.clone().unwrap_or_default(), + &self.version, + self.resource.signed_resource(), + "", // snapshot time + "", // encryption scope + "", // rscc + "", // rscd + "", // rsce + "", // rscl + "", // rsct + ); + + let decoded_key = hash::base64_decode(&self.key.value)?; + Ok(hash::base64_hmac_sha256( + &decoded_key, + string_to_sign.as_bytes(), + )) + } + + pub(crate) fn token(&self) -> Result<Vec<(String, String)>> { + let mut elements: Vec<(String, String)> = vec![ + ("sv".to_string(), self.version.to_string()), + ("se".to_string(), self.expiry.format_rfc3339_zulu()), + ("sp".to_string(), self.permissions.to_string()), + ("sr".to_string(), self.resource.signed_resource().to_string()), + ("skoid".to_string(), self.key.signed_oid.to_string()), + ("sktid".to_string(), self.key.signed_tid.to_string()), + ("skt".to_string(), self.key.signed_start.format_rfc3339_zulu()), + ("ske".to_string(), self.key.signed_expiry.format_rfc3339_zulu()), + ("sks".to_string(), self.key.signed_service.to_string()), + ("skv".to_string(), self.key.signed_version.to_string()), + ]; + + if let Some(start) = &self.start { + elements.push(("st".to_string(), start.format_rfc3339_zulu())) + } + if let Some(ip) = &self.ip { + elements.push(("sip".to_string(), ip.to_string())) + } + if let Some(protocol) = &self.protocol { + elements.push(("spr".to_string(), protocol.to_string())) + } + + let sig = self.signature()?; + elements.push(("sig".to_string(), sig)); + + Ok(elements) + } +}
