1use std::borrow::Cow;
2use std::time::Duration;
3
4use anyhow::Result;
5use http::header;
6use log::debug;
7use percent_encoding::percent_decode_str;
8use percent_encoding::utf8_percent_encode;
9use rsa::pkcs1v15::SigningKey;
10use rsa::pkcs8::DecodePrivateKey;
11use rsa::signature::RandomizedSigner;
12
13use super::constants::GOOG_QUERY_ENCODE_SET;
14use super::credential::Credential;
15use super::credential::ServiceAccount;
16use super::token::Token;
17use crate::ctx::SigningContext;
18use crate::ctx::SigningMethod;
19use crate::hash::hex_sha256;
20use crate::request::SignableRequest;
21use crate::time;
22use crate::time::format_date;
23use crate::time::format_iso8601;
24use crate::time::DateTime;
25
26pub struct Signer {
32 service: String,
33 region: String,
34 time: Option<DateTime>,
35}
36
37impl Signer {
38 pub fn new(service: &str) -> Self {
40 Self {
41 service: service.to_string(),
42 region: "auto".to_string(),
43 time: None,
44 }
45 }
46
47 pub fn region(&mut self, region: &str) -> &mut Self {
51 self.region = region.to_string();
52 self
53 }
54
55 #[cfg(test)]
62 pub fn time(mut self, time: DateTime) -> Self {
63 self.time = Some(time);
64 self
65 }
66
67 fn build_header(
68 &self,
69 req: &mut impl SignableRequest,
70 token: &Token,
71 ) -> Result<SigningContext> {
72 let mut ctx = req.build()?;
73
74 ctx.headers.insert(header::AUTHORIZATION, {
75 let mut value: http::HeaderValue =
76 format!("Bearer {}", token.access_token()).parse()?;
77 value.set_sensitive(true);
78
79 value
80 });
81
82 Ok(ctx)
83 }
84
85 fn build_query(
86 &self,
87 req: &mut impl SignableRequest,
88 expire: Duration,
89 cred: &ServiceAccount,
90 ) -> Result<SigningContext> {
91 let mut ctx = req.build()?;
92
93 let now = self.time.unwrap_or_else(time::now);
94
95 canonicalize_header(&mut ctx)?;
97 canonicalize_query(
98 &mut ctx,
99 SigningMethod::Query(expire),
100 cred,
101 now,
102 &self.service,
103 &self.region,
104 )?;
105
106 let creq = canonical_request_string(&mut ctx)?;
108 let encoded_req = hex_sha256(creq.as_bytes());
109
110 let scope = format!(
112 "{}/{}/{}/goog4_request",
113 format_date(now),
114 self.region,
115 self.service
116 );
117 debug!("calculated scope: {scope}");
118
119 let string_to_sign = {
126 let mut f = String::new();
127 f.push_str("GOOG4-RSA-SHA256");
128 f.push('\n');
129 f.push_str(&format_iso8601(now));
130 f.push('\n');
131 f.push_str(&scope);
132 f.push('\n');
133 f.push_str(&encoded_req);
134
135 f
136 };
137 debug!("calculated string to sign: {string_to_sign}");
138
139 let mut rng = rand::thread_rng();
140 let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(&cred.private_key)?;
141 let signing_key = SigningKey::<sha2::Sha256>::new(private_key);
142 let signature = signing_key.sign_with_rng(&mut rng, string_to_sign.as_bytes());
143
144 ctx.query
145 .push(("X-Goog-Signature".to_string(), signature.to_string()));
146
147 Ok(ctx)
148 }
149
150 pub fn sign(&self, req: &mut impl SignableRequest, token: &Token) -> Result<()> {
190 let ctx = self.build_header(req, token)?;
191 req.apply(ctx)
192 }
193
194 pub fn sign_query(
229 &self,
230 req: &mut impl SignableRequest,
231 duration: Duration,
232 cred: &Credential,
233 ) -> Result<()> {
234 let Some(cred) = &cred.service_account else {
235 anyhow::bail!("expected service account credential, got external account");
236 };
237
238 let ctx = self.build_query(req, duration, cred)?;
239 req.apply(ctx)
240 }
241}
242
243fn canonical_request_string(ctx: &mut SigningContext) -> Result<String> {
244 let mut f = String::with_capacity(256);
246
247 f.push_str(ctx.method.as_str());
249 f.push('\n');
250
251 let path = percent_decode_str(&ctx.path).decode_utf8()?;
253 f.push_str(&Cow::from(utf8_percent_encode(
254 &path,
255 &super::constants::GOOG_URI_ENCODE_SET,
256 )));
257 f.push('\n');
258
259 f.push_str(&SigningContext::query_to_string(
261 ctx.query.clone(),
262 "=",
263 "&",
264 ));
265 f.push('\n');
266
267 let signed_headers = ctx.header_name_to_vec_sorted();
269 for header in signed_headers.iter() {
270 let value = &ctx.headers[*header];
271 f.push_str(header);
272 f.push(':');
273 f.push_str(value.to_str().expect("header value must be valid"));
274 f.push('\n');
275 }
276 f.push('\n');
277 f.push_str(&signed_headers.join(";"));
278 f.push('\n');
279 f.push_str("UNSIGNED-PAYLOAD");
280
281 debug!("string to sign: {f}");
282 Ok(f)
283}
284
285fn canonicalize_header(ctx: &mut SigningContext) -> Result<()> {
286 for (_, value) in ctx.headers.iter_mut() {
287 SigningContext::header_value_normalize(value)
288 }
289
290 if ctx.headers.get(header::HOST).is_none() {
292 ctx.headers
293 .insert(header::HOST, ctx.authority.as_str().parse()?);
294 }
295
296 Ok(())
297}
298
299fn canonicalize_query(
300 ctx: &mut SigningContext,
301 method: SigningMethod,
302 cred: &ServiceAccount,
303 now: DateTime,
304 service: &str,
305 region: &str,
306) -> Result<()> {
307 if let SigningMethod::Query(expire) = method {
308 ctx.query
309 .push(("X-Goog-Algorithm".into(), "GOOG4-RSA-SHA256".into()));
310 ctx.query.push((
311 "X-Goog-Credential".into(),
312 format!(
313 "{}/{}/{}/{}/goog4_request",
314 &cred.client_email,
315 format_date(now),
316 region,
317 service
318 ),
319 ));
320 ctx.query.push(("X-Goog-Date".into(), format_iso8601(now)));
321 ctx.query
322 .push(("X-Goog-Expires".into(), expire.as_secs().to_string()));
323 ctx.query.push((
324 "X-Goog-SignedHeaders".into(),
325 ctx.header_name_to_vec_sorted().join(";"),
326 ));
327 }
328
329 if ctx.query.is_empty() {
331 return Ok(());
332 }
333
334 ctx.query.sort();
336
337 ctx.query = ctx
338 .query
339 .iter()
340 .map(|(k, v)| {
341 (
342 utf8_percent_encode(k, &GOOG_QUERY_ENCODE_SET).to_string(),
343 utf8_percent_encode(v, &GOOG_QUERY_ENCODE_SET).to_string(),
344 )
345 })
346 .collect();
347
348 Ok(())
349}
350
351#[cfg(test)]
352mod tests {
353 use chrono::Utc;
354 use pretty_assertions::assert_eq;
355
356 use super::super::credential::CredentialLoader;
357 use super::*;
358
359 #[tokio::test]
360 async fn test_sign_query() -> Result<()> {
361 let credential_path = format!(
362 "{}/testdata/services/google/testbucket_credential.json",
363 std::env::current_dir()
364 .expect("current_dir must exist")
365 .to_string_lossy()
366 );
367
368 let loader = CredentialLoader::default().with_path(&credential_path);
369 let cred = loader.load()?.unwrap();
370
371 let signer = Signer::new("storage");
372
373 let mut req = http::Request::new("");
374 *req.method_mut() = http::Method::GET;
375 *req.uri_mut() = "https://storage.googleapis.com/testbucket-reqsign/CONTRIBUTING.md"
376 .parse()
377 .expect("url must be valid");
378
379 signer.sign_query(&mut req, Duration::from_secs(3600), &cred)?;
380
381 let query = req.uri().query().unwrap();
382 assert!(query.contains("X-Goog-Algorithm=GOOG4-RSA-SHA256"));
383 assert!(query.contains("X-Goog-Credential"));
384
385 Ok(())
386 }
387
388 #[tokio::test]
389 async fn test_sign_query_deterministic() -> Result<()> {
390 let credential_path = format!(
391 "{}/testdata/services/google/testbucket_credential.json",
392 std::env::current_dir()
393 .expect("current_dir must exist")
394 .to_string_lossy()
395 );
396
397 let loader = CredentialLoader::default().with_path(&credential_path);
398 let cred = loader.load()?.unwrap();
399
400 let mut req = http::Request::new("");
401 *req.method_mut() = http::Method::GET;
402 *req.uri_mut() = "https://storage.googleapis.com/testbucket-reqsign/CONTRIBUTING.md"
403 .parse()
404 .expect("url must be valid");
405
406 let time_offset = chrono::DateTime::parse_from_rfc2822("Mon, 15 Aug 2022 16:50:12 GMT")
407 .unwrap()
408 .with_timezone(&Utc);
409
410 let signer = Signer::new("storage").time(time_offset);
411
412 signer.sign_query(&mut req, Duration::from_secs(3600), &cred)?;
413
414 let query = req.uri().query().unwrap();
415 assert!(query.contains("X-Goog-Algorithm=GOOG4-RSA-SHA256"));
416 assert!(query.contains("X-Goog-Credential"));
417 assert_eq!(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");
418 Ok(())
419 }
420}