This is an automated email from the ASF dual-hosted git repository.
xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
The following commit(s) were added to refs/heads/main by this push:
new f2eb9ea feat(google): Add sign blob support (#671)
f2eb9ea is described below
commit f2eb9ea8894566437b1f37f9c6a9f738155c2e29
Author: Xuanwo <[email protected]>
AuthorDate: Fri Dec 26 19:15:21 2025 +0800
feat(google): Add sign blob support (#671)
This PR will add sign blob support
---
**Parts of this PR were drafted with assistance from Codex (with
`gpt-5.2`) and fully reviewed and edited by me. I take full
responsibility for all changes.**
---
services/google/Cargo.toml | 1 +
services/google/src/sign_request.rs | 344 ++++++++++++++++++++++++++++++++----
2 files changed, 313 insertions(+), 32 deletions(-)
diff --git a/services/google/Cargo.toml b/services/google/Cargo.toml
index 83068bf..76e1db8 100644
--- a/services/google/Cargo.toml
+++ b/services/google/Cargo.toml
@@ -40,6 +40,7 @@ serde_json = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
+bytes = { workspace = true }
dotenv = { workspace = true }
env_logger = { workspace = true }
reqsign-file-read-tokio = { workspace = true }
diff --git a/services/google/src/sign_request.rs
b/services/google/src/sign_request.rs
index 34087f9..a5367d5 100644
--- a/services/google/src/sign_request.rs
+++ b/services/google/src/sign_request.rs
@@ -73,6 +73,7 @@ pub struct RequestSigner {
service: String,
region: String,
scope: Option<String>,
+ signer_email: Option<String>,
}
impl Default for RequestSigner {
@@ -81,6 +82,7 @@ impl Default for RequestSigner {
service: String::new(),
region: "auto".to_string(),
scope: None,
+ signer_email: None,
}
}
}
@@ -92,6 +94,7 @@ impl RequestSigner {
service: service.into(),
region: "auto".to_string(),
scope: None,
+ signer_email: None,
}
}
@@ -101,6 +104,15 @@ impl RequestSigner {
self
}
+ /// Set the signer service account email used for query signing via
IAMCredentials `signBlob`.
+ ///
+ /// This is required when generating signed URLs without an embedded
service account private key
+ /// (e.g. ADC / WIF / impersonation tokens).
+ pub fn with_signer_email(mut self, signer_email: impl Into<String>) ->
Self {
+ self.signer_email = Some(signer_email.into());
+ self
+ }
+
/// Set the region for the builder.
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.region = region.into();
@@ -185,34 +197,27 @@ impl RequestSigner {
Ok(req)
}
- fn build_signed_query(
+ fn build_string_to_sign(
&self,
- _ctx: &Context,
- parts: &mut http::request::Parts,
- service_account: &ServiceAccount,
+ req: &mut SigningRequest,
+ client_email: &str,
+ now: Timestamp,
expires_in: Duration,
- ) -> Result<SigningRequest> {
- let mut req = SigningRequest::build(parts)?;
- let now = Timestamp::now();
-
- // Canonicalize headers
- canonicalize_header(&mut req)?;
+ ) -> Result<String> {
+ canonicalize_header(req)?;
- // Canonicalize query
canonicalize_query(
- &mut req,
+ req,
SigningMethod::Query(expires_in),
- service_account,
+ client_email,
now,
&self.service,
&self.region,
)?;
- // Build canonical request string
- let creq = canonical_request_string(&mut req)?;
+ let creq = canonical_request_string(req)?;
let encoded_req = hex_sha256(creq.as_bytes());
- // Build scope
let scope = format!(
"{}/{}/{}/goog4_request",
now.format_date(),
@@ -221,7 +226,6 @@ impl RequestSigner {
);
debug!("calculated scope: {scope}");
- // Build string to sign
let string_to_sign = {
let mut f = String::new();
f.push_str("GOOG4-RSA-SHA256");
@@ -235,17 +239,121 @@ impl RequestSigner {
};
debug!("calculated string to sign: {string_to_sign}");
- // Sign the string
+ Ok(string_to_sign)
+ }
+
+ fn sign_with_service_account(private_key_pem: &str, string_to_sign: &str)
-> Result<String> {
let mut rng = thread_rng();
- let private_key =
rsa::RsaPrivateKey::from_pkcs8_pem(&service_account.private_key)
- .map_err(|e| {
- reqsign_core::Error::unexpected("failed to parse private
key").with_source(e)
- })?;
+ let private_key =
rsa::RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(|e| {
+ reqsign_core::Error::unexpected("failed to parse private
key").with_source(e)
+ })?;
let signing_key = SigningKey::<sha2::Sha256>::new(private_key);
let signature = signing_key.sign_with_rng(&mut rng,
string_to_sign.as_bytes());
- req.query
- .push(("X-Goog-Signature".to_string(), signature.to_string()));
+ Ok(signature.to_string())
+ }
+
+ fn build_signed_query_with_service_account(
+ &self,
+ parts: &mut http::request::Parts,
+ service_account: &ServiceAccount,
+ expires_in: Duration,
+ ) -> Result<SigningRequest> {
+ let mut req = SigningRequest::build(parts)?;
+ let now = Timestamp::now();
+
+ let string_to_sign =
+ self.build_string_to_sign(&mut req, &service_account.client_email,
now, expires_in)?;
+ let signature =
+ Self::sign_with_service_account(&service_account.private_key,
&string_to_sign)?;
+
+ req.query.push(("X-Goog-Signature".to_string(), signature));
+
+ Ok(req)
+ }
+
+ async fn sign_via_iamcredentials(
+ &self,
+ ctx: &Context,
+ token: &Token,
+ signer_email: &str,
+ payload: &[u8],
+ ) -> Result<String> {
+ #[derive(Serialize)]
+ struct SignBlobRequest<'a> {
+ payload: &'a str,
+ }
+
+ #[derive(Deserialize)]
+ #[serde(rename_all = "camelCase")]
+ struct SignBlobResponse {
+ signed_blob: String,
+ }
+
+ let payload_b64 = reqsign_core::hash::base64_encode(payload);
+ let body = serde_json::to_vec(&SignBlobRequest {
+ payload: &payload_b64,
+ })
+ .map_err(|e| {
+ reqsign_core::Error::unexpected("failed to encode signBlob
request").with_source(e)
+ })?;
+
+ let req = http::Request::builder()
+ .method(http::Method::POST)
+ .uri(format!(
+
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{signer_email}:signBlob"
+ ))
+ .header(header::CONTENT_TYPE, "application/json")
+ .header(header::AUTHORIZATION, {
+ let mut value: http::HeaderValue = format!("Bearer {}",
&token.access_token)
+ .parse()
+ .map_err(|e| {
+ reqsign_core::Error::unexpected("failed to parse
header value")
+ .with_source(e)
+ })?;
+ value.set_sensitive(true);
+ value
+ })
+ .body(body.into())
+ .map_err(|e| {
+ reqsign_core::Error::unexpected("failed to build HTTP
request").with_source(e)
+ })?;
+
+ let resp = ctx.http_send(req).await?;
+
+ if resp.status() != http::StatusCode::OK {
+ let body = String::from_utf8_lossy(resp.body());
+ return Err(reqsign_core::Error::unexpected(format!(
+ "iamcredentials signBlob failed: {body}"
+ )));
+ }
+
+ let sign_resp: SignBlobResponse =
serde_json::from_slice(resp.body()).map_err(|e| {
+ reqsign_core::Error::unexpected("failed to parse signBlob
response").with_source(e)
+ })?;
+
+ let signed =
reqsign_core::hash::base64_decode(&sign_resp.signed_blob)?;
+
+ Ok(hex_encode_upper(&signed))
+ }
+
+ async fn build_signed_query_via_iamcredentials(
+ &self,
+ ctx: &Context,
+ parts: &mut http::request::Parts,
+ token: &Token,
+ signer_email: &str,
+ expires_in: Duration,
+ ) -> Result<SigningRequest> {
+ let mut req = SigningRequest::build(parts)?;
+ let now = Timestamp::now();
+
+ let string_to_sign = self.build_string_to_sign(&mut req, signer_email,
now, expires_in)?;
+ let signature = self
+ .sign_via_iamcredentials(ctx, token, signer_email,
string_to_sign.as_bytes())
+ .await?;
+
+ req.query.push(("X-Goog-Signature".to_string(), signature));
Ok(req)
}
@@ -267,14 +375,32 @@ impl SignRequest for RequestSigner {
};
let signing_req = match expires_in {
- // Query signing - must use ServiceAccount
+ // Query signing - prefer ServiceAccount, otherwise use
IAMCredentials signBlob if possible.
Some(expires) => {
- let sa = cred.service_account.as_ref().ok_or_else(|| {
- reqsign_core::Error::credential_invalid(
- "service account required for query signing",
+ if let Some(sa) = cred.service_account.as_ref() {
+ self.build_signed_query_with_service_account(req, sa,
expires)?
+ } else if let (Some(token), Some(signer_email)) =
+ (cred.token.as_ref(), self.signer_email.as_deref())
+ {
+ if !token.is_valid() {
+ return Err(reqsign_core::Error::credential_invalid(
+ "token required for iamcredentials signBlob query
signing",
+ ));
+ }
+
+ self.build_signed_query_via_iamcredentials(
+ ctx,
+ req,
+ token,
+ signer_email,
+ expires,
)
- })?;
- self.build_signed_query(ctx, req, sa, expires)?
+ .await?
+ } else {
+ return Err(reqsign_core::Error::credential_invalid(
+ "service account or token + signer_email required for
query signing",
+ ));
+ }
}
// Header authentication - prefer valid token, otherwise exchange
from SA
None => {
@@ -311,6 +437,16 @@ impl SignRequest for RequestSigner {
}
}
+fn hex_encode_upper(bytes: &[u8]) -> String {
+ use std::fmt::Write;
+
+ let mut out = String::with_capacity(bytes.len() * 2);
+ for b in bytes {
+ write!(&mut out, "{:02X}", b).expect("writing to string must succeed");
+ }
+ out
+}
+
fn canonical_request_string(req: &mut SigningRequest) -> Result<String> {
// 256 is specially chosen to avoid reallocation for most requests.
let mut f = String::with_capacity(256);
@@ -373,7 +509,7 @@ fn canonicalize_header(req: &mut SigningRequest) ->
Result<()> {
fn canonicalize_query(
req: &mut SigningRequest,
method: SigningMethod,
- cred: &ServiceAccount,
+ client_email: &str,
now: Timestamp,
service: &str,
region: &str,
@@ -385,7 +521,7 @@ fn canonicalize_query(
"X-Goog-Credential".into(),
format!(
"{}/{}/{}/{}/goog4_request",
- &cred.client_email,
+ client_email,
now.format_date(),
region,
service
@@ -421,3 +557,147 @@ fn canonicalize_query(
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use async_trait::async_trait;
+ use bytes::Bytes;
+ use http::header;
+ use reqsign_core::HttpSend;
+ use std::sync::{Arc, Mutex};
+
+ #[derive(Debug, Default)]
+ struct Recorded {
+ payload_b64: Option<String>,
+ }
+
+ #[derive(Clone, Debug, Default)]
+ struct MockHttpSend {
+ recorded: Arc<Mutex<Recorded>>,
+ }
+
+ #[async_trait]
+ impl HttpSend for MockHttpSend {
+ async fn http_send(&self, req: http::Request<Bytes>) ->
Result<http::Response<Bytes>> {
+ assert_eq!(req.method(), http::Method::POST);
+ assert_eq!(
+ req.uri().to_string(),
+
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:signBlob"
+ );
+ assert_eq!(
+ req.headers()
+ .get(header::CONTENT_TYPE)
+ .expect("content-type must exist")
+ .to_str()
+ .expect("content-type must be valid string"),
+ "application/json"
+ );
+ assert_eq!(
+ req.headers()
+ .get(header::AUTHORIZATION)
+ .expect("authorization must exist")
+ .to_str()
+ .expect("authorization must be valid string"),
+ "Bearer test-access-token"
+ );
+
+ let value: serde_json::Value =
+ serde_json::from_slice(req.body()).expect("body must be valid
json");
+ let payload_b64 = value
+ .get("payload")
+ .and_then(|v| v.as_str())
+ .expect("payload must exist")
+ .to_string();
+
+ self.recorded.lock().unwrap().payload_b64 = Some(payload_b64);
+
+ // base64([0x01, 0x02, 0x03]) -> hex signature "010203"
+ let body = br#"{"signedBlob":"AQID"}"#;
+ Ok(http::Response::builder()
+ .status(http::StatusCode::OK)
+ .body(body.as_slice().into())
+ .expect("response must build"))
+ }
+ }
+
+ fn query_get<'a>(query: &'a str, key: &str) -> Option<&'a str> {
+ query.split('&').find_map(|kv| {
+ let (k, v) = kv.split_once('=')?;
+ if k == key { Some(v) } else { None }
+ })
+ }
+
+ fn parse_goog_date_to_timestamp(v: &str) -> Timestamp {
+ let year = &v[0..4];
+ let month = &v[4..6];
+ let day = &v[6..8];
+ let hour = &v[9..11];
+ let minute = &v[11..13];
+ let second = &v[13..15];
+ let rfc3339 =
format!("{year}-{month}-{day}T{hour}:{minute}:{second}Z");
+ rfc3339.parse().expect("date must parse")
+ }
+
+ #[tokio::test]
+ async fn test_signed_url_via_iamcredentials_sign_blob() -> Result<()> {
+ let mock_http = MockHttpSend::default();
+ let ctx = Context::new().with_http_send(mock_http.clone());
+
+ let signer =
RequestSigner::new("storage").with_signer_email("[email protected]");
+
+ let cred = Credential::with_token(Token {
+ access_token: "test-access-token".to_string(),
+ expires_at: None,
+ });
+
+ let expires_in = Duration::from_secs(60);
+
+ let mut builder = http::Request::builder();
+ builder = builder.method(http::Method::GET);
+ builder =
builder.uri("https://storage.googleapis.com/test-bucket/test-object");
+ let req = builder.body(Bytes::new()).expect("request must build");
+ let (mut parts, _body) = req.into_parts();
+
+ signer
+ .sign_request(&ctx, &mut parts, Some(&cred), Some(expires_in))
+ .await?;
+
+ let query = parts.uri.query().expect("signed url must have query");
+ assert_eq!(
+ query_get(query, "X-Goog-Signature").expect("signature must
exist"),
+ "010203"
+ );
+
+ let goog_date = query_get(query, "X-Goog-Date").expect("date must
exist");
+ let now = parse_goog_date_to_timestamp(goog_date);
+
+ let mut builder = http::Request::builder();
+ builder = builder.method(http::Method::GET);
+ builder =
builder.uri("https://storage.googleapis.com/test-bucket/test-object");
+ let req = builder.body(Bytes::new()).expect("request must build");
+ let (mut parts_for_rebuild, _body) = req.into_parts();
+
+ let mut signing_req = SigningRequest::build(&mut parts_for_rebuild)?;
+ let string_to_sign = signer.build_string_to_sign(
+ &mut signing_req,
+ "[email protected]",
+ now,
+ expires_in,
+ )?;
+ let expected_payload_b64 =
reqsign_core::hash::base64_encode(string_to_sign.as_bytes());
+
+ let recorded_payload_b64 = mock_http
+ .recorded
+ .lock()
+ .unwrap()
+ .payload_b64
+ .clone()
+ .expect("payload must be recorded");
+
+ assert_eq!(recorded_payload_b64, expected_payload_b64);
+
+ Ok(())
+ }
+}