google_cloud_storage/
sign.rs

1use std::collections::{BTreeMap, HashMap};
2use std::fmt::{Debug, Formatter};
3use std::ops::Deref;
4use std::time::{Duration, SystemTime};
5
6use base64::prelude::*;
7use once_cell::sync::Lazy;
8use pkcs8::der::pem::PemLabel;
9use pkcs8::SecretDocument;
10use regex::Regex;
11use sha2::{Digest, Sha256};
12use time::format_description::well_known::iso8601::{EncodedConfig, TimePrecision};
13use time::format_description::well_known::{self, Iso8601};
14use time::macros::format_description;
15use time::OffsetDateTime;
16use url;
17use url::{ParseError, Url};
18
19use crate::http;
20use crate::sign::SignedURLError::InvalidOption;
21
22static SPACE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r" +").unwrap());
23static TAB_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\t]+").unwrap());
24static ONE_WEEK_IN_SECONDS: u64 = 604801;
25
26pub enum SignedURLMethod {
27    DELETE,
28    GET,
29    HEAD,
30    POST,
31    PUT,
32}
33
34impl SignedURLMethod {
35    pub fn as_str(&self) -> &str {
36        match self {
37            SignedURLMethod::DELETE => "DELETE",
38            SignedURLMethod::GET => "GET",
39            SignedURLMethod::HEAD => "HEAD",
40            SignedURLMethod::POST => "POST",
41            SignedURLMethod::PUT => "PUT",
42        }
43    }
44}
45
46pub trait URLStyle {
47    fn host(&self, bucket: &str) -> String;
48    fn path(&self, bucket: &str, object: &str) -> String;
49}
50
51pub struct PathStyle {}
52
53const HOST: &str = "storage.googleapis.com";
54
55impl URLStyle for PathStyle {
56    fn host(&self, _bucket: &str) -> String {
57        //TODO emulator support
58        HOST.to_string()
59    }
60
61    fn path(&self, bucket: &str, object: &str) -> String {
62        if object.is_empty() {
63            return bucket.to_string();
64        }
65        format!("{bucket}/{object}")
66    }
67}
68
69#[derive(Clone)]
70pub enum SignBy {
71    PrivateKey(Vec<u8>),
72    SignBytes,
73}
74
75impl Debug for SignBy {
76    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
77        match self {
78            SignBy::PrivateKey(_) => f.write_str("private_key"),
79            SignBy::SignBytes => f.write_str("sign_bytes"),
80        }
81    }
82}
83
84/// SignedURLOptions allows you to restrict the access to the signed URL.
85pub struct SignedURLOptions {
86    /// Method is the HTTP method to be used with the signed URL.
87    /// Signed URLs can be used with GET, HEAD, PUT, and DELETE requests.
88    /// Required.
89    pub method: SignedURLMethod,
90
91    /// StartTime is the time at which the signed URL starts being valid.
92    /// Defaults to the current time.
93    /// Optional.
94    pub start_time: Option<std::time::SystemTime>,
95
96    /// Expires is the duration of time, beginning at StartTime, within which
97    /// the signed URL is valid. For SigningSchemeV4, the duration may be no
98    /// more than 604800 seconds (7 days).
99    /// Required.
100    pub expires: std::time::Duration,
101
102    /// ContentType is the content type header the client must provide
103    /// to use the generated signed URL.
104    /// Optional.
105    pub content_type: Option<String>,
106
107    /// Headers is a list of extension headers the client must provide
108    /// in order to use the generated signed URL. Each must be a string of the
109    /// form "key:values", with multiple values separated by a semicolon.
110    /// Optional.
111    pub headers: Vec<String>,
112
113    /// QueryParameters is a map of additional query parameters. When
114    /// SigningScheme is V4, this is used in computing the signature, and the
115    /// client must use the same query parameters when using the generated signed
116    /// URL.
117    /// Optional.
118    pub query_parameters: HashMap<String, Vec<String>>,
119
120    /// MD5 is the base64 encoded MD5 checksum of the file.
121    /// If provided, the client should provide the exact value on the request
122    /// header in order to use the signed URL.
123    /// Optional.
124    pub md5: Option<String>,
125
126    /// Style provides options for the type of URL to use. Options are
127    /// PathStyle (default), BucketBoundHostname, and VirtualHostedStyle. See
128    /// https://cloud.google.com/storage/docs/request-endpoints for details.
129    /// Only supported for V4 signing.
130    /// Optional.
131    pub style: Box<dyn URLStyle + Send + Sync>,
132
133    /// Insecure determines whether the signed URL should use HTTPS (default) or
134    /// HTTP.
135    /// Only supported for V4 signing.
136    /// Optional.
137    pub insecure: bool,
138}
139
140impl Default for SignedURLOptions {
141    fn default() -> Self {
142        Self {
143            method: SignedURLMethod::GET,
144            start_time: None,
145            expires: std::time::Duration::from_secs(600),
146            content_type: None,
147            headers: vec![],
148            query_parameters: Default::default(),
149            md5: None,
150            style: Box::new(PathStyle {}),
151            insecure: false,
152        }
153    }
154}
155
156#[derive(thiserror::Error, Debug)]
157pub enum SignedURLError {
158    #[error("invalid option {0}")]
159    InvalidOption(&'static str),
160    #[error(transparent)]
161    ParseError(#[from] ParseError),
162    #[error("cert error by: {0}")]
163    CertError(String),
164    #[error(transparent)]
165    SignBlob(#[from] http::Error),
166}
167
168pub(crate) fn create_signed_buffer(
169    bucket: &str,
170    name: &str,
171    google_access_id: &str,
172    opts: &SignedURLOptions,
173) -> Result<(Vec<u8>, Url), SignedURLError> {
174    validate_options(opts)?;
175    let start_time: OffsetDateTime = opts.start_time.unwrap_or_else(SystemTime::now).into();
176
177    let headers = v4_sanitize_headers(&opts.headers);
178    // create base url
179    let host = opts.style.host(bucket);
180    let mut builder = {
181        let url = if opts.insecure {
182            format!("http://{}", &host)
183        } else {
184            format!("https://{}", &host)
185        };
186        url::Url::parse(&url)
187    }?;
188
189    // create signed headers
190    let signed_headers = {
191        let mut header_names = extract_header_names(&headers);
192        header_names.push("host");
193        if opts.content_type.is_some() {
194            header_names.push("content-type");
195        }
196        if opts.md5.is_some() {
197            header_names.push("content-md5");
198        }
199        header_names.sort_unstable();
200        header_names.join(";")
201    };
202
203    const CONFIG: EncodedConfig = well_known::iso8601::Config::DEFAULT
204        .set_use_separators(false)
205        .set_time_precision(TimePrecision::Second { decimal_digits: None })
206        .encode();
207
208    let timestamp = start_time.format(&Iso8601::<CONFIG>).unwrap();
209    let credential_scope = format!(
210        "{}/auto/storage/goog4_request",
211        start_time.format(format_description!("[year][month][day]")).unwrap()
212    );
213
214    // append query parameters
215    {
216        let mut query_parameters = [
217            ("X-Goog-Algorithm", "GOOG4-RSA-SHA256"),
218            ("X-Goog-Credential", &format!("{}/{}", google_access_id, credential_scope)),
219            ("X-Goog-Date", &timestamp),
220            ("X-Goog-Expires", opts.expires.as_secs().to_string().as_str()),
221            ("X-Goog-SignedHeaders", &signed_headers),
222        ]
223        .into_iter()
224        .map(|(key, value)| (key.to_owned(), vec![value.to_owned()]))
225        .collect::<BTreeMap<_, _>>();
226        query_parameters.extend(opts.query_parameters.clone());
227
228        let mut query = builder.query_pairs_mut();
229        for (k, values) in &query_parameters {
230            for value in values {
231                query.append_pair(k.as_str(), value.as_str());
232            }
233        }
234    }
235    let escaped_query = builder.query().unwrap().replace('+', "%20");
236    tracing::trace!("escaped_query={}", escaped_query);
237
238    // create header with value
239    let header_with_value = {
240        let mut header_with_value = vec![format!("host:{host}")];
241        header_with_value.extend_from_slice(&headers);
242        if let Some(content_type) = &opts.content_type {
243            header_with_value.push(format!("content-type:{content_type}"))
244        }
245        if let Some(md5) = &opts.md5 {
246            header_with_value.push(format!("content-md5:{md5}"))
247        }
248        header_with_value.sort();
249        header_with_value
250    };
251    let path = opts.style.path(bucket, name);
252    builder.set_path(&path);
253
254    // create raw buffer
255    let buffer = {
256        let mut buffer = format!(
257            "{}\n{}\n{}\n{}\n\n{}\n",
258            opts.method.as_str(),
259            builder.path().replace('+', "%20"),
260            escaped_query,
261            header_with_value.join("\n"),
262            signed_headers
263        )
264        .into_bytes();
265
266        // If the user provides a value for X-Goog-Content-SHA256, we must use
267        // that value in the request string. If not, we use UNSIGNED-PAYLOAD.
268        let sha256_header = header_with_value.iter().any(|h| {
269            let ret = h.to_lowercase().starts_with("x-goog-content-sha256") && h.contains(':');
270            if ret {
271                let v: Vec<&str> = h.splitn(2, ':').collect();
272                buffer.extend_from_slice(v[1].as_bytes());
273            }
274            ret
275        });
276        if !sha256_header {
277            buffer.extend_from_slice("UNSIGNED-PAYLOAD".as_bytes());
278        }
279        buffer
280    };
281    tracing::trace!("raw_buffer={:?}", String::from_utf8_lossy(&buffer));
282
283    // create signed buffer
284    let hex_digest = hex::encode(Sha256::digest(buffer));
285    let mut signed_buffer: Vec<u8> = vec![];
286    signed_buffer.extend_from_slice("GOOG4-RSA-SHA256\n".as_bytes());
287    signed_buffer.extend_from_slice(format!("{timestamp}\n").as_bytes());
288    signed_buffer.extend_from_slice(format!("{credential_scope}\n").as_bytes());
289    signed_buffer.extend_from_slice(hex_digest.as_bytes());
290    Ok((signed_buffer, builder))
291}
292
293fn v4_sanitize_headers(hdrs: &[String]) -> Vec<String> {
294    let mut sanitized = HashMap::<String, Vec<String>>::new();
295    for hdr in hdrs {
296        let trimmed = hdr.trim().to_string();
297        let split: Vec<&str> = trimmed.split(':').collect();
298        if split.len() < 2 {
299            continue;
300        }
301        let key = split[0].trim().to_lowercase();
302        let space_removed = SPACE_REGEX.replace_all(split[1].trim(), " ");
303        let value = TAB_REGEX.replace_all(space_removed.as_ref(), "\t");
304        if !value.is_empty() {
305            sanitized.entry(key).or_default().push(value.to_string());
306        }
307    }
308    let mut sanitized_headers = Vec::with_capacity(sanitized.len());
309    for (key, value) in sanitized {
310        sanitized_headers.push(format!("{}:{}", key, value.join(",")));
311    }
312    sanitized_headers
313}
314
315fn extract_header_names(kvs: &[String]) -> Vec<&str> {
316    kvs.iter()
317        .map(|header| {
318            let name_value: Vec<&str> = header.split(':').collect();
319            name_value[0]
320        })
321        .collect()
322}
323
324fn validate_options(opts: &SignedURLOptions) -> Result<(), SignedURLError> {
325    if opts.expires.is_zero() {
326        return Err(InvalidOption("storage: expires cannot be zero"));
327    }
328    if let Some(md5) = &opts.md5 {
329        match BASE64_STANDARD.decode(md5) {
330            Ok(v) => {
331                if v.len() != 16 {
332                    return Err(InvalidOption("storage: invalid MD5 checksum length"));
333                }
334            }
335            Err(_e) => return Err(InvalidOption("storage: invalid MD5 checksum")),
336        }
337    }
338    if opts.expires > Duration::from_secs(ONE_WEEK_IN_SECONDS) {
339        return Err(InvalidOption("storage: expires must be within seven days from now"));
340    }
341    Ok(())
342}
343
344pub struct RsaKeyPair {
345    inner: ring::signature::RsaKeyPair,
346}
347
348impl PemLabel for RsaKeyPair {
349    const PEM_LABEL: &'static str = "PRIVATE KEY";
350}
351
352impl TryFrom<&Vec<u8>> for RsaKeyPair {
353    type Error = SignedURLError;
354
355    fn try_from(pem: &Vec<u8>) -> Result<Self, Self::Error> {
356        let str = String::from_utf8_lossy(pem);
357        let (label, doc) = SecretDocument::from_pem(&str).map_err(|v| SignedURLError::CertError(v.to_string()))?;
358        Self::validate_pem_label(label).map_err(|_| SignedURLError::CertError(label.to_string()))?;
359        let key_pair = ring::signature::RsaKeyPair::from_pkcs8(doc.as_bytes())
360            .map_err(|e| SignedURLError::CertError(e.to_string()))?;
361        Ok(Self { inner: key_pair })
362    }
363}
364
365impl Deref for RsaKeyPair {
366    type Target = ring::signature::RsaKeyPair;
367
368    fn deref(&self) -> &ring::signature::RsaKeyPair {
369        &self.inner
370    }
371}
372
373#[cfg(test)]
374mod test {
375    use std::collections::HashMap;
376    use std::time::Duration;
377
378    use serial_test::serial;
379
380    use crate::http::storage_client::test::bucket_name;
381    use google_cloud_auth::credentials::CredentialsFile;
382
383    use crate::sign::{create_signed_buffer, SignedURLOptions};
384
385    #[tokio::test]
386    #[serial]
387    async fn create_signed_buffer_test() {
388        let file = CredentialsFile::new().await.unwrap();
389        let param = {
390            let mut param = HashMap::new();
391            param.insert("tes t+".to_string(), vec!["++ +".to_string()]);
392            param
393        };
394        let google_access_id = file.client_email.unwrap();
395        let opts = SignedURLOptions {
396            expires: Duration::from_secs(3600),
397            query_parameters: param,
398            ..Default::default()
399        };
400        let (signed_buffer, _builder) = create_signed_buffer(
401            &bucket_name(&file.project_id.unwrap(), "object"),
402            "test1",
403            &google_access_id,
404            &opts,
405        )
406        .unwrap();
407        assert_eq!(signed_buffer.len(), 134)
408    }
409}