Skip to content

Commit

Permalink
refactor: Replace google signer with Signing Context
Browse files Browse the repository at this point in the history
Signed-off-by: Xuanwo <github@xuanwo.io>
  • Loading branch information
Xuanwo committed Mar 29, 2023
1 parent 77b6c99 commit 9a0dce4
Show file tree
Hide file tree
Showing 3 changed files with 283 additions and 459 deletions.
1 change: 0 additions & 1 deletion src/google/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
mod constants;
mod credential;
mod signer;
mod v4;
pub use credential::Token;
pub use credential::TokenLoad;
pub use signer::Builder;
Expand Down
305 changes: 283 additions & 22 deletions src/google/signer.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
use std::borrow::Cow;

use anyhow::anyhow;
use anyhow::Result;
use http::header;
use log::debug;

use percent_encoding::percent_decode_str;
use percent_encoding::utf8_percent_encode;
use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::DecodePrivateKey;
use rsa::signature::RandomizedSigner;

use super::constants::GOOG_QUERY_ENCODE_SET;
use super::credential::Credential;
use super::credential::CredentialLoader;
use super::credential::Token;
use super::credential::TokenLoad;
use super::v4;
use crate::ctx::SigningContext;
use crate::ctx::SigningMethod;
use crate::hash::hex_sha256;
use crate::request::SignableRequest;
use crate::time;
use crate::time::format_date;
use crate::time::format_iso8601;
use crate::time::DateTime;
use crate::time::Duration;

/// Builder for Signer.
Expand All @@ -19,6 +34,10 @@ pub struct Builder {
credential_path: Option<String>,
credential_content: Option<String>,

service: Option<String>,
region: Option<String>,
time: Option<DateTime>,

allow_anonymous: bool,
disable_load_from_env: bool,
disable_load_from_well_known_location: bool,
Expand Down Expand Up @@ -87,6 +106,34 @@ impl Builder {
self
}

/// Set the service name that used for google v4 signing.
///
/// Default to `storage`
pub fn service(&mut self, service: &str) -> &mut Self {
self.service = Some(service.to_string());
self
}

/// Set the region name that used for google v4 signing.
///
/// Default to `auto`
pub fn region(&mut self, region: &str) -> &mut Self {
self.region = Some(region.to_string());
self
}

/// Specify the signing time.
///
/// # Note
///
/// We should always take current time to sign requests.
/// Only use this function for testing.
#[cfg(test)]
pub fn time(&mut self, time: DateTime) -> &mut Self {
self.time = Some(time);
self
}

/// Use exising information to build a new signer.
///
///
Expand Down Expand Up @@ -128,6 +175,12 @@ impl Builder {
Ok(Signer {
credential_loader: cred_loader,
allow_anonymous: self.allow_anonymous,
service: self
.service
.clone()
.unwrap_or_else(|| "storage".to_string()),
region: self.region.clone().unwrap_or_else(|| "auto".to_string()),
time: self.time,
})
}
}
Expand All @@ -142,6 +195,10 @@ pub struct Signer {

/// Allow anonymous request if credential is not loaded.
allow_anonymous: bool,

service: String,
region: String,
time: Option<DateTime>,
}

impl Signer {
Expand All @@ -160,6 +217,78 @@ impl Signer {
self.credential_loader.load()
}

fn credential(&self) -> Option<Credential> {
self.credential_loader.load_credential()
}

fn build(
&self,
req: &mut impl SignableRequest,
method: SigningMethod,
cred: &Credential,
token: &Token,
) -> Result<SigningContext> {
let mut ctx = req.build()?;

match method {
SigningMethod::Header => {
ctx.headers.insert(header::AUTHORIZATION, {
let mut value: http::HeaderValue =
format!("Bearer {}", token.access_token()).parse()?;
value.set_sensitive(true);

value
});
}
SigningMethod::Query(_) => {
let now = self.time.unwrap_or_else(time::now);

// canonicalize context
canonicalize_header(&mut ctx)?;
canonicalize_query(&mut ctx, method, cred, now, &self.service, &self.region)?;

// build canonical request and string to sign.
let creq = canonical_request_string(&mut ctx)?;
let encoded_req = hex_sha256(creq.as_bytes());

// Scope: "20220313/<region>/<service>/goog4_request"
let scope = format!(
"{}/{}/{}/goog4_request",
format_date(now),
self.region,
self.service
);
debug!("calculated scope: {scope}");

// StringToSign:
//
// GOOG4-RSA-SHA256
// 20220313T072004Z
// 20220313/<region>/<service>/goog4_request
// <hashed_canonical_request>
let string_to_sign = {
let mut f = String::new();
f.push_str("GOOG4-RSA-SHA256");
f.push_str(&format_iso8601(now));
f.push_str(&scope);
f.push_str(&encoded_req);
f
};
debug!("calculated string to sign: {string_to_sign}");

let mut rng = rand::thread_rng();
let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(cred.private_key())?;
let signing_key = SigningKey::<rsa::sha2::Sha256>::new_with_prefix(private_key);
let signature = signing_key.sign_with_rng(&mut rng, string_to_sign.as_bytes());

ctx.query
.push(("X-Goog-Signature".to_string(), signature.to_string()));
}
}

Ok(ctx)
}

/// Signing request.
///
/// # Example
Expand Down Expand Up @@ -196,20 +325,13 @@ impl Signer {
///
/// we can also send API via signed JWT: [Addendum: Service account authorization without OAuth](https://developers.google.com/identity/protocols/oauth2/service-account#jwt-auth)
pub fn sign(&self, req: &mut impl SignableRequest) -> Result<()> {
if let Some(token) = self.token() {
req.insert_header(header::AUTHORIZATION, {
let mut value: http::HeaderValue =
format!("Bearer {}", token.access_token()).parse()?;
value.set_sensitive(true);

value
})?;

return Ok(());
if let (Some(cred), Some(token)) = (self.credential(), self.token()) {
let ctx = self.build(req, SigningMethod::Header, &cred, &token)?;
return req.apply(ctx);
}

if self.allow_anonymous {
debug!("token not found and anonymous is allowed, skipping signing.");
debug!("credential not found and anonymous is allowed, skipping signing.");
return Ok(());
}

Expand Down Expand Up @@ -248,22 +370,128 @@ impl Signer {
/// Ok(())
/// }
/// ```
pub fn sign_query(&self, query: &mut impl SignableRequest, duration: Duration) -> Result<()> {
let credentials = self.credential_loader.load_credential().unwrap();
let v4_signer = v4::Signer::builder()
// TODO set this from outside?
.service("storage")
.region("auto")
.credential(credentials)
.build()?;
v4_signer.sign_query(query, duration)
pub fn sign_query(&self, req: &mut impl SignableRequest, duration: Duration) -> Result<()> {
if let (Some(cred), Some(token)) = (self.credential(), self.token()) {
let ctx = self.build(req, SigningMethod::Query(duration), &cred, &token)?;
return req.apply(ctx);
}

if self.allow_anonymous {
debug!("credential not found and anonymous is allowed, skipping signing.");
return Ok(());
}

Err(anyhow!("token not found"))
}
}

fn canonical_request_string(ctx: &mut SigningContext) -> Result<String> {
// 256 is specially chosen to avoid reallocation for most requests.
let mut f = String::with_capacity(256);

// Insert method
f.push_str(ctx.method.as_str());
// Insert encoded path
let path = percent_decode_str(&ctx.path).decode_utf8()?;
f.push_str(&Cow::from(utf8_percent_encode(
&path,
&super::constants::GOOG_URI_ENCODE_SET,
)));
// Insert query
f.push_str(&SigningContext::query_to_string(
ctx.query.clone(),
"=",
"&",
));

// Insert signed headers
let signed_headers = ctx.header_name_to_vec_sorted();
for header in signed_headers.iter() {
let value = &ctx.headers[*header];
f.push_str(header);
f.push(':');
f.push_str(value.to_str().expect("header value must be valid"));
}
f.push('\n');
f.push_str(&signed_headers.join(";"));
f.push_str("UNSIGNED-PAYLOAD");

Ok(f)
}

fn canonicalize_header(ctx: &mut SigningContext) -> Result<()> {
// Header names and values need to be normalized according to Step 4 of https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
for (_, value) in ctx.headers.iter_mut() {
SigningContext::header_value_normalize(value)
}

// Insert HOST header if not present.
if ctx.headers.get(header::HOST).is_none() {
ctx.headers
.insert(header::HOST, ctx.authority.as_str().parse()?);
}

Ok(())
}

fn canonicalize_query(
ctx: &mut SigningContext,
method: SigningMethod,
cred: &Credential,
now: DateTime,
service: &str,
region: &str,
) -> Result<()> {
if let SigningMethod::Query(expire) = method {
ctx.query
.push(("X-Goog-Algorithm".into(), "GOOG4-RSA-SHA256".into()));
ctx.query.push((
"X-Goog-Credentia".into(),
format!(
"{}/{}/{}/{}/aws4_request",
cred.client_email(),
format_date(now),
region,
service
),
));
ctx.query.push(("X-Goog-Date".into(), format_iso8601(now)));
ctx.query
.push(("X-Goog-Expires".into(), expire.whole_seconds().to_string()));
ctx.query.push((
"X-Goog-SignedHeaders".into(),
ctx.header_name_to_vec_sorted().join(";"),
));
}

// Return if query is empty.
if ctx.query.is_empty() {
return Ok(());
}

// Sort by param name
ctx.query.sort();

ctx.query = ctx
.query
.iter()
.map(|(k, v)| {
(
utf8_percent_encode(k, &GOOG_QUERY_ENCODE_SET).to_string(),
utf8_percent_encode(v, &GOOG_QUERY_ENCODE_SET).to_string(),
)
})
.collect();

Ok(())
}

#[cfg(test)]
mod tests {
use reqwest::blocking::Client;

use crate::time::parse_rfc2822;

use super::*;

#[derive(Debug)]
Expand Down Expand Up @@ -318,4 +546,37 @@ mod tests {

Ok(())
}

#[test]
fn test_sign_query_deterministic() -> Result<()> {
let credential_path = format!(
"{}/testdata/services/google/testbucket_credential.json",
std::env::current_dir()
.expect("current_dir must exist")
.to_string_lossy()
);

let mut req = http::Request::new("");
*req.method_mut() = http::Method::GET;
*req.uri_mut() = "https://storage.googleapis.com/testbucket-reqsign/CONTRIBUTING.md"
.parse()
.expect("url must be valid");

let time_offset =
parse_rfc2822("Mon, 15 Aug 2022 16:50:12 GMT")?.to_offset(::time::UtcOffset::UTC);

let signer = Signer::builder()
.credential_path(&credential_path)
.scope("storage")
.time(time_offset)
.build()?;

signer.sign_query(&mut req, time::Duration::hours(1))?;

let query = req.query().unwrap();
assert!(query.contains("X-Goog-Algorithm=GOOG4-RSA-SHA256"));
assert!(query.contains("X-Goog-Credential"));
assert!(query == "X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=testbucket-reqsign-account%40iam-testbucket-reqsign-project.iam.gserviceaccount.com%2F20220815%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20220815T165012Z&X-Goog-Expires=3600&X-Goog-SignedHeaders=host&X-Goog-Signature=9F423139DB223D818F2D4D6BCA4916DD1EE5AEB8E72D99EC60E8B903DC3CF0586C27A0F821C8CB20C6BB76C776E63134DAFF5957E7862BB89926F18E0D3618E4EE40EF8DBEC64D87F5AD4CAF6FE4C2BC3239E1076A33BE3113D6E0D1AF263C16FA5E1C9590C8F8E4E2CA2FED11533607B5AFE84B53E2E00CB320E0BC853C138EBBDCFEC3E9219C73551478EE12AABBD2576686F887738A21DC5AE00DFF3D481BD08F642342C8CCB476E74C8FEA0C02BA6FEFD61300218D6E216EAD4B59F3351E456601DF38D1CC1B4CE639D2748739933672A08B5FEBBED01B5BC0785E81A865EE0252A0C5AE239061F3F5DB4AFD8CC676646750C762A277FBFDE70A85DFDF33");
Ok(())
}
}
Loading

1 comment on commit 9a0dce4

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy preview for reqsign ready!

✅ Preview
https://reqsign-ccq8fnybb-xuanwo.vercel.app

Built with commit 9a0dce4.
This pull request is being automatically deployed with vercel-action

Please sign in to comment.