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 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
84pub struct SignedURLOptions {
86 pub method: SignedURLMethod,
90
91 pub start_time: Option<std::time::SystemTime>,
95
96 pub expires: std::time::Duration,
101
102 pub content_type: Option<String>,
106
107 pub headers: Vec<String>,
112
113 pub query_parameters: HashMap<String, Vec<String>>,
119
120 pub md5: Option<String>,
125
126 pub style: Box<dyn URLStyle + Send + Sync>,
132
133 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 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 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 {
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", ×tamp),
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 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 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 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 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}