1use http::header;
19use jsonwebtoken::{Algorithm, EncodingKey, Header as JwtHeader};
20use log::debug;
21use percent_encoding::{percent_decode_str, utf8_percent_encode};
22use rsa::pkcs1v15::SigningKey;
23use rsa::pkcs8::DecodePrivateKey;
24use rsa::rand_core::OsRng;
25use rsa::signature::RandomizedSigner;
26use serde::{Deserialize, Serialize};
27use std::borrow::Cow;
28use std::time::Duration;
29
30use reqsign_core::{
31 Context, Result, SignRequest, SigningCredential, SigningMethod, SigningRequest,
32 hash::hex_sha256, time::*,
33};
34
35use crate::constants::{DEFAULT_SCOPE, GOOG_QUERY_ENCODE_SET, GOOG_URI_ENCODE_SET, GOOGLE_SCOPE};
36use crate::credential::{Credential, ServiceAccount, Token};
37
38#[derive(Debug, Serialize)]
40struct Claims {
41 iss: String,
42 scope: String,
43 aud: String,
44 exp: u64,
45 iat: u64,
46}
47
48impl Claims {
49 fn new(client_email: &str, scope: &str) -> Self {
50 let current = Timestamp::now().as_second() as u64;
51
52 Claims {
53 iss: client_email.to_string(),
54 scope: scope.to_string(),
55 aud: "https://oauth2.googleapis.com/token".to_string(),
56 exp: current + 3600,
57 iat: current,
58 }
59 }
60}
61
62#[derive(Deserialize)]
64struct TokenResponse {
65 access_token: String,
66 #[serde(default)]
67 expires_in: Option<u64>,
68}
69
70#[derive(Debug)]
72pub struct RequestSigner {
73 service: String,
74 region: String,
75 scope: Option<String>,
76 signer_email: Option<String>,
77}
78
79impl Default for RequestSigner {
80 fn default() -> Self {
81 Self {
82 service: String::new(),
83 region: "auto".to_string(),
84 scope: None,
85 signer_email: None,
86 }
87 }
88}
89
90impl RequestSigner {
91 pub fn new(service: impl Into<String>) -> Self {
93 Self {
94 service: service.into(),
95 region: "auto".to_string(),
96 scope: None,
97 signer_email: None,
98 }
99 }
100
101 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
103 self.scope = Some(scope.into());
104 self
105 }
106
107 pub fn with_signer_email(mut self, signer_email: impl Into<String>) -> Self {
112 self.signer_email = Some(signer_email.into());
113 self
114 }
115
116 pub fn with_region(mut self, region: impl Into<String>) -> Self {
118 self.region = region.into();
119 self
120 }
121
122 async fn exchange_token(&self, ctx: &Context, sa: &ServiceAccount) -> Result<Token> {
127 let scope = self
128 .scope
129 .clone()
130 .or_else(|| ctx.env_var(GOOGLE_SCOPE))
131 .unwrap_or_else(|| DEFAULT_SCOPE.to_string());
132
133 debug!("exchanging service account for token with scope: {scope}");
134
135 let jwt = jsonwebtoken::encode(
137 &JwtHeader::new(Algorithm::RS256),
138 &Claims::new(&sa.client_email, &scope),
139 &EncodingKey::from_rsa_pem(sa.private_key.as_bytes()).map_err(|e| {
140 reqsign_core::Error::unexpected("failed to parse RSA private key").with_source(e)
141 })?,
142 )
143 .map_err(|e| reqsign_core::Error::unexpected("failed to encode JWT").with_source(e))?;
144
145 let body =
147 format!("grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion={jwt}");
148 let req = http::Request::builder()
149 .method(http::Method::POST)
150 .uri("https://oauth2.googleapis.com/token")
151 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
152 .body(body.into_bytes().into())
153 .map_err(|e| {
154 reqsign_core::Error::unexpected("failed to build HTTP request").with_source(e)
155 })?;
156
157 let resp = ctx.http_send(req).await?;
158
159 if resp.status() != http::StatusCode::OK {
160 let body = String::from_utf8_lossy(resp.body());
161 return Err(reqsign_core::Error::unexpected(format!(
162 "exchange token failed: {body}"
163 )));
164 }
165
166 let token_resp: TokenResponse = serde_json::from_slice(resp.body()).map_err(|e| {
167 reqsign_core::Error::unexpected("failed to parse token response").with_source(e)
168 })?;
169
170 let expires_at = token_resp
171 .expires_in
172 .map(|expires_in| Timestamp::now() + Duration::from_secs(expires_in));
173
174 Ok(Token {
175 access_token: token_resp.access_token,
176 expires_at,
177 })
178 }
179
180 fn build_token_auth(
181 &self,
182 parts: &mut http::request::Parts,
183 token: &Token,
184 ) -> Result<SigningRequest> {
185 let mut req = SigningRequest::build(parts)?;
186
187 req.headers.insert(header::AUTHORIZATION, {
188 let mut value: http::HeaderValue = format!("Bearer {}", &token.access_token)
189 .parse()
190 .map_err(|e| {
191 reqsign_core::Error::unexpected("failed to parse header value").with_source(e)
192 })?;
193 value.set_sensitive(true);
194 value
195 });
196
197 Ok(req)
198 }
199
200 fn build_string_to_sign(
201 &self,
202 req: &mut SigningRequest,
203 client_email: &str,
204 now: Timestamp,
205 expires_in: Duration,
206 ) -> Result<String> {
207 canonicalize_header(req)?;
208
209 canonicalize_query(
210 req,
211 SigningMethod::Query(expires_in),
212 client_email,
213 now,
214 &self.service,
215 &self.region,
216 )?;
217
218 let creq = canonical_request_string(req)?;
219 let encoded_req = hex_sha256(creq.as_bytes());
220
221 let scope = format!(
222 "{}/{}/{}/goog4_request",
223 now.format_date(),
224 self.region,
225 self.service
226 );
227 debug!("calculated scope: {scope}");
228
229 let string_to_sign = {
230 let mut f = String::new();
231 f.push_str("GOOG4-RSA-SHA256");
232 f.push('\n');
233 f.push_str(&now.format_iso8601());
234 f.push('\n');
235 f.push_str(&scope);
236 f.push('\n');
237 f.push_str(&encoded_req);
238 f
239 };
240 debug!("calculated string to sign: {string_to_sign}");
241
242 Ok(string_to_sign)
243 }
244
245 fn sign_with_service_account(private_key_pem: &str, string_to_sign: &str) -> Result<String> {
246 let mut rng = OsRng;
247 let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(|e| {
248 reqsign_core::Error::unexpected("failed to parse private key").with_source(e)
249 })?;
250 let signing_key = SigningKey::<sha2::Sha256>::new(private_key);
251 let signature = signing_key.sign_with_rng(&mut rng, string_to_sign.as_bytes());
252
253 Ok(signature.to_string())
254 }
255
256 fn build_signed_query_with_service_account(
257 &self,
258 parts: &mut http::request::Parts,
259 service_account: &ServiceAccount,
260 expires_in: Duration,
261 ) -> Result<SigningRequest> {
262 let mut req = SigningRequest::build(parts)?;
263 let now = Timestamp::now();
264
265 let string_to_sign =
266 self.build_string_to_sign(&mut req, &service_account.client_email, now, expires_in)?;
267 let signature =
268 Self::sign_with_service_account(&service_account.private_key, &string_to_sign)?;
269
270 req.query.push(("X-Goog-Signature".to_string(), signature));
271
272 Ok(req)
273 }
274
275 async fn sign_via_iamcredentials(
276 &self,
277 ctx: &Context,
278 token: &Token,
279 signer_email: &str,
280 payload: &[u8],
281 ) -> Result<String> {
282 #[derive(Serialize)]
283 struct SignBlobRequest<'a> {
284 payload: &'a str,
285 }
286
287 #[derive(Deserialize)]
288 #[serde(rename_all = "camelCase")]
289 struct SignBlobResponse {
290 signed_blob: String,
291 }
292
293 let payload_b64 = reqsign_core::hash::base64_encode(payload);
294 let body = serde_json::to_vec(&SignBlobRequest {
295 payload: &payload_b64,
296 })
297 .map_err(|e| {
298 reqsign_core::Error::unexpected("failed to encode signBlob request").with_source(e)
299 })?;
300
301 let req = http::Request::builder()
302 .method(http::Method::POST)
303 .uri(format!(
304 "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{signer_email}:signBlob"
305 ))
306 .header(header::CONTENT_TYPE, "application/json")
307 .header(header::AUTHORIZATION, {
308 let mut value: http::HeaderValue = format!("Bearer {}", &token.access_token)
309 .parse()
310 .map_err(|e| {
311 reqsign_core::Error::unexpected("failed to parse header value")
312 .with_source(e)
313 })?;
314 value.set_sensitive(true);
315 value
316 })
317 .body(body.into())
318 .map_err(|e| {
319 reqsign_core::Error::unexpected("failed to build HTTP request").with_source(e)
320 })?;
321
322 let resp = ctx.http_send(req).await?;
323
324 if resp.status() != http::StatusCode::OK {
325 let body = String::from_utf8_lossy(resp.body());
326 return Err(reqsign_core::Error::unexpected(format!(
327 "iamcredentials signBlob failed: {body}"
328 )));
329 }
330
331 let sign_resp: SignBlobResponse = serde_json::from_slice(resp.body()).map_err(|e| {
332 reqsign_core::Error::unexpected("failed to parse signBlob response").with_source(e)
333 })?;
334
335 let signed = reqsign_core::hash::base64_decode(&sign_resp.signed_blob)?;
336
337 Ok(hex_encode_upper(&signed))
338 }
339
340 async fn build_signed_query_via_iamcredentials(
341 &self,
342 ctx: &Context,
343 parts: &mut http::request::Parts,
344 token: &Token,
345 signer_email: &str,
346 expires_in: Duration,
347 ) -> Result<SigningRequest> {
348 let mut req = SigningRequest::build(parts)?;
349 let now = Timestamp::now();
350
351 let string_to_sign = self.build_string_to_sign(&mut req, signer_email, now, expires_in)?;
352 let signature = self
353 .sign_via_iamcredentials(ctx, token, signer_email, string_to_sign.as_bytes())
354 .await?;
355
356 req.query.push(("X-Goog-Signature".to_string(), signature));
357
358 Ok(req)
359 }
360}
361impl SignRequest for RequestSigner {
362 type Credential = Credential;
363
364 async fn sign_request(
365 &self,
366 ctx: &Context,
367 req: &mut http::request::Parts,
368 credential: Option<&Self::Credential>,
369 expires_in: Option<Duration>,
370 ) -> Result<()> {
371 let Some(cred) = credential else {
372 return Ok(());
373 };
374
375 let signing_req = match expires_in {
376 Some(expires) => {
378 if let Some(sa) = cred.service_account.as_ref() {
379 self.build_signed_query_with_service_account(req, sa, expires)?
380 } else if let (Some(token), Some(signer_email)) =
381 (cred.token.as_ref(), self.signer_email.as_deref())
382 {
383 if !token.is_valid() {
384 return Err(reqsign_core::Error::credential_invalid(
385 "token required for iamcredentials signBlob query signing",
386 ));
387 }
388
389 self.build_signed_query_via_iamcredentials(
390 ctx,
391 req,
392 token,
393 signer_email,
394 expires,
395 )
396 .await?
397 } else {
398 return Err(reqsign_core::Error::credential_invalid(
399 "service account or token + signer_email required for query signing",
400 ));
401 }
402 }
403 None => {
405 if let Some(token) = &cred.token {
407 if token.is_valid() {
408 self.build_token_auth(req, token)?
409 } else if let Some(sa) = &cred.service_account {
410 debug!("token expired, exchanging service account for new token");
412 let new_token = self.exchange_token(ctx, sa).await?;
413 self.build_token_auth(req, &new_token)?
414 } else {
415 return Err(reqsign_core::Error::credential_invalid(
416 "token expired and no service account available",
417 ));
418 }
419 } else if let Some(sa) = &cred.service_account {
420 debug!("no token available, exchanging service account for token");
422 let token = self.exchange_token(ctx, sa).await?;
423 self.build_token_auth(req, &token)?
424 } else {
425 return Err(reqsign_core::Error::credential_invalid(
426 "no valid credential available",
427 ));
428 }
429 }
430 };
431
432 signing_req.apply(req).map_err(|e| {
433 reqsign_core::Error::unexpected("failed to apply signing request").with_source(e)
434 })
435 }
436}
437
438fn hex_encode_upper(bytes: &[u8]) -> String {
439 use std::fmt::Write;
440
441 let mut out = String::with_capacity(bytes.len() * 2);
442 for b in bytes {
443 write!(&mut out, "{:02X}", b).expect("writing to string must succeed");
444 }
445 out
446}
447
448fn canonical_request_string(req: &mut SigningRequest) -> Result<String> {
449 let mut f = String::with_capacity(256);
451
452 f.push_str(req.method.as_str());
454 f.push('\n');
455
456 let path = percent_decode_str(&req.path)
458 .decode_utf8()
459 .map_err(|e| reqsign_core::Error::unexpected("failed to decode path").with_source(e))?;
460 f.push_str(&Cow::from(utf8_percent_encode(&path, &GOOG_URI_ENCODE_SET)));
461 f.push('\n');
462
463 f.push_str(&SigningRequest::query_to_string(
465 req.query.clone(),
466 "=",
467 "&",
468 ));
469 f.push('\n');
470
471 let signed_headers = req.header_name_to_vec_sorted();
473 for header in signed_headers.iter() {
474 let value = &req.headers[*header];
475 f.push_str(header);
476 f.push(':');
477 f.push_str(value.to_str().expect("header value must be valid"));
478 f.push('\n');
479 }
480 f.push('\n');
481 f.push_str(&signed_headers.join(";"));
482 f.push('\n');
483 f.push_str("UNSIGNED-PAYLOAD");
484
485 debug!("canonical request string: {f}");
486 Ok(f)
487}
488
489fn canonicalize_header(req: &mut SigningRequest) -> Result<()> {
490 for (_, value) in req.headers.iter_mut() {
491 SigningRequest::header_value_normalize(value)
492 }
493
494 if req.headers.get(header::HOST).is_none() {
496 req.headers.insert(
497 header::HOST,
498 req.authority.as_str().parse().map_err(|e| {
499 reqsign_core::Error::unexpected("failed to parse host header").with_source(e)
500 })?,
501 );
502 }
503
504 Ok(())
505}
506
507fn canonicalize_query(
508 req: &mut SigningRequest,
509 method: SigningMethod,
510 client_email: &str,
511 now: Timestamp,
512 service: &str,
513 region: &str,
514) -> Result<()> {
515 if let SigningMethod::Query(expire) = method {
516 req.query
517 .push(("X-Goog-Algorithm".into(), "GOOG4-RSA-SHA256".into()));
518 req.query.push((
519 "X-Goog-Credential".into(),
520 format!(
521 "{}/{}/{}/{}/goog4_request",
522 client_email,
523 now.format_date(),
524 region,
525 service
526 ),
527 ));
528 req.query.push(("X-Goog-Date".into(), now.format_iso8601()));
529 req.query
530 .push(("X-Goog-Expires".into(), expire.as_secs().to_string()));
531 req.query.push((
532 "X-Goog-SignedHeaders".into(),
533 req.header_name_to_vec_sorted().join(";"),
534 ));
535 }
536
537 if req.query.is_empty() {
539 return Ok(());
540 }
541
542 req.query.sort();
544
545 req.query = req
546 .query
547 .iter()
548 .map(|(k, v)| {
549 (
550 utf8_percent_encode(k, &GOOG_QUERY_ENCODE_SET).to_string(),
551 utf8_percent_encode(v, &GOOG_QUERY_ENCODE_SET).to_string(),
552 )
553 })
554 .collect();
555
556 Ok(())
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use bytes::Bytes;
563 use http::header;
564 use reqsign_core::HttpSend;
565 use std::sync::{Arc, Mutex};
566
567 #[derive(Debug, Default)]
568 struct Recorded {
569 payload_b64: Option<String>,
570 }
571
572 #[derive(Clone, Debug, Default)]
573 struct MockHttpSend {
574 recorded: Arc<Mutex<Recorded>>,
575 }
576 impl HttpSend for MockHttpSend {
577 async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
578 assert_eq!(req.method(), http::Method::POST);
579 assert_eq!(
580 req.uri().to_string(),
581 "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-signer@example.com:signBlob"
582 );
583 assert_eq!(
584 req.headers()
585 .get(header::CONTENT_TYPE)
586 .expect("content-type must exist")
587 .to_str()
588 .expect("content-type must be valid string"),
589 "application/json"
590 );
591 assert_eq!(
592 req.headers()
593 .get(header::AUTHORIZATION)
594 .expect("authorization must exist")
595 .to_str()
596 .expect("authorization must be valid string"),
597 "Bearer test-access-token"
598 );
599
600 let value: serde_json::Value =
601 serde_json::from_slice(req.body()).expect("body must be valid json");
602 let payload_b64 = value
603 .get("payload")
604 .and_then(|v| v.as_str())
605 .expect("payload must exist")
606 .to_string();
607
608 self.recorded.lock().unwrap().payload_b64 = Some(payload_b64);
609
610 let body = br#"{"signedBlob":"AQID"}"#;
612 Ok(http::Response::builder()
613 .status(http::StatusCode::OK)
614 .body(body.as_slice().into())
615 .expect("response must build"))
616 }
617 }
618
619 fn query_get<'a>(query: &'a str, key: &str) -> Option<&'a str> {
620 query.split('&').find_map(|kv| {
621 let (k, v) = kv.split_once('=')?;
622 if k == key { Some(v) } else { None }
623 })
624 }
625
626 fn parse_goog_date_to_timestamp(v: &str) -> Timestamp {
627 let year = &v[0..4];
628 let month = &v[4..6];
629 let day = &v[6..8];
630 let hour = &v[9..11];
631 let minute = &v[11..13];
632 let second = &v[13..15];
633 let rfc3339 = format!("{year}-{month}-{day}T{hour}:{minute}:{second}Z");
634 rfc3339.parse().expect("date must parse")
635 }
636
637 #[tokio::test]
638 async fn test_signed_url_via_iamcredentials_sign_blob() -> Result<()> {
639 let mock_http = MockHttpSend::default();
640 let ctx = Context::new().with_http_send(mock_http.clone());
641
642 let signer = RequestSigner::new("storage").with_signer_email("test-signer@example.com");
643
644 let cred = Credential::with_token(Token {
645 access_token: "test-access-token".to_string(),
646 expires_at: None,
647 });
648
649 let expires_in = Duration::from_secs(60);
650
651 let mut builder = http::Request::builder();
652 builder = builder.method(http::Method::GET);
653 builder = builder.uri("https://storage.googleapis.com/test-bucket/test-object");
654 let req = builder.body(Bytes::new()).expect("request must build");
655 let (mut parts, _body) = req.into_parts();
656
657 signer
658 .sign_request(&ctx, &mut parts, Some(&cred), Some(expires_in))
659 .await?;
660
661 let query = parts.uri.query().expect("signed url must have query");
662 assert_eq!(
663 query_get(query, "X-Goog-Signature").expect("signature must exist"),
664 "010203"
665 );
666
667 let goog_date = query_get(query, "X-Goog-Date").expect("date must exist");
668 let now = parse_goog_date_to_timestamp(goog_date);
669
670 let mut builder = http::Request::builder();
671 builder = builder.method(http::Method::GET);
672 builder = builder.uri("https://storage.googleapis.com/test-bucket/test-object");
673 let req = builder.body(Bytes::new()).expect("request must build");
674 let (mut parts_for_rebuild, _body) = req.into_parts();
675
676 let mut signing_req = SigningRequest::build(&mut parts_for_rebuild)?;
677 let string_to_sign = signer.build_string_to_sign(
678 &mut signing_req,
679 "test-signer@example.com",
680 now,
681 expires_in,
682 )?;
683 let expected_payload_b64 = reqsign_core::hash::base64_encode(string_to_sign.as_bytes());
684
685 let recorded_payload_b64 = mock_http
686 .recorded
687 .lock()
688 .unwrap()
689 .payload_b64
690 .clone()
691 .expect("payload must be recorded");
692
693 assert_eq!(recorded_payload_b64, expected_payload_b64);
694
695 Ok(())
696 }
697}