Skip to main content

ocre/storage/
presign.rs

1//! Presigned URLs (AWS Signature Version 4, query-string form) for R2's S3
2//! API and other S3-compatible stores, and the signed keys of direct uploads.
3//!
4//! Signing is local: two SHA-256 and five HMAC-SHA256 over a few hundred
5//! bytes (a few microseconds of CPU), no binding call and no R2 operation.
6//! The browser's `GET` (class B) or `PUT` (class A) on the URL is what R2 counts.
7
8use std::{collections::BTreeMap, fmt, fmt::Write as _};
9
10use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
11use hmac::{Hmac, Mac};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15use super::{Attachment, BINARY_TYPES, Disposition, Rules, StoredObject, content_disposition, essence, new_key};
16use crate::{Error, Result, Validator, helpers::civil_from_days, token::constant_time_eq};
17
18/// Worker variable holding the Cloudflare account ID, for presigned R2 URLs.
19///
20/// Set it in cloudflare.config.ts (`R2_ACCOUNT_ID: bindings.text("<id>"),` in
21/// `worker.env`); the ID is on the dashboard's R2 overview page. Not a secret.
22///
23/// # Examples
24///
25/// ```
26/// assert_eq!(ocre::storage::R2_ACCOUNT_ID, "R2_ACCOUNT_ID");
27/// ```
28pub const R2_ACCOUNT_ID: &str = "R2_ACCOUNT_ID";
29
30/// Worker variable holding the R2 bucket name (`<app>-storage`), for presigned R2 URLs.
31///
32/// The `STORAGE` binding knows its bucket, but R2's S3 API needs the name in
33/// the URL: set `R2_BUCKET: bindings.text("<app>-storage"),` in `worker.env`.
34///
35/// # Examples
36///
37/// ```
38/// assert_eq!(ocre::storage::R2_BUCKET, "R2_BUCKET");
39/// ```
40pub const R2_BUCKET: &str = "R2_BUCKET";
41
42/// Worker secret holding the access key ID of an R2 API token, for presigned R2 URLs.
43///
44/// Create the token in the dashboard (R2 > Manage API tokens, "Object Read &
45/// Write" on the bucket), put it in `.dev.vars` and upload it with `ocre
46/// secrets push R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY --file .prod.vars`.
47///
48/// # Examples
49///
50/// ```
51/// assert_eq!(ocre::storage::R2_ACCESS_KEY_ID, "R2_ACCESS_KEY_ID");
52/// ```
53pub const R2_ACCESS_KEY_ID: &str = "R2_ACCESS_KEY_ID";
54
55/// Worker secret holding the secret access key of an R2 API token, for presigned R2 URLs.
56///
57/// Shown once when the token is created; set it like [`R2_ACCESS_KEY_ID`].
58/// It also signs the keys handed out by [`direct_upload`](crate::storage::direct_upload),
59/// so replacing it invalidates uploads in progress.
60///
61/// # Examples
62///
63/// ```
64/// assert_eq!(ocre::storage::R2_SECRET_ACCESS_KEY, "R2_SECRET_ACCESS_KEY");
65/// ```
66pub const R2_SECRET_ACCESS_KEY: &str = "R2_SECRET_ACCESS_KEY";
67
68/// Longest lifetime of a presigned URL, in seconds: 7 days, the SigV4 maximum.
69///
70/// # Examples
71///
72/// ```
73/// assert_eq!(ocre::storage::MAX_EXPIRES_IN, 7 * 24 * 3600);
74/// ```
75pub const MAX_EXPIRES_IN: u64 = 604_800;
76
77/// An S3-compatible bucket and the credentials to presign URLs for it.
78///
79/// [`S3Endpoint::r2`] builds the one of an R2 bucket; the runtime helpers
80/// ([`presign_get`](crate::storage::presign_get),
81/// [`direct_upload`](crate::storage::direct_upload)...) read it from the
82/// `R2_*` variables and secrets. Fill the fields yourself for another
83/// S3-compatible store (AWS S3, MinIO...). `Debug` hides the secret.
84///
85/// # Examples
86///
87/// ```
88/// use ocre::storage::S3Endpoint;
89///
90/// let r2 = S3Endpoint::r2("0123abcd", "blog-storage", "AKID", "s3cr3t");
91/// assert_eq!(r2.host, "0123abcd.r2.cloudflarestorage.com");
92/// assert_eq!(r2.region, "auto");
93/// assert!(!format!("{r2:?}").contains("s3cr3t"));
94/// ```
95#[derive(Clone, PartialEq, Eq)]
96pub struct S3Endpoint {
97    /// Host name of the S3 API, without scheme (`<account_id>.r2.cloudflarestorage.com`).
98    pub host: String,
99    /// Signing region: `auto` for R2.
100    pub region: String,
101    /// Bucket name, put first in the path (`/<bucket>/<key>`, path style); empty when the host already names the bucket.
102    pub bucket: String,
103    /// Access key ID of the credentials.
104    pub access_key_id: String,
105    /// Secret access key of the credentials.
106    pub secret_access_key: String,
107}
108
109impl fmt::Debug for S3Endpoint {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        f.debug_struct("S3Endpoint")
112            .field("host", &self.host)
113            .field("region", &self.region)
114            .field("bucket", &self.bucket)
115            .field("access_key_id", &self.access_key_id)
116            .field("secret_access_key", &"[redacted]")
117            .finish()
118    }
119}
120
121impl S3Endpoint {
122    /// The S3 endpoint of an R2 bucket: host `<account_id>.r2.cloudflarestorage.com`, region `auto`, path-style URLs.
123    ///
124    /// # Examples
125    ///
126    /// ```
127    /// let r2 = ocre::storage::S3Endpoint::r2("acc", "blog-storage", "AKID", "secret");
128    /// assert_eq!((r2.host.as_str(), r2.bucket.as_str()), ("acc.r2.cloudflarestorage.com", "blog-storage"));
129    /// ```
130    pub fn r2(account_id: &str, bucket: &str, access_key_id: &str, secret_access_key: &str) -> Self {
131        Self {
132            host: format!("{account_id}.r2.cloudflarestorage.com"),
133            region: "auto".to_owned(),
134            bucket: bucket.to_owned(),
135            access_key_id: access_key_id.to_owned(),
136            secret_access_key: secret_access_key.to_owned(),
137        }
138    }
139
140    /// Presigns a request on `key` (AWS SigV4, query-string form) and returns its `https://` URL.
141    ///
142    /// `headers` are signed too (`host` always is): the client must send
143    /// them with exactly these values, or the store refuses the request.
144    /// `query` adds parameters such as `response-content-disposition`. The
145    /// payload is not signed (`UNSIGNED-PAYLOAD`). `now` is the Unix time
146    /// of signing ([`ocre::now()`](crate::now)); the URL works for
147    /// `expires_in` seconds. Pure: no binding, a few microseconds of CPU.
148    ///
149    /// # Errors
150    ///
151    /// [`Error::Internal`] when `expires_in` is 0 or over [`MAX_EXPIRES_IN`] (7 days).
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// use ocre::storage::S3Endpoint;
157    ///
158    /// let r2 = S3Endpoint::r2("acc", "blog-storage", "AKID", "secret");
159    /// let url = r2.presign("GET", "exports/report 1.csv", &[], &[], 1_790_000_000, 300).unwrap();
160    /// assert!(url.starts_with("https://acc.r2.cloudflarestorage.com/blog-storage/exports/report%201.csv?X-Amz-Algorithm="));
161    /// assert!(url.contains("&X-Amz-Expires=300&"));
162    /// assert!(r2.presign("GET", "k", &[], &[], 1_790_000_000, 8 * 24 * 3600).is_err());
163    /// ```
164    pub fn presign(
165        &self,
166        method: &str,
167        key: &str,
168        headers: &[(&str, &str)],
169        query: &[(&str, &str)],
170        now: i64,
171        expires_in: u64,
172    ) -> Result<String> {
173        if expires_in == 0 || expires_in > MAX_EXPIRES_IN {
174            return Err(Error::internal(format!(
175                "a presigned URL lasts 1 to {MAX_EXPIRES_IN} seconds (7 days), not {expires_in}"
176            )));
177        }
178        let (date, timestamp) = amz_date(now);
179        let scope = format!("{date}/{}/s3/aws4_request", self.region);
180        let path = match self.bucket.as_str() {
181            "" => format!("/{}", encode(key, false)),
182            bucket => format!("/{}/{}", encode(bucket, false), encode(key, false)),
183        };
184        let mut signed: BTreeMap<String, String> =
185            headers.iter().map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_owned())).collect();
186        signed.insert("host".to_owned(), self.host.clone());
187        let signed_names = signed.keys().map(String::as_str).collect::<Vec<_>>().join(";");
188        let credential = format!("{}/{scope}", self.access_key_id);
189        let expires = expires_in.to_string();
190        let mut params: Vec<(String, String)> = [
191            ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"),
192            ("X-Amz-Credential", credential.as_str()),
193            ("X-Amz-Date", timestamp.as_str()),
194            ("X-Amz-Expires", expires.as_str()),
195            ("X-Amz-SignedHeaders", signed_names.as_str()),
196        ]
197        .iter()
198        .chain(query)
199        .map(|(name, value)| (encode(name, true), encode(value, true)))
200        .collect();
201        params.sort();
202        let canonical_query =
203            params.iter().map(|(name, value)| format!("{name}={value}")).collect::<Vec<_>>().join("&");
204        let canonical_headers: String = signed.iter().map(|(name, value)| format!("{name}:{value}\n")).collect();
205        let method = method.to_ascii_uppercase();
206        let canonical_request =
207            format!("{method}\n{path}\n{canonical_query}\n{canonical_headers}\n{signed_names}\nUNSIGNED-PAYLOAD");
208        let string_to_sign =
209            format!("AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{}", hex(&Sha256::digest(canonical_request.as_bytes())));
210        let mut key = hmac(format!("AWS4{}", self.secret_access_key).as_bytes(), date.as_bytes());
211        for part in [self.region.as_str(), "s3", "aws4_request"] {
212            key = hmac(&key, part.as_bytes());
213        }
214        let signature = hex(&hmac(&key, string_to_sign.as_bytes()));
215        Ok(format!("https://{}{path}?{canonical_query}&X-Amz-Signature={signature}", self.host))
216    }
217}
218
219/// What the browser declares before a direct upload: the file's name, type and size.
220///
221/// The JSON body of the "start an upload" request (`{"filename": "a.png",
222/// "content_type": "image/png", "size": 1234}`), read from `File.name`,
223/// `File.type` and `File.size`. [`direct_upload`](crate::storage::direct_upload)
224/// checks it against [`Rules`] before signing anything.
225///
226/// # Examples
227///
228/// ```
229/// let request: ocre::storage::DirectUploadRequest =
230///     serde_json::from_str(r#"{"filename":"a.png","content_type":"image/png","size":1234}"#).unwrap();
231/// assert_eq!(request.size, 1234);
232/// ```
233#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
234pub struct DirectUploadRequest {
235    /// File name, as the browser gives it (cleaned up when attached).
236    pub filename: String,
237    /// Declared content type; signed into the upload URL, so R2 stores this one.
238    pub content_type: String,
239    /// Declared size in bytes; signed into the upload URL as `Content-Length`.
240    pub size: u64,
241}
242
243/// A direct upload the browser may perform: `PUT` the file to `url` with `headers`, then submit `signed_key`.
244///
245/// Returned by [`direct_upload`](crate::storage::direct_upload) and sent to
246/// the browser as JSON (Rails' `direct_upload: { url, headers }` plus
247/// `signed_id`). `signed_key` is the new object key followed by an HMAC:
248/// [`attach_direct_upload`](crate::storage::attach_direct_upload) accepts
249/// only keys this app issued, so a client cannot claim another record's file.
250///
251/// # Examples
252///
253/// ```
254/// use ocre::storage::{DirectUpload, DirectUploadRequest, Rules, S3Endpoint};
255///
256/// const PHOTO: Rules = Rules { max_bytes: 1024, content_types: &["image/png"] };
257/// let r2 = S3Endpoint::r2("acc", "blog-storage", "AKID", "secret");
258/// let request = DirectUploadRequest { filename: "a.png".into(), content_type: "image/png".into(), size: 10 };
259/// let upload = DirectUpload::sign(&r2, "uploads/photos", "image", &request, &PHOTO, 1_790_000_000, 600).unwrap();
260/// assert!(upload.signed_key.starts_with("uploads/photos/"));
261/// assert_eq!(upload.headers["Content-Type"], "image/png");
262/// let json = serde_json::to_value(&upload).unwrap();
263/// assert!(json["url"].as_str().unwrap().contains("X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost"));
264/// ```
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct DirectUpload {
267    /// The object key and its signature (`<prefix>/<22 characters>.<43 characters>`); the form submits it.
268    pub signed_key: String,
269    /// Presigned `PUT` URL on R2's S3 API.
270    pub url: String,
271    /// Headers the `PUT` must carry, exactly (`Content-Type`); the browser adds `Content-Length` itself.
272    pub headers: BTreeMap<String, String>,
273}
274
275impl DirectUpload {
276    /// Checks `request` against `rules` and presigns a `PUT` of a new key under `prefix`, valid `expires_in` seconds.
277    ///
278    /// The URL signs `Content-Type` (the declared type, normalized) and
279    /// `Content-Length` (the declared size), so R2 refuses a `PUT` of
280    /// another type or size. [`direct_upload`](crate::storage::direct_upload)
281    /// calls it with the `R2_*` settings and [`ocre::now()`](crate::now).
282    ///
283    /// # Errors
284    ///
285    /// [`Error::Invalid`] (422) on `field` when the declared size or type
286    /// breaks `rules` (the messages of [`Validator::file`]);
287    /// [`Error::Internal`] when `expires_in` is out of range.
288    ///
289    /// # Examples
290    ///
291    /// ```
292    /// use ocre::storage::{DirectUpload, DirectUploadRequest, Rules, S3Endpoint};
293    ///
294    /// const PHOTO: Rules = Rules { max_bytes: 1024, content_types: &["image/png"] };
295    /// let r2 = S3Endpoint::r2("acc", "blog-storage", "AKID", "secret");
296    /// let svg = DirectUploadRequest { filename: "a.svg".into(), content_type: "image/svg+xml".into(), size: 10 };
297    /// let err = DirectUpload::sign(&r2, "uploads", "image", &svg, &PHOTO, 1_790_000_000, 600).unwrap_err();
298    /// assert_eq!(err.to_string(), "invalid: Image has an unsupported type (allowed: image/png)");
299    /// ```
300    pub fn sign(
301        endpoint: &S3Endpoint,
302        prefix: &str,
303        field: &str,
304        request: &DirectUploadRequest,
305        rules: &Rules,
306        now: i64,
307        expires_in: u64,
308    ) -> Result<Self> {
309        Validator::new().file_size_and_type(field, request.size, &request.content_type, rules).finish()?;
310        let key = new_key(prefix);
311        let content_type = essence(&request.content_type);
312        let size = request.size.to_string();
313        let headers = [("content-type", content_type.as_str()), ("content-length", size.as_str())];
314        let url = endpoint.presign("PUT", &key, &headers, &[], now, expires_in)?;
315        let signed_key = sign_key(&endpoint.secret_access_key, &key);
316        Ok(Self { signed_key, url, headers: BTreeMap::from([("Content-Type".to_owned(), content_type)]) })
317    }
318}
319
320/// `<key>.<HMAC-SHA256 of the key, URL-safe base64>`, with a key derived from the R2 secret.
321pub(crate) fn sign_key(secret: &str, key: &str) -> String {
322    let mac = hmac(&hmac(secret.as_bytes(), b"ocre.storage.direct_upload"), key.as_bytes());
323    format!("{key}.{}", URL_SAFE_NO_PAD.encode(mac))
324}
325
326/// The key of a `signed_key` made by [`sign_key`] with `secret`; a 422 on `field` when it was not.
327pub(crate) fn verify_key(field: &str, secret: &str, signed_key: &str) -> Result<String> {
328    let key = signed_key.rsplit_once('.').map(|(key, _)| key).unwrap_or_default();
329    let valid = constant_time_eq(sign_key(secret, key).as_bytes(), signed_key.as_bytes());
330    Validator::new().check(field, !valid, "is not a valid upload").finish()?;
331    Ok(key.to_owned())
332}
333
334/// The secret that signs upload keys: `R2_SECRET_ACCESS_KEY` (which direct
335/// uploads need anyway), else `SECRET_KEY_BASE` for uploads that go through
336/// the Worker.
337pub(crate) fn upload_secret(var: &dyn Fn(&str) -> Option<String>) -> Result<String> {
338    [R2_SECRET_ACCESS_KEY, crate::session::SECRET_KEY_BASE]
339        .iter()
340        .find_map(|name| var(name).map(|value| value.trim().to_owned()).filter(|value| !value.is_empty()))
341        .ok_or_else(|| {
342            Error::internal(
343                "uploads need R2_SECRET_ACCESS_KEY or SECRET_KEY_BASE to sign their keys (neither is set). Fix: \
344                 `ocre secret` makes a SECRET_KEY_BASE for .dev.vars; `ocre deploy` sets it in production",
345            )
346        })
347}
348
349/// The endpoint of the `R2_*` variables and secrets read with `var`; the error names every missing one.
350pub(crate) fn r2_endpoint(var: &dyn Fn(&str) -> Option<String>) -> Result<S3Endpoint> {
351    let names = [R2_ACCOUNT_ID, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY];
352    let values = names.map(|name| var(name).map(|value| value.trim().to_owned()).filter(|v| !v.is_empty()));
353    if let [Some(account), Some(bucket), Some(id), Some(secret)] = &values {
354        return Ok(S3Endpoint::r2(account, bucket, id, secret));
355    }
356    let missing: Vec<&str> = names.iter().zip(&values).filter(|(_, value)| value.is_none()).map(|(n, _)| *n).collect();
357    Err(Error::internal(format!(
358        "presigned R2 URLs need {} (not set). Fix: add `R2_ACCOUNT_ID: bindings.text(\"<account id>\"),` and \
359         `R2_BUCKET: bindings.text(\"<app>-storage\"),` to worker.env in cloudflare.config.ts; create an R2 API \
360         token (dashboard: R2 > Manage API tokens, Object Read & Write), put R2_ACCESS_KEY_ID and \
361         R2_SECRET_ACCESS_KEY in .dev.vars and run `ocre secrets push R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY \
362         --file .prod.vars`",
363        missing.join(", ")
364    )))
365}
366
367/// Presigned `GET` of an attachment: R2 answers with a safe `Content-Type` and `Content-Disposition`.
368pub(crate) fn presign_get_url(
369    endpoint: &S3Endpoint,
370    attachment: &Attachment,
371    disposition: Disposition,
372    now: i64,
373    expires_in: u64,
374) -> Result<String> {
375    let binary = BINARY_TYPES.contains(&attachment.content_type.as_str());
376    let content_type = if binary { "application/octet-stream" } else { &attachment.content_type };
377    let disposition = content_disposition(disposition, &attachment.filename, &attachment.content_type);
378    let query = [("response-content-disposition", disposition.as_str()), ("response-content-type", content_type)];
379    endpoint.presign("GET", &attachment.key, &[], &query, now, expires_in)
380}
381
382/// The attachment of a direct upload found by `head`, after checking it against `rules`.
383pub(crate) fn attachment_from_head(
384    field: &str,
385    head: Option<&StoredObject>,
386    filename: &str,
387    rules: &Rules,
388) -> Result<Attachment> {
389    let mut v = Validator::new();
390    match head {
391        None => {
392            v.check(field, true, "was not uploaded");
393        }
394        Some(object) => {
395            v.file_size_and_type(field, object.size, &object.content_type, rules);
396        }
397    }
398    v.finish()?;
399    let object = head.expect("the validator refused a missing upload");
400    Ok(object.attachment(filename))
401}
402
403/// `(yyyymmdd, yyyymmddThhmmssZ)` of a Unix time, in UTC.
404fn amz_date(now: i64) -> (String, String) {
405    let (year, month, day) = civil_from_days(now.div_euclid(86_400));
406    let seconds = now.rem_euclid(86_400);
407    let date = format!("{year:04}{month:02}{day:02}");
408    let time = format!("{date}T{:02}{:02}{:02}Z", seconds / 3600, seconds % 3600 / 60, seconds % 60);
409    (date, time)
410}
411
412/// RFC 3986 percent-encoding of everything but unreserved characters (and `/` in paths).
413fn encode(text: &str, slash: bool) -> String {
414    let mut out = String::with_capacity(text.len());
415    for byte in text.bytes() {
416        if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) || (byte == b'/' && !slash) {
417            out.push(byte as char);
418        } else {
419            write!(out, "%{byte:02X}").expect("writing to a String");
420        }
421    }
422    out
423}
424
425fn hmac(key: &[u8], data: &[u8]) -> Vec<u8> {
426    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC takes any key length");
427    mac.update(data);
428    mac.finalize().into_bytes().to_vec()
429}
430
431fn hex(bytes: &[u8]) -> String {
432    bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut out, byte| {
433        write!(out, "{byte:02x}").expect("writing to a String");
434        out
435    })
436}
437
438#[cfg(test)]
439#[path = "../../tests/storage/presign.rs"]
440mod tests;