pink_s3/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2#![doc = include_str!("../README.md")]
3
4#[macro_use]
5extern crate alloc;
6
7pub use pink::chain_extension::HttpResponse;
8
9use scale::{Decode, Encode};
10// To encrypt/decrypt HTTP payloads
11
12// To generate AWS4 Signature
13use alloc::{
14    borrow::ToOwned,
15    string::{String, ToString},
16    vec::Vec,
17};
18use hmac::{Hmac, Mac};
19use sha2::Digest;
20use sha2::Sha256;
21
22#[derive(Encode, Decode, Debug, PartialEq, Eq, Copy, Clone)]
23#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
24pub enum Error {
25    RequestFailed(u16),
26    InvalidEndpoint,
27}
28
29/// The S3 client
30pub struct S3<'a> {
31    endpoint: &'a str,
32    region: &'a str,
33    access_key: &'a str,
34    secret_key: &'a str,
35    virtual_host_mode: bool,
36}
37
38impl<'a> S3<'a> {
39    /// Create a new S3 client instance
40    pub fn new(
41        endpoint: &'a str,
42        region: &'a str,
43        access_key: &'a str,
44        secret_key: &'a str,
45    ) -> Result<Self, Error> {
46        Ok(Self {
47            endpoint,
48            region,
49            access_key,
50            secret_key,
51            virtual_host_mode: false,
52        })
53    }
54
55    /// Turn on virtual host mode
56    ///
57    /// AWS S3 requires virtual host mode for newly created buckets.
58    pub fn virtual_host_mode(self) -> Self {
59        Self {
60            virtual_host_mode: true,
61            ..self
62        }
63    }
64
65    /// Get object metadata from given bucket
66    ///
67    /// Returns Error::RequestFailed(404) it does not exist.
68    pub fn head(&self, bucket_name: &str, object_key: &str) -> Result<HttpResponse, Error> {
69        self.request("HEAD", bucket_name, object_key, None)
70    }
71
72    /// Get object value from bucket `bucket_name` with key `object_key`.
73    ///
74    /// Returns Error::RequestFailed(404) it does not exist.
75    pub fn get(&self, bucket_name: &str, object_key: &str) -> Result<HttpResponse, Error> {
76        self.request("GET", bucket_name, object_key, None)
77    }
78
79    /// Put an value into bucket `bucket_name` with key `object_key`.
80    pub fn put(
81        &self,
82        bucket_name: &str,
83        object_key: &str,
84        value: &[u8],
85    ) -> Result<HttpResponse, Error> {
86        self.request("PUT", bucket_name, object_key, Some(value))
87    }
88
89    /// Delete given object from bucket `bucket_name` with key `object_key`.
90    ///
91    /// Returns Error::RequestFailed(404) it does not exist.
92    pub fn delete(&self, bucket_name: &str, object_key: &str) -> Result<HttpResponse, Error> {
93        self.request("DELETE", bucket_name, object_key, None)
94    }
95
96    fn request(
97        &self,
98        method: &str,
99        bucket_name: &str,
100        object_key: &str,
101        value: Option<&[u8]>,
102    ) -> Result<HttpResponse, Error> {
103        // Set request values
104        let service = "s3";
105        let payload_hash = format!("{:x}", Sha256::digest(value.unwrap_or_default()));
106
107        let host = if self.virtual_host_mode {
108            format!("{bucket_name}.{}", self.endpoint)
109        } else {
110            self.endpoint.to_owned()
111        };
112
113        // Get current time: datestamp (e.g. 20220727) and amz_date (e.g. 20220727T141618Z)
114        let (datestamp, amz_date) = times();
115
116        // 1. Create canonical request
117        let canonical_uri = if self.virtual_host_mode {
118            format!("/{object_key}")
119        } else {
120            format!("/{bucket_name}/{object_key}")
121        };
122        let canonical_querystring = "";
123        let canonical_headers =
124            format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n");
125        let signed_headers = "host;x-amz-content-sha256;x-amz-date";
126        let canonical_request = format!(
127            "{method}\n{canonical_uri}\n{canonical_querystring}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
128        );
129
130        // 2. Create "String to sign"
131        let algorithm = "AWS4-HMAC-SHA256";
132        let credential_scope = format!("{datestamp}/{}/{service}/aws4_request", self.region);
133        let canonical_request_hash = format!("{:x}", Sha256::digest(canonical_request.as_bytes()));
134        let string_to_sign =
135            format!("{algorithm}\n{amz_date}\n{credential_scope}\n{canonical_request_hash}");
136
137        // 3. Calculate signature
138        let signature_key = get_signature_key(
139            self.secret_key.as_bytes(),
140            datestamp.as_bytes(),
141            self.region.as_bytes(),
142            service.as_bytes(),
143        );
144        let signature_bytes = hmac_sign(&signature_key, string_to_sign.as_bytes());
145        let signature = base16::encode_lower(&signature_bytes);
146
147        // 4. Create authorization header
148        let authorization_header = format!(
149            "{} Credential={}/{}, SignedHeaders={}, Signature={}",
150            algorithm, self.access_key, credential_scope, signed_headers, signature
151        );
152
153        let mut headers: Vec<(String, String)> = vec![
154            ("Authorization".into(), authorization_header),
155            ("x-amz-content-sha256".into(), payload_hash),
156            ("x-amz-date".into(), amz_date),
157        ];
158
159        let body = if let Some(value) = value {
160            headers.push(("Content-Length".into(), format!("{}", &value.len())));
161            headers.push(("Content-Type".into(), "binary/octet-stream".into()));
162            value
163        } else {
164            &[]
165        };
166
167        // Make HTTP PUT request
168        let request_url = format!("https://{host}{canonical_uri}");
169        let response = pink::http_req!(method, request_url, body.to_vec(), headers);
170
171        if response.status_code / 100 != 2 {
172            return Err(Error::RequestFailed(response.status_code));
173        }
174
175        Ok(response)
176    }
177}
178
179fn times() -> (String, String) {
180    // Get block time (UNIX time in nano seconds)and convert to Utc datetime object
181    #[cfg(test)]
182    let datetime = chrono::Utc::now();
183    #[cfg(not(test))]
184    let datetime = {
185        use chrono::{TimeZone, Utc};
186        let time = pink::ext().untrusted_millis_since_unix_epoch() / 1000;
187        Utc.timestamp_opt(time as _, 0)
188            .earliest()
189            .expect("Could not convert timestamp to Utc")
190    };
191
192    // Format both date and datetime for AWS4 signature
193    let datestamp = datetime.format("%Y%m%d").to_string();
194    let datetimestamp = datetime.format("%Y%m%dT%H%M%SZ").to_string();
195
196    (datestamp, datetimestamp)
197}
198
199// Create alias for HMAC-SHA256
200type HmacSha256 = Hmac<Sha256>;
201
202// Returns encrypted hex bytes of key and message using SHA256
203fn hmac_sign(key: &[u8], msg: &[u8]) -> Vec<u8> {
204    let mut mac =
205        <HmacSha256 as Mac>::new_from_slice(key).expect("Could not instantiate HMAC instance");
206    mac.update(msg);
207    let result = mac.finalize().into_bytes();
208    result.to_vec()
209}
210
211// Returns the signature key for the complicated version
212fn get_signature_key(
213    key: &[u8],
214    datestamp: &[u8],
215    region_name: &[u8],
216    service_name: &[u8],
217) -> Vec<u8> {
218    let k_date = hmac_sign(&[b"AWS4", key].concat(), datestamp);
219    let k_region = hmac_sign(&k_date, region_name);
220    let k_service = hmac_sign(&k_region, service_name);
221    hmac_sign(&k_service, b"aws4_request")
222}