diff --git a/Cargo.toml b/Cargo.toml index 8226293d..750eda97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ reqsign-http-send-reqwest = { version = "3.0.0", path = "context/http-send-reqwe reqsign-huaweicloud-obs = { version = "2.0.2", path = "services/huaweicloud-obs" } reqsign-oracle = { version = "2.0.2", path = "services/oracle" } reqsign-tencent-cos = { version = "2.0.2", path = "services/tencent-cos" } +reqsign-volcengine-tos = { version = "2.0.2", path = "services/volcengine-tos" } # Crates.io dependencies anyhow = "1" diff --git a/reqsign/Cargo.toml b/reqsign/Cargo.toml index 843c78a2..3983c450 100644 --- a/reqsign/Cargo.toml +++ b/reqsign/Cargo.toml @@ -44,6 +44,7 @@ reqsign-google = { workspace = true, optional = true } reqsign-huaweicloud-obs = { workspace = true, optional = true } reqsign-oracle = { workspace = true, optional = true } reqsign-tencent-cos = { workspace = true, optional = true } +reqsign-volcengine-tos = { workspace = true, optional = true } # Context implementations (optional but included by default) reqsign-command-execute-tokio = { workspace = true, optional = true } @@ -66,9 +67,10 @@ google = ["dep:reqsign-google"] huaweicloud = ["dep:reqsign-huaweicloud-obs"] oracle = ["dep:reqsign-oracle"] tencent = ["dep:reqsign-tencent-cos"] +volcengine = ["dep:reqsign-volcengine-tos"] # Full feature set -full = ["aliyun", "aws", "azure", "google", "huaweicloud", "oracle", "tencent"] +full = ["aliyun", "aws", "azure", "google", "huaweicloud", "oracle", "tencent", "volcengine"] [dev-dependencies] anyhow = "1" diff --git a/reqsign/src/lib.rs b/reqsign/src/lib.rs index 9184937d..9edc5908 100644 --- a/reqsign/src/lib.rs +++ b/reqsign/src/lib.rs @@ -48,3 +48,6 @@ pub mod oracle; #[cfg(feature = "tencent")] pub mod tencent; + +#[cfg(feature = "volcengine")] +pub mod volcengine; diff --git a/reqsign/src/volcengine.rs b/reqsign/src/volcengine.rs new file mode 100644 index 00000000..b9ea771c --- /dev/null +++ b/reqsign/src/volcengine.rs @@ -0,0 +1,67 @@ +// 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. + +//! Volcengine TOS service support with convenience APIs +//! +//! This module provides Volcengine TOS signing functionality along with convenience +//! functions for common use cases. + +// Re-export all Volcengine TOS signing types +pub use reqsign_volcengine_tos::*; + +#[cfg(feature = "default-context")] +use crate::{Signer, default_context}; + +/// Default Volcengine TOS Signer type with commonly used components +#[cfg(feature = "default-context")] +pub type DefaultSigner = Signer; + +/// Create a default Volcengine TOS signer with standard configuration +/// +/// This function creates a signer with: +/// - Default context (with Tokio file reader, reqwest HTTP client, OS environment) +/// - Default credential provider (reads from env vars) +/// - Request signer for the specified region +/// +/// # Example +/// +/// ```no_run +/// # #[tokio::main] +/// # async fn main() -> reqsign_core::Result<()> { +/// // Create a signer for Volcengine TOS in cn-beijing region +/// let signer = reqsign::volcengine::default_signer("cn-beijing"); +/// +/// // Sign a request +/// let mut req = http::Request::builder() +/// .method("GET") +/// .uri("https://mybucket.tos-cn-beijing.volces.com/myobject") +/// .body(()) +/// .unwrap() +/// .into_parts() +/// .0; +/// +/// signer.sign(&mut req, None).await?; +/// # Ok(()) +/// # } +/// ``` +#[cfg(feature = "default-context")] +pub fn default_signer(region: &str) -> DefaultSigner { + let ctx = default_context(); + let provider = DefaultCredentialProvider::new(); + let signer = RequestSigner::new(region); + Signer::new(ctx, provider, signer) +} diff --git a/services/volcengine-tos/Cargo.toml b/services/volcengine-tos/Cargo.toml new file mode 100644 index 00000000..e24c8b42 --- /dev/null +++ b/services/volcengine-tos/Cargo.toml @@ -0,0 +1,42 @@ +# 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. + +[package] +name = "reqsign-volcengine-tos" +version = "2.0.2" + +description = "Volcengine TOS signing implementation for reqsign." + +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +http = { workspace = true } +log = { workspace = true } +percent-encoding = { workspace = true } +reqsign-core = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +env_logger = { workspace = true } +reqsign-file-read-tokio = { workspace = true } +reqsign-http-send-reqwest = { workspace = true } +tokio = { workspace = true, features = ["full"] } diff --git a/services/volcengine-tos/src/constants.rs b/services/volcengine-tos/src/constants.rs new file mode 100644 index 00000000..0091e132 --- /dev/null +++ b/services/volcengine-tos/src/constants.rs @@ -0,0 +1,38 @@ +// 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 percent_encoding::{AsciiSet, NON_ALPHANUMERIC}; + +pub const ENV_ACCESS_KEY_ID: &str = "VOLCENGINE_ACCESS_KEY_ID"; +pub const ENV_SECRET_ACCESS_KEY: &str = "VOLCENGINE_SECRET_ACCESS_KEY"; +pub const ENV_SESSION_TOKEN: &str = "VOLCENGINE_SESSION_TOKEN"; + +pub static VOLCENGINE_URI_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~') + .remove(b'/'); + +pub static VOLCENGINE_QUERY_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~'); + +pub const EMPTY_PAYLOAD_SHA256: &str = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; diff --git a/services/volcengine-tos/src/credential.rs b/services/volcengine-tos/src/credential.rs new file mode 100644 index 00000000..c5fb9b54 --- /dev/null +++ b/services/volcengine-tos/src/credential.rs @@ -0,0 +1,62 @@ +// 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::SigningCredential; +use reqsign_core::utils::Redact; +use std::fmt::{Debug, Formatter}; + +/// Credential for Volcengine TOS. +#[derive(Clone)] +pub struct Credential { + /// Access key id. + pub access_key_id: String, + /// Secret access key. + pub secret_access_key: String, + /// Session token. + pub session_token: Option, +} + +impl Credential { + pub fn new(access_key_id: &str, secret_access_key: &str) -> Self { + Self { + access_key_id: access_key_id.to_string(), + secret_access_key: secret_access_key.to_string(), + session_token: None, + } + } + + pub fn with_session_token(mut self, token: &str) -> Self { + self.session_token = Some(token.to_string()); + self + } +} + +impl Debug for Credential { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credential") + .field("access_key_id", &Redact::from(&self.access_key_id)) + .field("secret_access_key", &Redact::from(&self.secret_access_key)) + .field("session_token", &Redact::from(&self.session_token)) + .finish() + } +} + +impl SigningCredential for Credential { + fn is_valid(&self) -> bool { + !self.access_key_id.is_empty() && !self.secret_access_key.is_empty() + } +} diff --git a/services/volcengine-tos/src/lib.rs b/services/volcengine-tos/src/lib.rs new file mode 100644 index 00000000..48a36372 --- /dev/null +++ b/services/volcengine-tos/src/lib.rs @@ -0,0 +1,31 @@ +// 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. + +//! Volcengine TOS signing implementation for reqsign. + +mod constants; +mod credential; +pub use credential::Credential; + +mod provide_credential; +pub use provide_credential::*; + +mod sign_request; +mod uri; +pub use uri::{percent_encode_path, percent_encode_query}; + +pub use sign_request::RequestSigner; diff --git a/services/volcengine-tos/src/provide_credential/default.rs b/services/volcengine-tos/src/provide_credential/default.rs new file mode 100644 index 00000000..678596b3 --- /dev/null +++ b/services/volcengine-tos/src/provide_credential/default.rs @@ -0,0 +1,196 @@ +// 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 async_trait::async_trait; +use reqsign_core::Result; +use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain}; + +use crate::credential::Credential; +use crate::provide_credential::EnvCredentialProvider; + +/// DefaultCredentialProvider will try to load credential from different sources. +/// +/// Resolution order: +/// +/// 1. Environment variables +#[derive(Debug)] +pub struct DefaultCredentialProvider { + chain: ProvideCredentialChain, +} + +impl Default for DefaultCredentialProvider { + fn default() -> Self { + Self::new() + } +} + +impl DefaultCredentialProvider { + /// Create a builder to configure the default credential chain. + pub fn builder() -> DefaultCredentialProviderBuilder { + DefaultCredentialProviderBuilder::default() + } + + /// Create a new DefaultCredentialProvider using the default chain. + pub fn new() -> Self { + Self::builder().build() + } + + /// Create with a custom credential chain. + pub fn with_chain(chain: ProvideCredentialChain) -> Self { + Self { chain } + } + + /// Add a credential provider to the front of the default chain. + /// + /// This allows adding a high-priority credential source that will be tried + /// before all other providers in the default chain. + /// + /// # Example + /// + /// ```no_run + /// use reqsign_volcengine_tos::{DefaultCredentialProvider, StaticCredentialProvider}; + /// + /// let provider = DefaultCredentialProvider::new() + /// .push_front(StaticCredentialProvider::new("access_key_id", "secret_access_key")); + /// ``` + pub fn push_front( + mut self, + provider: impl ProvideCredential + 'static, + ) -> Self { + self.chain = self.chain.push_front(provider); + self + } +} + +/// Builder for `DefaultCredentialProvider`. +/// +/// Use `configure_env` to customize environment loading and +/// `disable_env(bool)` to control participation, then `build()` to create the provider. +#[derive(Default)] +pub struct DefaultCredentialProviderBuilder { + env: Option, +} + +impl DefaultCredentialProviderBuilder { + /// Create a new builder with default state. + pub fn new() -> Self { + Self::default() + } + + /// Configure the environment credential provider. + pub fn configure_env(mut self, f: F) -> Self + where + F: FnOnce(EnvCredentialProvider) -> EnvCredentialProvider, + { + let p = self.env.take().unwrap_or_default(); + self.env = Some(f(p)); + self + } + + /// Disable (true) or ensure enabled (false) the environment provider. + pub fn disable_env(mut self, disable: bool) -> Self { + if disable { + self.env = None; + } else if self.env.is_none() { + self.env = Some(EnvCredentialProvider::new()); + } + self + } + + /// Build the `DefaultCredentialProvider` with the configured options. + pub fn build(self) -> DefaultCredentialProvider { + let mut chain = ProvideCredentialChain::new(); + if let Some(p) = self.env { + chain = chain.push(p); + } else { + chain = chain.push(EnvCredentialProvider::new()); + } + DefaultCredentialProvider::with_chain(chain) + } +} + +#[async_trait] +impl ProvideCredential for DefaultCredentialProvider { + type Credential = Credential; + + async fn provide_credential(&self, ctx: &Context) -> Result> { + self.chain.provide_credential(ctx).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::*; + use reqsign_core::StaticEnv; + use std::collections::HashMap; + + #[tokio::test] + async fn test_default_loader_without_env() { + let ctx = Context::new().with_env(StaticEnv { + home_dir: None, + envs: HashMap::new(), + }); + + let loader = DefaultCredentialProvider::new(); + let credential = loader.provide_credential(&ctx).await.unwrap(); + + assert!(credential.is_none()); + } + + #[tokio::test] + async fn test_default_loader_with_env() { + let ctx = Context::new().with_env(StaticEnv { + home_dir: None, + envs: HashMap::from_iter([ + (ENV_ACCESS_KEY_ID.to_string(), "access_key_id".to_string()), + ( + ENV_SECRET_ACCESS_KEY.to_string(), + "secret_access_key".to_string(), + ), + ]), + }); + + let loader = DefaultCredentialProvider::new(); + let credential = loader.provide_credential(&ctx).await.unwrap().unwrap(); + + assert_eq!("access_key_id", credential.access_key_id); + assert_eq!("secret_access_key", credential.secret_access_key); + } + + #[tokio::test] + async fn test_default_loader_with_security_token() { + let ctx = Context::new().with_env(StaticEnv { + home_dir: None, + envs: HashMap::from_iter([ + (ENV_ACCESS_KEY_ID.to_string(), "access_key_id".to_string()), + ( + ENV_SECRET_ACCESS_KEY.to_string(), + "secret_access_key".to_string(), + ), + (ENV_SESSION_TOKEN.to_string(), "security_token".to_string()), + ]), + }); + + let loader = DefaultCredentialProvider::new(); + let credential = loader.provide_credential(&ctx).await.unwrap().unwrap(); + + assert_eq!("access_key_id", credential.access_key_id); + assert_eq!("secret_access_key", credential.secret_access_key); + assert_eq!("security_token", credential.session_token.unwrap()); + } +} diff --git a/services/volcengine-tos/src/provide_credential/env.rs b/services/volcengine-tos/src/provide_credential/env.rs new file mode 100644 index 00000000..5c68b3e7 --- /dev/null +++ b/services/volcengine-tos/src/provide_credential/env.rs @@ -0,0 +1,153 @@ +// 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 async_trait::async_trait; +use reqsign_core::Context; +use reqsign_core::ProvideCredential; +use reqsign_core::Result; + +use super::super::constants::*; +use super::super::credential::Credential; + +/// EnvCredentialProvider loads Volcengine credentials from environment variables. +/// +/// This provider looks for the following environment variables: +/// - `VOLCENGINE_ACCESS_KEY_ID`: The Volcengine access key ID +/// - `VOLCENGINE_SECRET_ACCESS_KEY`: The Volcengine secret access key +/// - `VOLCENGINE_SESSION_TOKEN`: The Volcengine session token (optional) +#[derive(Debug, Default, Clone)] +pub struct EnvCredentialProvider; + +impl EnvCredentialProvider { + /// Create a new EnvCredentialProvider. + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ProvideCredential for EnvCredentialProvider { + type Credential = Credential; + + async fn provide_credential(&self, ctx: &Context) -> Result> { + let env = ctx.env_vars(); + let access_key_id = env.get(ENV_ACCESS_KEY_ID).cloned(); + let secret_access_key = env.get(ENV_SECRET_ACCESS_KEY).cloned(); + let security_token = env.get(ENV_SESSION_TOKEN).cloned(); + + match (access_key_id, secret_access_key) { + (Some(ak), Some(sk)) => { + let mut cred = Credential::new(&ak, &sk); + if let Some(token) = security_token { + cred = cred.with_session_token(&token); + } + Ok(Some(cred)) + } + _ => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reqsign_core::StaticEnv; + use std::collections::HashMap; + + #[tokio::test] + async fn test_env_credential_provider() -> anyhow::Result<()> { + let envs = HashMap::from([ + (ENV_ACCESS_KEY_ID.to_string(), "test_access_key".to_string()), + ( + ENV_SECRET_ACCESS_KEY.to_string(), + "test_secret_key".to_string(), + ), + ]); + + let ctx = Context::new().with_env(StaticEnv { + home_dir: None, + envs, + }); + + let provider = EnvCredentialProvider::new(); + let cred = provider.provide_credential(&ctx).await?; + assert!(cred.is_some()); + let cred = cred.unwrap(); + assert_eq!(cred.access_key_id, "test_access_key"); + assert_eq!(cred.secret_access_key, "test_secret_key"); + assert!(cred.session_token.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_env_credential_provider_with_session_token() -> anyhow::Result<()> { + let envs = HashMap::from([ + (ENV_ACCESS_KEY_ID.to_string(), "test_access_key".to_string()), + ( + ENV_SECRET_ACCESS_KEY.to_string(), + "test_secret_key".to_string(), + ), + ( + ENV_SESSION_TOKEN.to_string(), + "test_session_token".to_string(), + ), + ]); + + let ctx = Context::new().with_env(StaticEnv { + home_dir: None, + envs, + }); + + let provider = EnvCredentialProvider::new(); + let cred = provider.provide_credential(&ctx).await?; + assert!(cred.is_some()); + let cred = cred.unwrap(); + assert_eq!(cred.access_key_id, "test_access_key"); + assert_eq!(cred.secret_access_key, "test_secret_key"); + assert_eq!(cred.session_token, Some("test_session_token".to_string())); + + Ok(()) + } + + #[tokio::test] + async fn test_env_credential_provider_missing_credentials() -> anyhow::Result<()> { + let ctx = Context::new().with_env(StaticEnv::default()); + + let provider = EnvCredentialProvider::new(); + let cred = provider.provide_credential(&ctx).await?; + assert!(cred.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_env_credential_provider_partial_credentials() -> anyhow::Result<()> { + let envs = HashMap::from([(ENV_ACCESS_KEY_ID.to_string(), "test_access_key".to_string())]); + + let ctx = Context::new().with_env(StaticEnv { + home_dir: None, + envs, + }); + + let provider = EnvCredentialProvider::new(); + let cred = provider.provide_credential(&ctx).await?; + assert!(cred.is_none()); + + Ok(()) + } +} diff --git a/services/volcengine-tos/src/provide_credential/mod.rs b/services/volcengine-tos/src/provide_credential/mod.rs new file mode 100644 index 00000000..4461cd34 --- /dev/null +++ b/services/volcengine-tos/src/provide_credential/mod.rs @@ -0,0 +1,24 @@ +// 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. + +mod default; +mod env; +mod r#static; + +pub use default::DefaultCredentialProvider; +pub use env::EnvCredentialProvider; +pub use r#static::StaticCredentialProvider; diff --git a/services/volcengine-tos/src/provide_credential/static.rs b/services/volcengine-tos/src/provide_credential/static.rs new file mode 100644 index 00000000..42f4df75 --- /dev/null +++ b/services/volcengine-tos/src/provide_credential/static.rs @@ -0,0 +1,100 @@ +// 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 async_trait::async_trait; +use reqsign_core::Context; +use reqsign_core::ProvideCredential; +use reqsign_core::Result; + +use super::super::credential::Credential; + +/// StaticCredentialProvider provides static Volcengine credentials. +/// +/// This provider is used when you have the access key ID and secret access key +/// directly and want to use them without any dynamic loading. +#[derive(Debug, Clone)] +pub struct StaticCredentialProvider { + access_key_id: String, + access_key_secret: String, + security_token: Option, +} + +impl StaticCredentialProvider { + /// Create a new StaticCredentialProvider with access key ID and secret access key. + pub fn new(access_key_id: &str, access_key_secret: &str) -> Self { + Self { + access_key_id: access_key_id.to_string(), + access_key_secret: access_key_secret.to_string(), + security_token: None, + } + } + + /// Set the security token. + pub fn with_security_token(mut self, token: &str) -> Self { + self.security_token = Some(token.to_string()); + self + } +} + +#[async_trait] +impl ProvideCredential for StaticCredentialProvider { + type Credential = Credential; + + async fn provide_credential(&self, _ctx: &Context) -> Result> { + let mut cred = Credential::new(&self.access_key_id, &self.access_key_secret); + if let Some(token) = &self.security_token { + cred = cred.with_session_token(token); + } + Ok(Some(cred)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_static_credential_provider() -> anyhow::Result<()> { + let ctx = Context::new(); + + let provider = StaticCredentialProvider::new("test_access_key", "test_secret_key"); + let cred = provider.provide_credential(&ctx).await?; + assert!(cred.is_some()); + let cred = cred.unwrap(); + assert_eq!(cred.access_key_id, "test_access_key"); + assert_eq!(cred.secret_access_key, "test_secret_key"); + assert!(cred.session_token.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_static_credential_provider_with_security_token() -> anyhow::Result<()> { + let ctx = Context::new(); + + let provider = StaticCredentialProvider::new("test_access_key", "test_secret_key") + .with_security_token("test_security_token"); + let cred = provider.provide_credential(&ctx).await?; + assert!(cred.is_some()); + let cred = cred.unwrap(); + assert_eq!(cred.access_key_id, "test_access_key"); + assert_eq!(cred.secret_access_key, "test_secret_key"); + assert_eq!(cred.session_token, Some("test_security_token".to_string())); + + Ok(()) + } +} diff --git a/services/volcengine-tos/src/sign_request.rs b/services/volcengine-tos/src/sign_request.rs new file mode 100644 index 00000000..c4e1c42f --- /dev/null +++ b/services/volcengine-tos/src/sign_request.rs @@ -0,0 +1,310 @@ +// 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 std::fmt::Write; +use std::sync::LazyLock; + +use async_trait::async_trait; +use http::header::AUTHORIZATION; +use http::{HeaderName, HeaderValue, header}; +use log::debug; +use percent_encoding::percent_decode_str; +use reqsign_core::hash::{hex_hmac_sha256, hex_sha256, hmac_sha256}; +use reqsign_core::time::Timestamp; +use reqsign_core::{Context, Result, SignRequest, SigningRequest}; + +use crate::constants::*; +use crate::credential::Credential; +use crate::uri::{percent_encode_path, percent_encode_query}; + +static HEADER_TOS_DATE: LazyLock = + LazyLock::new(|| HeaderName::from_static("x-tos-date")); +static HEADER_TOS_SECURITY_TOKEN: LazyLock = + LazyLock::new(|| HeaderName::from_static("x-tos-security-token")); + +/// RequestSigner that implements Volcengine TOS signing. +/// +/// - [Volcengine TOS Signature](https://www.volcengine.com/docs/6349/1747874) +#[derive(Debug)] +pub struct RequestSigner { + region: String, + time: Option, +} + +impl RequestSigner { + /// Create a new RequestSigner for the given region. + pub fn new(region: &str) -> Self { + Self { + region: region.to_string(), + time: None, + } + } + + /// Specify the signing time. + /// + /// # Note + /// + /// We should always take current time to sign requests. + /// Only use this function for testing. + #[cfg(test)] + pub fn with_time(mut self, time: Timestamp) -> Self { + self.time = Some(time); + self + } +} + +#[async_trait] +impl SignRequest for RequestSigner { + type Credential = Credential; + + async fn sign_request( + &self, + _ctx: &Context, + req: &mut http::request::Parts, + credential: Option<&Self::Credential>, + _expires_in: Option, + ) -> Result<()> { + let Some(cred) = credential else { + return Ok(()); + }; + + let now = self.time.unwrap_or_else(Timestamp::now); + + let mut signing_req = SigningRequest::build(req)?; + + // Insert HOST header if not present. + if signing_req.headers.get(header::HOST).is_none() { + signing_req.headers.insert( + header::HOST, + signing_req.authority.as_str().parse().map_err(|e| { + reqsign_core::Error::unexpected(format!( + "failed to parse authority as header value: {e}" + )) + })?, + ); + } + + let date_str = now.format_iso8601(); + let date_only = now.format_date(); + + signing_req + .headers + .insert(&*HEADER_TOS_DATE, date_str.parse()?); + + if let Some(token) = &cred.session_token { + signing_req + .headers + .insert(&*HEADER_TOS_SECURITY_TOKEN, token.parse()?); + } + + canonicalize_query(&mut signing_req); + let (canonical_request_hash, _) = canonical_request_hash(&mut signing_req)?; + + // Scope: "//tos/request" + let credential_scope = format!("{}/{}/tos/request", date_only, self.region); + + // StringToSign: + // + // TOS4-HMAC-SHA256 + // + // + // + let string_to_sign = { + let mut s = String::new(); + writeln!(s, "TOS4-HMAC-SHA256")?; + writeln!(s, "{}", date_str)?; + writeln!(s, "{}", credential_scope)?; + s.push_str(&canonical_request_hash); + s + }; + + debug!("string to sign: {}", &string_to_sign); + + let signed_headers_str = signing_req.header_name_to_vec_sorted().join(";"); + + let signing_key = generate_signing_key(&cred.secret_access_key, &date_only, &self.region); + let signature = hex_hmac_sha256(&signing_key, string_to_sign.as_bytes()); + + let authorization = format!( + "TOS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + cred.access_key_id, credential_scope, signed_headers_str, signature + ); + + debug!("authorization: {}", &authorization); + + let mut auth_value: HeaderValue = authorization.parse()?; + auth_value.set_sensitive(true); + signing_req.headers.insert(AUTHORIZATION, auth_value); + + signing_req.apply(req) + } +} + +fn canonicalize_query(ctx: &mut SigningRequest) { + ctx.query = ctx + .query + .iter() + .map(|(k, v)| (percent_encode_query(k), percent_encode_query(v))) + .collect(); + // Sort by param name + ctx.query.sort(); +} + +fn canonical_request_hash(ctx: &mut SigningRequest) -> Result<(String, String)> { + let mut canonical_request = String::with_capacity(256); + + // Insert method + canonical_request.push_str(ctx.method.as_str()); + canonical_request.push('\n'); + + // Insert encoded path + let path = percent_decode_str(&ctx.path) + .decode_utf8() + .map_err(|e| reqsign_core::Error::unexpected(format!("failed to decode path: {e}")))?; + let canonical_path = percent_encode_path(&path); + canonical_request.push_str(&canonical_path); + canonical_request.push('\n'); + + // Insert encoded query + let query_string = ctx + .query + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join("&"); + + canonical_request.push_str(&query_string); + canonical_request.push('\n'); + + // Insert signed headers + let signed_headers = ctx.header_name_to_vec_sorted(); + + for header in &signed_headers { + let value = &ctx.headers[*header]; + canonical_request.push_str(header); + canonical_request.push(':'); + if let Ok(value_str) = value.to_str() { + canonical_request.push_str(value_str.trim()); + } + canonical_request.push('\n'); + } + + canonical_request.push('\n'); + canonical_request.push_str(signed_headers.join(";").as_str()); + canonical_request.push('\n'); + + canonical_request.push_str(EMPTY_PAYLOAD_SHA256); + + let hash = hex_sha256(canonical_request.as_bytes()); + + Ok((hash, canonical_request)) +} + +fn generate_signing_key(secret: &str, date: &str, region: &str) -> Vec { + // Sign date + let sign_date = hmac_sha256(secret.as_bytes(), date.as_bytes()); + // Sign region + let sign_region = hmac_sha256(sign_date.as_slice(), region.as_bytes()); + // Sign service + let sign_service = hmac_sha256(sign_region.as_slice(), "tos".as_bytes()); + // Sign request + hmac_sha256(sign_service.as_slice(), "request".as_bytes()) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use http::Uri; + + use super::*; + use crate::provide_credential::StaticCredentialProvider; + use reqsign_core::{Context, OsEnv, Signer}; + use reqsign_file_read_tokio::TokioFileRead; + use reqsign_http_send_reqwest::ReqwestHttpSend; + + #[tokio::test] + async fn test_sign_request() -> Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let loader = StaticCredentialProvider::new("testAK", "testSK"); + let signer = RequestSigner::new("cn-beijing") + .with_time(Timestamp::parse_rfc2822("Sat, 1 Jan 2022 00:00:00 GMT")?); + + let ctx = Context::new() + .with_file_read(TokioFileRead) + .with_http_send(ReqwestHttpSend::default()) + .with_env(OsEnv); + + let signer = Signer::new(ctx, loader, signer); + + let get_req = "https://examplebucket.tos-cn-beijing.volces.com/exampleobject"; + let mut req = http::Request::get(Uri::from_str(get_req)?).body(())?; + req.headers_mut().insert( + HeaderName::from_str("x-tos-content-sha256")?, + HeaderValue::from_str( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + )?, + ); + + let (mut parts, _) = req.into_parts(); + signer.sign(&mut parts, None).await?; + + let headers = parts.headers; + let tos_date = headers.get("x-tos-date").unwrap(); + let auth = headers.get("Authorization").unwrap(); + + assert!( + tos_date.to_str()?.starts_with("2022"), + "x-tos-date should be in ISO8601 format" + ); + assert_eq!( + "TOS4-HMAC-SHA256 Credential=testAK/20220101/cn-beijing/tos/request, SignedHeaders=host;x-tos-content-sha256;x-tos-date, Signature=d40b66cf0054d1642843670d10fa095e1609c7896f25df217770b0abe717693b", + auth.to_str()? + ); + + Ok(()) + } + + #[tokio::test] + async fn test_sign_list_objects() -> Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + let loader = StaticCredentialProvider::new("testAK", "testSK"); + + let signer = RequestSigner::new("cn-beijing").with_time("2026-02-03T12:24:12Z".parse()?); + + let ctx = Context::new() + .with_file_read(TokioFileRead) + .with_http_send(ReqwestHttpSend::default()) + .with_env(OsEnv); + let signer = Signer::new(ctx, loader, signer); + + let req = http::Request::get("https://bucket.tos-cn-beijing.volces.com?list-type=2&prefix=abc&delimiter=%2F&max-keys=5&continuation-token=whvFnl2rE5vm9cWvQSgxwpc7QXHY7dgUGQ7nxlsVxFymg2%2BK227j5IHQZ32h").body(())?; + let (mut parts, _) = req.into_parts(); + + signer.sign(&mut parts, None).await?; + + let headers = parts.headers; + let auth = headers.get("Authorization").unwrap(); + + assert_eq!( + "TOS4-HMAC-SHA256 Credential=testAK/20260203/cn-beijing/tos/request, SignedHeaders=host;x-tos-date, Signature=db01ee877fa24847ec042703353a76a0e11bd9b6ce68eabe5ccb2924420156b0", + auth.to_str()? + ); + Ok(()) + } +} diff --git a/services/volcengine-tos/src/uri.rs b/services/volcengine-tos/src/uri.rs new file mode 100644 index 00000000..e9ea4fb2 --- /dev/null +++ b/services/volcengine-tos/src/uri.rs @@ -0,0 +1,27 @@ +// 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 crate::constants::{VOLCENGINE_QUERY_ENCODE_SET, VOLCENGINE_URI_ENCODE_SET}; +use percent_encoding::utf8_percent_encode; + +pub fn percent_encode_path(path: &str) -> String { + utf8_percent_encode(path, &VOLCENGINE_URI_ENCODE_SET).to_string() +} + +pub fn percent_encode_query(query: &str) -> String { + utf8_percent_encode(query, &VOLCENGINE_QUERY_ENCODE_SET).to_string() +}