use std::collections::HashMap;
use base64::Engine;
use hmac::digest::InvalidLength;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use time::macros::format_description;
use time::{Duration, OffsetDateTime};
use crate::impl_debug;
const MEGABYTE_BYTES: u64 = 1_000_000;
const MAX_DEFAULT_SIZE_BYTES: u64 = MEGABYTE_BYTES * 1000; // 1 GB
type HmacSha256 = Hmac<Sha256>;
#[derive(thiserror::Error)]
pub enum Error {
#[error("Date parsing error: {0}")]
DateParsingError(#[from] time::error::Format),
#[error("HMAC signing error: {0}")]
HmacError(#[from] InvalidLength),
#[error("JSON serialization error: {0}")]
JsonSerError(#[from] serde_json::Error),
}
impl_debug!(Error);
#[cfg_attr(feature = "utoipa",
derive(utoipa::ToSchema, utoipa::ToResponse),
response(
description = "Presigned post object data",
content_type = "application/json",
example = json!(
{
"url": "http://ghashy.garage:9000/mustore-data",
"fields": {
"policy": "... long policy ...",
"key": "abc123-d3090bb8-493b-4837-80fc-cc2deeae3705-image.png",
"Content-Disposition": "attachment; filename=\"abc123-d3090bb8-493b-4837-80fc-cc2deeae3705-image.png\"",
"acl": "private",
"success_action_status": "200",
"X-Amz-Date": "20240121T211735Z",
"Content-Type": "image/png",
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Signature": "4b08ff30d6f18e95ebe6b797831304c8ab750d7cb98875daeebc2005fb205312",
"X-Amz-Credential": "garage/20240121/ru-central1/s3/aws4_request"
}
}
)
),
)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresignedPostData {
pub url: String,
pub fields: HashMap<String, String>,
}
impl PresignedPostData {
pub fn builder<'a>(
access_key: &'a str,
access_key_id: &'a str,
obj_storage_endpoint: &'a str,
region_name: &'a str,
bucket: &'a str,
object_key: &'a str,
) -> PresignedPostDataBuilder<'a> {
PresignedPostDataBuilder {
access_key,
access_key_id,
obj_storage_endpoint,
bucket,
object_key,
region_name,
date: None,
content_disposition: None,
expiration: None,
mime: None,
service_name: None,
min_obj_length: None,
max_obj_length: None,
}
}
}
pub struct PresignedPostDataBuilder<'a> {
access_key: &'a str,
access_key_id: &'a str,
obj_storage_endpoint: &'a str,
bucket: &'a str,
object_key: &'a str,
region_name: &'a str,
date: Option<OffsetDateTime>,
content_disposition: Option<&'a str>,
expiration: Option<Duration>,
mime: Option<mediatype::MediaType<'a>>,
service_name: Option<&'a str>,
min_obj_length: Option<u64>,
max_obj_length: Option<u64>,
}
impl<'a> PresignedPostDataBuilder<'a> {
pub fn with_date(self, date: OffsetDateTime) -> Self {
Self {
date: Some(date),
..self
}
}
pub fn with_content_disposition(
self,
content_disposition: &'a str,
) -> Self {
Self {
content_disposition: Some(content_disposition),
..self
}
}
pub fn with_expiration(self, expiration: Duration) -> Self {
Self {
expiration: Some(expiration),
..self
}
}
pub fn with_mime(self, mime: mediatype::MediaType<'a>) -> Self {
Self {
mime: Some(mime),
..self
}
}
pub fn with_service_name(self, service_name: &'a str) -> Self {
Self {
service_name: Some(service_name),
..self
}
}
pub fn with_content_length_range(self, min: u64, max: u64) -> Self {
Self {
min_obj_length: Some(min),
max_obj_length: Some(max),
..self
}
}
pub fn build(self) -> Result<PresignedPostData, Error> {
let date = self.date.unwrap_or(OffsetDateTime::now_utc());
let expiration = date + self.expiration.unwrap_or(Duration::MINUTE);
let service_name = self.service_name.unwrap_or("s3");
let mime = self
.mime
.unwrap_or(mediatype::media_type!(APPLICATION / OCTET_STREAM));
let default_disposition =
format!("attachment; filename=\"{}\"", self.object_key);
let content_disposition =
self.content_disposition.unwrap_or(&default_disposition);
let yyyymmdd_date = get_date_yyyymmdd(date)?;
let iso8601_date = get_date_iso8601(date)?;
let x_amz_credential = format!(
"{}/{}/{}/{}/aws4_request",
self.access_key_id, yyyymmdd_date, self.region_name, service_name
);
let policy = Self::create_policy_document(
self.bucket,
self.object_key,
&mime,
expiration,
content_disposition,
&x_amz_credential,
&iso8601_date,
self.min_obj_length.unwrap_or(0),
self.max_obj_length.unwrap_or(MAX_DEFAULT_SIZE_BYTES),
)?;
let signing_key = get_signing_key(
self.access_key,
&yyyymmdd_date,
self.region_name,
service_name,
)?;
let policy_signature = get_policy_signature(&signing_key, &policy)?;
let mut map: HashMap<String, String> = HashMap::new();
map.insert("X-Amz-Algorithm".into(), "AWS4-HMAC-SHA256".into());
map.insert("X-Amz-Date".into(), iso8601_date.into());
map.insert("success_action_status".into(), "200".into());
map.insert("X-Amz-Signature".into(), policy_signature.into());
map.insert("key".into(), self.object_key.into());
map.insert("bucket".into(), self.bucket.to_owned());
// NOTE: Garage doesn't support S3 ACL access control mechanisms, uses its own system instead, built around a per-access-key-per-bucket logic
// map.insert("acl".into(), "private".into());
map.insert("policy".into(), policy.into());
map.insert("X-Amz-Credential".into(), x_amz_credential.into());
map.insert("Content-Type".into(), mime.to_string());
map.insert(
"Content-Disposition".into(),
content_disposition.to_string(),
);
Ok(PresignedPostData {
url: format!("{}/{}", self.obj_storage_endpoint, self.bucket),
fields: map,
})
}
#[allow(clippy::too_many_arguments)]
fn create_policy_document(
bucket: &str,
object_key: &str,
mime: &mediatype::MediaType<'_>,
expiration: OffsetDateTime,
content_disposition: &str,
x_amz_credential: &str,
iso8601_date: &str,
min: u64,
max: u64,
) -> Result<String, Error> {
let policy = serde_json::json!({
"expiration": expiration.format(&time::format_description::well_known::Rfc3339).map_err(Error::DateParsingError)?,
"conditions": [
{"X-Amz-Algorithm": "AWS4-HMAC-SHA256"},
{"X-Amz-Date": iso8601_date},
{"X-Amz-Credential": x_amz_credential},
{"bucket": bucket},
{"key": object_key},
// NOTE: Garage doesn't support S3 ACL access control mechanisms, uses its own system instead, built around a per-access-key-per-bucket logic
// {"acl": "private"},
{"success_action_status": "200"},
{"Content-Disposition": content_disposition},
["starts-with", "$Content-Type", mime.ty.as_ref()],
["content-length-range", min, max]
]
});
let policy =
serde_json::to_string(&policy).map_err(Error::JsonSerError)?;
// Base64 encode the policy document
Ok(base64::engine::general_purpose::STANDARD.encode(policy))
}
}
fn get_date_yyyymmdd(date: OffsetDateTime) -> Result<String, Error> {
let yyyymmdd_format = format_description!("[year][month][day]");
let yyyymmdd_date = date
.format(&yyyymmdd_format)
.map_err(Error::DateParsingError)?;
Ok(yyyymmdd_date)
}
fn get_date_iso8601(date: OffsetDateTime) -> Result<String, Error> {
let iso8601_format =
format_description!("[year][month][day]T[hour][minute][second]Z");
let iso8601_date = date
.format(&iso8601_format)
.map_err(Error::DateParsingError)?;
Ok(iso8601_date)
}
fn sign(key: &[u8], msg: &[u8]) -> Result<Vec<u8>, Error> {
let mut mac = HmacSha256::new_from_slice(key).map_err(Error::HmacError)?;
mac.update(msg);
Ok(mac.finalize().into_bytes().to_vec())
}
fn get_signing_key(
access_key: &str,
date: &str,
region_name: &str,
service_name: &str,
) -> Result<Vec<u8>, Error> {
let k_date =
sign(format!("AWS4{}", access_key).as_bytes(), date.as_bytes())?;
let k_region = sign(&k_date, region_name.as_bytes())?;
let k_service = sign(&k_region, service_name.as_bytes())?;
sign(&k_service, b"aws4_request")
}
fn get_policy_signature(
signing_key: &[u8],
policy_document_base64: &str,
) -> Result<String, Error> {
// Sign the policy document
let signature = sign(signing_key, policy_document_base64.as_bytes())?;
Ok(hex::encode(signature))
}
#[cfg(test)]
mod tests {
use time::OffsetDateTime;
use crate::ppo::MEGABYTE_BYTES;
use super::PresignedPostData;
#[test]
fn data_form_correct_from_template() {
let key_id = "test_key_id";
let access_key = "test_access_id";
let presigned_post = PresignedPostData::builder(
access_key,
key_id,
"https://storage.yandexcloud.net",
"ru-central1",
"test-data",
"image.png",
)
.with_mime(mediatype::media_type!(IMAGE / PNG))
.with_date(OffsetDateTime::UNIX_EPOCH)
.with_expiration(time::Duration::minutes(10))
.with_content_length_range(0, 5 * MEGABYTE_BYTES)
.build()
.expect("Failed to build presigned post form");
assert_eq!(
presigned_post.url,
"https://storage.yandexcloud.net/test-data"
);
assert_eq!(
presigned_post
.fields
.get("X-Amz-Algorithm")
.map(|alg| alg.as_str()),
Some("AWS4-HMAC-SHA256")
);
assert_eq!(
presigned_post
.fields
.get("X-Amz-Credential")
.map(|cred| cred.as_str()),
Some("test_key_id/19700101/ru-central1/s3/aws4_request")
);
assert_eq!(
presigned_post
.fields
.get("success_action_status")
.map(|s| s.as_str()),
Some("200")
);
assert_eq!(
presigned_post
.fields
.get("X-Amz-Date")
.map(|date| date.as_str()),
Some("19700101T000000Z")
);
assert_eq!(
presigned_post.fields.get("key").map(|key| key.as_str()),
Some("image.png")
);
assert_eq!(
presigned_post
.fields
.get("Content-Type")
.map(|t| t.as_str()),
Some("image/png")
);
// NOTE: Garage doesn't support S3 ACL access control mechanisms, uses its own system instead, built around a per-access-key-per-bucket logic
// assert_eq!(
// presigned_post.fields.get("acl").map(|acl| acl.as_str()),
// Some("private")
// );
assert_eq!(
presigned_post.fields.get("policy").map(|p| p.as_str()),
Some("eyJjb25kaXRpb25zIjpbeyJYLUFtei1BbGdvcml0aG0iOiJBV1M0LUhNQUMtU0hBMjU2In0seyJYLUFtei1EYXRlIjoiMTk3MDAxMDFUMDAwMDAwWiJ9LHsiWC1BbXotQ3JlZGVudGlhbCI6InRlc3Rfa2V5X2lkLzE5NzAwMTAxL3J1LWNlbnRyYWwxL3MzL2F3czRfcmVxdWVzdCJ9LHsiYnVja2V0IjoidGVzdC1kYXRhIn0seyJrZXkiOiJpbWFnZS5wbmcifSx7InN1Y2Nlc3NfYWN0aW9uX3N0YXR1cyI6IjIwMCJ9LHsiQ29udGVudC1EaXNwb3NpdGlvbiI6ImF0dGFjaG1lbnQ7IGZpbGVuYW1lPVwiaW1hZ2UucG5nXCIifSxbInN0YXJ0cy13aXRoIiwiJENvbnRlbnQtVHlwZSIsImltYWdlIl0sWyJjb250ZW50LWxlbmd0aC1yYW5nZSIsMCw1MDAwMDAwMDAwMDAwXV0sImV4cGlyYXRpb24iOiIxOTcwLTAxLTAxVDAwOjEwOjAwWiJ9")
);
assert_eq!(
presigned_post.fields.get("X-Amz-Signature").map(|s| s.as_str()),
Some("731a9fb7beb797517bc6336c4a956e06daeb6dc6c1ddee3e64909e01212b0dba")
);
assert_eq!(
presigned_post
.fields
.get("Content-Disposition")
.map(|s| s.as_str()),
Some("attachment; filename=\"image.png\"")
);
}
}