From dfd0bd64f91c4a59e01876667cfe298a312e32cf Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Sun, 4 Feb 2024 02:16:17 +0800 Subject: [PATCH] feat(oci): init oci api key sign support (#407) --- Cargo.toml | 5 ++- src/lib.rs | 5 +++ src/oracle/config.rs | 31 +++++++++++++ src/oracle/constants.rs | 2 + src/oracle/credential.rs | 90 +++++++++++++++++++++++++++++++++++++ src/oracle/mod.rs | 14 ++++++ src/oracle/oci.rs | 97 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 src/oracle/config.rs create mode 100644 src/oracle/constants.rs create mode 100644 src/oracle/credential.rs create mode 100644 src/oracle/mod.rs create mode 100644 src/oracle/oci.rs diff --git a/Cargo.toml b/Cargo.toml index 36a770c9..2eead931 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ services-all = [ "services-azblob", "services-google", "services-huaweicloud", + "services-oracle", "services-tencent", ] @@ -48,6 +49,7 @@ services-google = [ "dep:jsonwebtoken", ] services-huaweicloud = ["dep:serde", "dep:serde_json", "dep:once_cell"] +services-oracle = ["dep:reqwest", "dep:rsa", "dep:toml"] services-tencent = ["dep:reqwest", "dep:serde", "dep:serde_json"] [[bench]] @@ -70,12 +72,13 @@ percent-encoding = "2" quick-xml = { version = "0.31", features = ["serialize"], optional = true } rand = "0.8.5" reqwest = { version = "0.11", default-features = false, optional = true } -rsa = { version = "0.9.2" } +rsa = { version = "0.9.2", features = ["pkcs5", "sha2"], optional = true } rust-ini = { version = "0.20", optional = true } serde = { version = "1", features = ["derive"], optional = true } serde_json = { version = "1", optional = true } sha1 = "0.10" sha2 = { version = "0.10", features = ["oid"] } +toml = { version = "0.8.9", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] home = "0.5" diff --git a/src/lib.rs b/src/lib.rs index 1ceb4ac7..539cdf3c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -75,6 +75,11 @@ mod huaweicloud; #[cfg(feature = "services-huaweicloud")] pub use huaweicloud::*; +#[cfg(feature = "services-oracle")] +mod oracle; +#[cfg(feature = "services-oracle")] +pub use oracle::*; + #[cfg(feature = "services-tencent")] mod tencent; #[cfg(feature = "services-tencent")] diff --git a/src/oracle/config.rs b/src/oracle/config.rs new file mode 100644 index 00000000..cf54feb9 --- /dev/null +++ b/src/oracle/config.rs @@ -0,0 +1,31 @@ +use anyhow::Result; +use serde::Deserialize; +use std::fs::read_to_string; +use toml::from_str; + +/// Config carries all the configuration for Oracle services. +/// will be loaded from default config file ~/.oci/config +#[derive(Clone, Default, Deserialize)] +#[cfg_attr(test, derive(Debug))] +pub struct Config { + /// userID for Oracle Cloud Infrastructure. + pub user: String, + /// tenancyID for Oracle Cloud Infrastructure. + pub tenancy: String, + /// region for Oracle Cloud Infrastructure. + pub region: String, + /// private key file for Oracle Cloud Infrastructure. + pub key_file: Option, + /// fingerprint for the key_file. + pub fingerprint: Option, +} + +impl Config { + /// Load config from env. + pub fn from_config(path: &str) -> Result { + let content = read_to_string(path)?; + let config = from_str(&content)?; + + Ok(config) + } +} diff --git a/src/oracle/constants.rs b/src/oracle/constants.rs new file mode 100644 index 00000000..974f21ba --- /dev/null +++ b/src/oracle/constants.rs @@ -0,0 +1,2 @@ +// Env values used in oracle cloud infrastructure services. +pub const ORACLE_CONFIG_PATH: &str = "~/.oci/config"; diff --git a/src/oracle/credential.rs b/src/oracle/credential.rs new file mode 100644 index 00000000..08759cd3 --- /dev/null +++ b/src/oracle/credential.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; +use std::sync::Mutex; + +use anyhow::Result; +use log::debug; + +use super::config::Config; +use super::constants::ORACLE_CONFIG_PATH; +use crate::time::now; +use crate::time::DateTime; + +/// Credential that holds the API private key. +/// private_key_path is optional, because some other credential will be added later +#[derive(Default, Clone)] +#[cfg_attr(test, derive(Debug))] +pub struct Credential { + /// TenantID for Oracle Cloud Infrastructure. + pub tenancy: String, + /// UserID for Oracle Cloud Infrastructure. + pub user: String, + /// API Private Key for credential. + pub key_file: Option, + /// Fingerprint of the API Key. + pub fingerprint: Option, + /// expires in for credential. + pub expires_in: Option, +} + +impl Credential { + /// is current cred is valid? + pub fn is_valid(&self) -> bool { + self.key_file.is_some() + && self.fingerprint.is_some() + && self.expires_in.unwrap_or_default() > now() + } +} + +/// Loader will load credential from different methods. +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct Loader { + credential: Arc>>, +} + +impl Loader { + /// Load credential. + pub async fn load(&self) -> Result> { + // Return cached credential if it's valid. + match self.credential.lock().expect("lock poisoned").clone() { + Some(cred) if cred.is_valid() => return Ok(Some(cred)), + _ => (), + } + + let cred = if let Some(cred) = self.load_inner().await? { + cred + } else { + return Ok(None); + }; + + let mut lock = self.credential.lock().expect("lock poisoned"); + *lock = Some(cred.clone()); + + Ok(Some(cred)) + } + + async fn load_inner(&self) -> Result> { + if let Ok(Some(cred)) = self + .load_via_config() + .map_err(|err| debug!("load credential via static failed: {err:?}")) + { + return Ok(Some(cred)); + } + + Ok(None) + } + + fn load_via_config(&self) -> Result> { + let config = Config::from_config(ORACLE_CONFIG_PATH)?; + + Ok(Some(Credential { + tenancy: config.tenancy, + user: config.user, + key_file: config.key_file, + fingerprint: config.fingerprint, + // Set expires_in to 10 minutes to enforce re-read + // from file. + expires_in: Some(now() + chrono::Duration::minutes(10)), + })) + } +} diff --git a/src/oracle/mod.rs b/src/oracle/mod.rs new file mode 100644 index 00000000..89402d47 --- /dev/null +++ b/src/oracle/mod.rs @@ -0,0 +1,14 @@ +//! Oracle Cloud Infrastructure service signer +//! + +mod oci; +pub use oci::APIKeySigner as OCIAPIKeySigner; + +mod config; +pub use config::Config as OCIConfig; + +mod credential; +pub use credential::Credential as OCICredential; +pub use credential::Loader as OCILoader; + +mod constants; diff --git a/src/oracle/oci.rs b/src/oracle/oci.rs new file mode 100644 index 00000000..10e91ac3 --- /dev/null +++ b/src/oracle/oci.rs @@ -0,0 +1,97 @@ +//! Oracle Cloud Infrastructure Singer + +use anyhow::{Error, Result}; +use base64::{engine::general_purpose, Engine as _}; +use http::{ + header::{AUTHORIZATION, DATE}, + HeaderValue, +}; +use log::debug; +use rsa::pkcs1v15::SigningKey; +use rsa::sha2::Sha256; +use rsa::signature::{SignatureEncoding, Signer}; +use rsa::{pkcs8::DecodePrivateKey, RsaPrivateKey}; +use std::fmt::Write; + +use super::credential::Credential; +use crate::ctx::SigningContext; +use crate::request::SignableRequest; +use crate::time; +use crate::time::DateTime; + +/// Singer for Oracle Cloud Infrastructure using API Key. +#[derive(Default)] +pub struct APIKeySigner {} + +impl APIKeySigner { + /// Building a signing context. + fn build(&self, req: &mut impl SignableRequest, cred: &Credential) -> Result { + let now = time::now(); + let mut ctx = req.build()?; + + let string_to_sign = string_to_sign(&mut ctx, now)?; + let private_key = if let Some(path) = &cred.key_file { + RsaPrivateKey::read_pkcs8_pem_file(path)? + } else { + return Err(Error::msg("no private key")); + }; + let signing_key = SigningKey::::new(private_key); + let signature = signing_key.try_sign(string_to_sign.as_bytes())?; + let encoded_signature = general_purpose::STANDARD.encode(signature.to_bytes()); + + ctx.headers + .insert(DATE, HeaderValue::from_str(&time::format_http_date(now))?); + if let Some(fp) = &cred.fingerprint { + let mut auth_value = String::new(); + write!(auth_value, "Signature version=\"1\",")?; + write!(auth_value, "headers=\"date (request-target) host\",")?; + write!( + auth_value, + "keyId=\"{}/{}/{}\",", + cred.tenancy, cred.user, &fp + )?; + write!(auth_value, "algorithm=\"rsa-sha256\",")?; + write!(auth_value, "signature=\"{}\"", encoded_signature)?; + ctx.headers + .insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?); + } else { + return Err(Error::msg("no fingerprint")); + } + + Ok(ctx) + } + + /// Signing request with header. + pub fn sign(&self, req: &mut impl SignableRequest, cred: &Credential) -> Result<()> { + let ctx = self.build(req, cred)?; + + req.apply(ctx) + } +} + +/// Construct string to sign. +/// +/// # Format +/// +/// ```text +/// "date: {Date}" + "\n" +/// + "(request-target): {verb} {uri}" + "\n" +/// + "host: {Host}" +/// ``` +fn string_to_sign(ctx: &mut SigningContext, now: DateTime) -> Result { + let string_to_sign = { + let mut f = String::new(); + writeln!(f, "date: {}", time::format_http_date(now))?; + writeln!( + f, + "(request-target): {} {}", + ctx.method.as_str().to_lowercase(), + ctx.path + )?; + write!(f, "host: {}", ctx.authority)?; + f + }; + + debug!("string to sign: {}", &string_to_sign); + Ok(string_to_sign) +}