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;