Skip to main content

reqsign/google/
signer.rs

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
26/// Singer that implement Google OAuth2 Authentication.
27///
28/// ## Reference
29///
30/// -  [Authenticating as a service account](https://cloud.google.com/docs/authentication/production)
31pub struct Signer {
32    service: String,
33    region: String,
34    time: Option<DateTime>,
35}
36
37impl Signer {
38    /// Create a builder of Signer.
39    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    /// Set the region name that used for google v4 signing.
48    ///
49    /// Default to `auto`
50    pub fn region(&mut self, region: &str) -> &mut Self {
51        self.region = region.to_string();
52        self
53    }
54
55    /// Specify the signing time.
56    ///
57    /// # Note
58    ///
59    /// We should always take current time to sign requests.
60    /// Only use this function for testing.
61    #[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 context
96        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        // build canonical request and string to sign.
107        let creq = canonical_request_string(&mut ctx)?;
108        let encoded_req = hex_sha256(creq.as_bytes());
109
110        // Scope: "20220313/<region>/<service>/goog4_request"
111        let scope = format!(
112            "{}/{}/{}/goog4_request",
113            format_date(now),
114            self.region,
115            self.service
116        );
117        debug!("calculated scope: {scope}");
118
119        // StringToSign:
120        //
121        // GOOG4-RSA-SHA256
122        // 20220313T072004Z
123        // 20220313/<region>/<service>/goog4_request
124        // <hashed_canonical_request>
125        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    /// Signing request.
151    ///
152    /// # Example
153    ///
154    /// ```rust,no_run
155    /// use anyhow::Result;
156    /// use reqsign::GoogleSigner;
157    /// use reqsign::GoogleTokenLoader;
158    /// use reqwest::Client;
159    /// use reqwest::Request;
160    /// use reqwest::Url;
161    ///
162    /// #[tokio::main]
163    /// async fn main() -> Result<()> {
164    ///     // Signer will load region and credentials from environment by default.
165    ///     let token_loader = GoogleTokenLoader::new(
166    ///         "https://www.googleapis.com/auth/devstorage.read_only",
167    ///         Client::new(),
168    ///     );
169    ///     let signer = GoogleSigner::new("storage");
170    ///
171    ///     // Construct request
172    ///     let url = Url::parse("https://storage.googleapis.com/storage/v1/b/test")?;
173    ///     let mut req = reqwest::Request::new(http::Method::GET, url);
174    ///
175    ///     // Signing request with Signer
176    ///     let token = token_loader.load().await?.unwrap();
177    ///     signer.sign(&mut req, &token)?;
178    ///
179    ///     // Sending already signed request.
180    ///     let resp = Client::new().execute(req).await?;
181    ///     println!("resp got status: {}", resp.status());
182    ///     Ok(())
183    /// }
184    /// ```
185    ///
186    /// # TODO
187    ///
188    /// 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)
189    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    /// Sign the query with a duration.
195    ///
196    /// # Example
197    /// ```rust,no_run
198    /// use std::time::Duration;
199    ///
200    /// use anyhow::Result;
201    /// use reqsign::GoogleCredentialLoader;
202    /// use reqsign::GoogleSigner;
203    /// use reqwest::Client;
204    /// use reqwest::Url;
205    ///
206    /// #[tokio::main]
207    /// async fn main() -> Result<()> {
208    ///     // Signer will load region and credentials from environment by default.
209    ///     let credential_loader = GoogleCredentialLoader::default();
210    ///     let signer = GoogleSigner::new("stroage");
211    ///
212    ///     // Construct request
213    ///     let url = Url::parse("https://storage.googleapis.com/testbucket-reqsign/CONTRIBUTING.md")?;
214    ///     let mut req = reqwest::Request::new(http::Method::GET, url);
215    ///
216    ///     // Signing request with Signer
217    ///     let credential = credential_loader.load()?.unwrap();
218    ///     signer.sign_query(&mut req, Duration::from_secs(3600), &credential)?;
219    ///
220    ///     println!("signed request: {:?}", req);
221    ///     // Sending already signed request.
222    ///     let resp = Client::new().execute(req).await?;
223    ///     println!("resp got status: {}", resp.status());
224    ///     println!("resp got body: {}", resp.text().await?);
225    ///     Ok(())
226    /// }
227    /// ```
228    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    // 256 is specially chosen to avoid reallocation for most requests.
245    let mut f = String::with_capacity(256);
246
247    // Insert method
248    f.push_str(ctx.method.as_str());
249    f.push('\n');
250
251    // Insert encoded path
252    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    // Insert query
260    f.push_str(&SigningContext::query_to_string(
261        ctx.query.clone(),
262        "=",
263        "&",
264    ));
265    f.push('\n');
266
267    // Insert signed headers
268    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    // Insert HOST header if not present.
291    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    // Return if query is empty.
330    if ctx.query.is_empty() {
331        return Ok(());
332    }
333
334    // Sort by param name
335    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}