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