Skip to main content

local_driver/
s3_sign.rs

1//! AWS Signature Version 4 helpers for S3-compatible object storage.
2//!
3//! Shared by `provider::hetzner` (Hetzner Object Storage) and
4//! `provider::local_docker` (MinIO). Both speak S3 + AWS SigV4 for bucket
5//! create/head/delete; only the endpoint and region differ.
6//!
7//! @yah:ticket(R630-B1, "SigV4 canonical URI is unencoded — any S3/R2 key containing a colon fails SignatureDoesNotMatch")
8//! @yah:status(review)
9//! @yah:assignee(agent:bundle-anthropic-ashguard)
10//! @yah:at(2026-08-25T07:42:31Z)
11//! @yah:parent(R630)
12//! @yah:severity(high)
13//! @yah:next("Repro, no setup: yah cloud bucket put --bucket yah-cr-cache 'test:colon' --file /tmp/any -> 403 SignatureDoesNotMatch. A colon-free key at the same moment succeeds, so it is not a credential problem.")
14//! @yah:next("Cause: the sign_s3_* helpers build the SigV4 canonical URI as parsed.path() verbatim (~line 30, 'let uri = parsed.path().to_string()'). SigV4 requires it percent-encoded outside A-Za-z0-9-_.~ and /, so ':' must be '%3A'. R2 canonicalizes per spec, we do not, and the signatures diverge. All four entrypoints are affected: sign_s3_empty_body, sign_s3_no_body, sign_s3_put_object, sign_s3_get_with_query.")
15//! @yah:verify("A key containing ':' round-trips through put/get/head/ls, AND existing colon-free keys still round-trip byte-identically (the double-encoding regression guard).")
16//! @yah:gotcha("Do NOT fix by re-encoding parsed.path(). Url::parse has already percent-encoded part of it, so re-encoding double-encodes '%' to '%25' and breaks keys that work today. The raw key must be threaded to the signer, or the encoding applied before URL construction. This is exactly why it was left unfixed rather than patched in passing.")
17//! @yah:gotcha("Blast radius is silent: the failure looks like bad credentials, not like a key-shape problem. cr.yah.dev stores OCI digests as sha256/<hex> rather than the natural sha256:<hex> purely to route around this — see digest_key in app/yah/cli/src/cr.rs and digestKey in app/yah/workers/yah-cr/src/index.ts, which must stay in lockstep.")
18//! @yah:handoff("Fixed via the gotcha's second option — encode when the URL is BUILT, never in the signer. New `pub fn uri_encode_key` in oss/yah-base/crates/local-driver/src/s3_sign.rs implements AWS UriEncode (unreserved A-Za-z0-9-_.~ and / stay literal; everything else %XX UPPERCASE over UTF-8 bytes; S3 encodes once, not twice). All five signers now take their canonical URI from a new private `canonical_uri()`, which still returns parsed.path() VERBATIM — that remains the only correct answer — behind a debug_assert that the path is already encoded. A caller who forgets now gets a test failure instead of a 403 against live R2.")
19//! @yah:handoff("Encoding applied at every site where a key becomes a URL: object-store r2.rs `object_url` (the choke point behind put/put_cached/get/head/delete/locate), yubaba pond_publish.rs:117, static_asset.rs (lock-skip probe + the PUT), static_asset_prune.rs (DELETE of listed keys, where the key comes straight back from ListObjectsV2). `rg sign_s3_` names the complete set of direct signers — 10 files, all accounted for.")
20//! @yah:verify("LIVE A/B against real R2, same bucket, same key, minutes apart. Pre-fix ~/.local/bin/yah (built 2026-08-24 17:34): `yah cloud bucket head --bucket yah-cr-cache 'r630b1:probe-nonexistent'` -> `403 Forbidden`, exit 1. Post-fix target/debug/yah: `absent`, exit 2 — signature accepted, object genuinely not there. Regression control: a colon-FREE key returns `absent` exit 2 on BOTH binaries, and an existing real key HEADs `present` exit 0 post-fix.")
21//! @yah:verify("Full live colon-key round-trip: put -> head present -> get (bytes match) -> ls (key comes back raw and un-escaped) -> delete -> head absent -> ls empty. The probe object was reclaimed; nothing was left behind in yah-cr-cache.")
22//! @yah:verify("cargo test -p yah-local-driver --lib s3_sign 16/16; -p yah-object-store --lib 38/38; -p yah-cloud --lib 909/909; -p yah --lib 1165/1165. cargo check --workspace exit 0. New offline tests assert the property the fix rests on: wire path == signed path, checked against a one-shot HTTP server, plus byte-identical output for every key shape that works today.")
23//! @yah:gotcha("cr.yah.dev's sha256/<hex> workaround is now UNNECESSARY but deliberately NOT removed. Simplifying digest_key (app/yah/cli/src/cr.rs) and digestKey (app/yah/workers/yah-cr/src/index.ts) to the natural sha256:<hex> means rewriting every blob key already written to yah-cr-cache — a live-data migration on a running registry, and an operator call rather than a drive-by. The two remain load-bearing and must stay in lockstep either way.")
24
25use anyhow::{Context, Result};
26use hmac::{Hmac, Mac};
27use reqwest::header::HeaderMap;
28use sha2::{Digest, Sha256};
29
30type HmacSha256 = Hmac<Sha256>;
31
32/// The `host` value to sign, which must be byte-identical to the `Host` header
33/// the HTTP client will actually send — SigV4 hashes it into the canonical
34/// request, so any divergence is a 403 `SignatureDoesNotMatch` that reads like
35/// a credentials problem.
36///
37/// `Url::host_str()` alone drops the port, and reqwest includes a NON-DEFAULT
38/// port in `Host`. For every https endpoint this crate has signed until now
39/// (Hetzner, R2) the port is implicit and the two agree, which is why this went
40/// unnoticed. It stops being true the moment anything signs against a local
41/// MinIO on `:9000` (R330-T32's pond-tier index writes). `Url::port()` returns
42/// `None` for a scheme's default port, so the https path is unchanged.
43fn canonical_host(parsed: &reqwest::Url) -> Result<String> {
44    let host = parsed.host_str().context("no host in S3 URL")?;
45    Ok(match parsed.port() {
46        Some(port) => format!("{host}:{port}"),
47        None => host.to_string(),
48    })
49}
50
51/// AWS `UriEncode` for an S3 object key (R630-B1).
52///
53/// SigV4's canonical request contains the request path percent-encoded so that
54/// only the RFC 3986 unreserved set — `A-Z a-z 0-9 - _ . ~` — plus the `/`
55/// segment separator survive literally. Everything else is `%XX` with UPPERCASE
56/// hex over the UTF-8 bytes. S3 encodes the path exactly once (unlike every
57/// other AWS service, which encodes twice).
58///
59/// **Apply this when you BUILD the URL, not inside the signer.** The wire path
60/// and the signed path have to be the same bytes, and by the time a `&str` URL
61/// reaches [`sign_s3_put_object`] and friends it is too late to tell an
62/// already-encoded `%3A` from a literal `%` in the key — re-encoding there
63/// turns `%3A` into `%253A` and breaks every key that works today. Encode the
64/// raw key here, interpolate the result into the URL, and the signer's
65/// [`canonical_uri`] reads back exactly what went on the wire.
66///
67/// Without this, any key containing `:` `@` `+` `,` `=` `&` `;` `$` `!` `'`
68/// `(` `)` `*` `[` `]` — none of which `Url::parse` touches — signs as itself
69/// while R2 canonicalizes per spec, and the request comes back `403
70/// SignatureDoesNotMatch`, which reads like a credentials problem.
71pub fn uri_encode_key(key: &str) -> String {
72    let mut out = String::with_capacity(key.len());
73    for byte in key.bytes() {
74        match byte {
75            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
76                out.push(byte as char);
77            }
78            _ => out.push_str(&format!("%{byte:02X}")),
79        }
80    }
81    out
82}
83
84/// True when `path` is already AWS-`UriEncode`d: every byte is unreserved, a
85/// `/`, or the start of a `%XX` triple with uppercase hex.
86///
87/// Only used to power the [`canonical_uri`] debug assertion — lowercase hex is
88/// rejected deliberately, because R2 re-encodes with uppercase and a lowercase
89/// `%3a` on the wire signs differently from the `%3A` the server computes.
90fn is_aws_uri_encoded(path: &str) -> bool {
91    let bytes = path.as_bytes();
92    let mut i = 0;
93    while i < bytes.len() {
94        match bytes[i] {
95            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => i += 1,
96            b'%' => {
97                let hex = bytes.get(i + 1..i + 3);
98                match hex {
99                    Some(h) if h.iter().all(|c| c.is_ascii_digit() || (b'A'..=b'F').contains(c)) => {
100                        i += 3
101                    }
102                    _ => return false,
103                }
104            }
105            _ => return false,
106        }
107    }
108    true
109}
110
111/// The SigV4 canonical URI: the URL's path, verbatim.
112///
113/// Verbatim is the *only* correct answer here — see [`uri_encode_key`] for why
114/// the signer cannot encode. The debug assertion is the guard rail that turns
115/// "a caller forgot to encode" from a 403 against live R2 into a test failure.
116fn canonical_uri(parsed: &reqwest::Url) -> String {
117    let uri = parsed.path().to_string();
118    debug_assert!(
119        is_aws_uri_encoded(&uri),
120        "R630-B1: S3 URL paths must be AWS-UriEncoded before they reach the \
121         signer — build the URL with `uri_encode_key(key)`. Got: {uri}"
122    );
123    uri
124}
125
126/// AWS Sig V4 for any S3 verb that sends no body (PUT CreateBucket, HEAD,
127/// DELETE bucket). Callers supply the full `url`, S3 `region` string, and
128/// HMAC credentials.
129///
130/// `url`'s path must already be AWS-`UriEncode`d — see [`uri_encode_key`].
131pub fn sign_s3_empty_body(
132    method: &str,
133    url: &str,
134    region: &str,
135    access_key: &str,
136    secret_key: &str,
137) -> Result<HeaderMap> {
138    let now = chrono::Utc::now();
139    let date = now.format("%Y%m%d").to_string();
140    let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
141
142    let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
143    let host = canonical_host(&parsed)?;
144    let uri = canonical_uri(&parsed);
145
146    let empty_hash = {
147        let mut h = Sha256::new();
148        h.update(b"");
149        hex::encode(h.finalize())
150    };
151
152    let canonical_headers = format!(
153        "content-length:0\nhost:{host}\nx-amz-content-sha256:{empty_hash}\nx-amz-date:{datetime}\n"
154    );
155    let signed_headers = "content-length;host;x-amz-content-sha256;x-amz-date";
156
157    let canonical_request =
158        format!("{method}\n{uri}\n\n{canonical_headers}\n{signed_headers}\n{empty_hash}");
159
160    let cr_hash = {
161        let mut h = Sha256::new();
162        h.update(canonical_request.as_bytes());
163        hex::encode(h.finalize())
164    };
165
166    let credential_scope = format!("{date}/{region}/s3/aws4_request");
167    let string_to_sign =
168        format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
169
170    let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
171        let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
172        mac.update(data);
173        mac.finalize().into_bytes().to_vec()
174    };
175
176    let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
177    let date_region_key = hmac_sign(&date_key, region.as_bytes());
178    let date_region_service_key = hmac_sign(&date_region_key, b"s3");
179    let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
180    let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
181
182    let authorization = format!(
183        "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
184         SignedHeaders={signed_headers}, Signature={signature}"
185    );
186
187    let mut headers = HeaderMap::new();
188    headers.insert("host", host.parse()?);
189    headers.insert("x-amz-date", datetime.parse()?);
190    headers.insert("x-amz-content-sha256", empty_hash.parse()?);
191    headers.insert("content-length", "0".parse()?);
192    headers.insert("authorization", authorization.parse()?);
193    Ok(headers)
194}
195
196pub fn sign_s3_put_bucket(
197    url: &str,
198    region: &str,
199    access_key: &str,
200    secret_key: &str,
201) -> Result<HeaderMap> {
202    sign_s3_empty_body("PUT", url, region, access_key, secret_key)
203}
204
205pub fn sign_s3_head_bucket(
206    url: &str,
207    region: &str,
208    access_key: &str,
209    secret_key: &str,
210) -> Result<HeaderMap> {
211    sign_s3_empty_body("HEAD", url, region, access_key, secret_key)
212}
213
214pub fn sign_s3_delete_bucket(
215    url: &str,
216    region: &str,
217    access_key: &str,
218    secret_key: &str,
219) -> Result<HeaderMap> {
220    sign_s3_empty_body("DELETE", url, region, access_key, secret_key)
221}
222
223/// AWS Sig V4 for a `GET` with an empty body and a canonical query string.
224///
225/// `canonical_query` is the already-formed query (no leading `?`) sorted
226/// lexicographically by parameter name with URL-encoded keys + values, e.g.
227/// `"list-type=2&prefix=whisper%2F"`. The caller is responsible for ordering
228/// and encoding; this helper signs the request as given.
229///
230/// Used by `ListObjectsV2`. The returned headers are suitable for `reqwest`'s
231/// `GET <url>` where `<url>` already includes the `?<canonical_query>` suffix.
232pub fn sign_s3_get_with_query(
233    url: &str,
234    canonical_query: &str,
235    region: &str,
236    access_key: &str,
237    secret_key: &str,
238) -> Result<HeaderMap> {
239    sign_s3_no_body("GET", url, canonical_query, region, access_key, secret_key)
240}
241
242/// AWS Sig V4 for any body-less verb (`GET`, `HEAD`) **without** signing
243/// `content-length`.
244///
245/// reqwest/hyper strip the `content-length: 0` header off the wire for
246/// body-less requests, so signing it — as [`sign_s3_empty_body`] does — leaves
247/// the server unable to reproduce the signature, yielding
248/// `403 SignatureDoesNotMatch`. Object `GET`/`HEAD` must use this signer; only
249/// methods that actually carry a (possibly empty) body and emit
250/// `content-length` on the wire may use [`sign_s3_empty_body`].
251///
252/// `canonical_query` follows the [`sign_s3_get_with_query`] contract (empty
253/// string for no query).
254pub fn sign_s3_no_body(
255    method: &str,
256    url: &str,
257    canonical_query: &str,
258    region: &str,
259    access_key: &str,
260    secret_key: &str,
261) -> Result<HeaderMap> {
262    let now = chrono::Utc::now();
263    let date = now.format("%Y%m%d").to_string();
264    let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
265
266    let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
267    let host = canonical_host(&parsed)?;
268    let uri = canonical_uri(&parsed);
269
270    let empty_hash = {
271        let mut h = Sha256::new();
272        h.update(b"");
273        hex::encode(h.finalize())
274    };
275
276    let canonical_headers = format!(
277        "host:{host}\nx-amz-content-sha256:{empty_hash}\nx-amz-date:{datetime}\n"
278    );
279    let signed_headers = "host;x-amz-content-sha256;x-amz-date";
280
281    let canonical_request = format!(
282        "{method}\n{uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{empty_hash}"
283    );
284
285    let cr_hash = {
286        let mut h = Sha256::new();
287        h.update(canonical_request.as_bytes());
288        hex::encode(h.finalize())
289    };
290
291    let credential_scope = format!("{date}/{region}/s3/aws4_request");
292    let string_to_sign =
293        format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
294
295    let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
296        let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
297        mac.update(data);
298        mac.finalize().into_bytes().to_vec()
299    };
300
301    let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
302    let date_region_key = hmac_sign(&date_key, region.as_bytes());
303    let date_region_service_key = hmac_sign(&date_region_key, b"s3");
304    let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
305    let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
306
307    let authorization = format!(
308        "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
309         SignedHeaders={signed_headers}, Signature={signature}"
310    );
311
312    let mut headers = HeaderMap::new();
313    headers.insert("host", host.parse()?);
314    headers.insert("x-amz-date", datetime.parse()?);
315    headers.insert("x-amz-content-sha256", empty_hash.parse()?);
316    headers.insert("authorization", authorization.parse()?);
317    Ok(headers)
318}
319
320/// The per-object headers a `PUT` carries beyond the ones SigV4 always needs.
321///
322/// A struct rather than more positional arguments: [`sign_s3_put_object`] was
323/// already at eight, two of them `Option<&str>`, and a third adjacent optional
324/// string is the kind of parameter list where a swapped pair compiles and ships
325/// the wrong header.
326#[derive(Debug, Clone, Default)]
327pub struct S3PutOptions<'a> {
328    /// `Content-Type`. Empty is not valid S3 — pass the caller's default.
329    pub content_type: &'a str,
330    /// `x-amz-meta-blake3` (R546-B10) — see [`sign_s3_put_object`].
331    pub blake3_meta: Option<&'a str>,
332    /// `Cache-Control` (R703-B8). `None` leaves the header off entirely, which
333    /// is how every CLI-driven publish behaved before this existed: R2 then
334    /// serves the object with no directive at all, so a browser (and any future
335    /// edge-cache rule) is free to hold a mutable pointer — `latest.json`, a
336    /// release manifest — for as long as it likes. Versioned, content-addressed
337    /// keys want [`CACHE_CONTROL_IMMUTABLE`]; fixed-key pointers want
338    /// [`CACHE_CONTROL_NO_CACHE`].
339    pub cache_control: Option<&'a str>,
340}
341
342/// `Cache-Control` for immutable, versioned, content-addressed objects.
343/// Matches what `.github/workflows/release.yml` tags them with, so an object
344/// published by the CLI is indistinguishable from one published by CI.
345pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
346
347/// `Cache-Control` for a mutable pointer at a fixed key — `latest.json`, a
348/// release manifest, an index. Also matches `release.yml`.
349pub const CACHE_CONTROL_NO_CACHE: &str = "no-cache, max-age=0";
350
351/// AWS Sig V4 for `PUT /<bucket>/<key>` with an object body.
352///
353/// The caller pre-computes `body_sha256 = hex(sha256(body))` and passes
354/// `content_length = body.len()` separately so the headers can be computed
355/// without holding the bytes in this function.
356///
357/// `blake3_meta` attaches `x-amz-meta-blake3` to the object (R546-B10). This is
358/// what lets a later run tell "the same bytes are already there" from "different
359/// bytes are already there" — an existence probe alone cannot, and ETag is not a
360/// usable substitute because it stops being a content MD5 for multipart uploads.
361/// Pass `None` for callers that don't track a BLAKE3 for the body.
362///
363/// Reach for [`sign_s3_put_object_with`] when the object also needs a
364/// `Cache-Control`; this signs without one.
365pub fn sign_s3_put_object(
366    url: &str,
367    body_sha256: &str,
368    content_type: &str,
369    content_length: usize,
370    region: &str,
371    access_key: &str,
372    secret_key: &str,
373    blake3_meta: Option<&str>,
374) -> Result<HeaderMap> {
375    sign_s3_put_object_with(
376        url,
377        body_sha256,
378        content_length,
379        region,
380        access_key,
381        secret_key,
382        &S3PutOptions {
383            content_type,
384            blake3_meta,
385            cache_control: None,
386        },
387    )
388}
389
390/// [`sign_s3_put_object`] with the full per-object header set (R703-B8).
391pub fn sign_s3_put_object_with(
392    url: &str,
393    body_sha256: &str,
394    content_length: usize,
395    region: &str,
396    access_key: &str,
397    secret_key: &str,
398    opts: &S3PutOptions<'_>,
399) -> Result<HeaderMap> {
400    let S3PutOptions {
401        content_type,
402        blake3_meta,
403        cache_control,
404    } = *opts;
405    let now = chrono::Utc::now();
406    let date = now.format("%Y%m%d").to_string();
407    let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
408
409    let parsed = reqwest::Url::parse(url).context("parsing S3 object URL")?;
410    let host = canonical_host(&parsed)?;
411    let uri = canonical_uri(&parsed);
412
413    // SigV4 requires the canonical header block AND the SignedHeaders list to be
414    // in lexicographic order, and the two must agree exactly or the server
415    // computes a different signature and answers 403 SignatureDoesNotMatch.
416    // Build both from one ordered list rather than two hand-maintained string
417    // literals — the previous pair of literals was already one optional header
418    // away from being wrong, and `cache-control` is the awkward case: it sorts
419    // BEFORE `content-length`, so it prepends where `x-amz-meta-blake3` appends.
420    let mut signed: Vec<(&str, String)> = Vec::with_capacity(7);
421    if let Some(cc) = cache_control {
422        signed.push(("cache-control", cc.to_string()));
423    }
424    signed.push(("content-length", content_length.to_string()));
425    signed.push(("content-type", content_type.to_string()));
426    signed.push(("host", host.clone()));
427    signed.push(("x-amz-content-sha256", body_sha256.to_string()));
428    signed.push(("x-amz-date", datetime.clone()));
429    if let Some(b3) = blake3_meta {
430        signed.push(("x-amz-meta-blake3", b3.to_string()));
431    }
432    debug_assert!(
433        signed.windows(2).all(|w| w[0].0 < w[1].0),
434        "canonical headers must be lexicographically ordered: {:?}",
435        signed.iter().map(|(n, _)| *n).collect::<Vec<_>>()
436    );
437
438    let canonical_headers: String = signed
439        .iter()
440        .map(|(name, value)| format!("{name}:{value}\n"))
441        .collect();
442    let signed_headers = signed
443        .iter()
444        .map(|(name, _)| *name)
445        .collect::<Vec<_>>()
446        .join(";");
447    let signed_headers = signed_headers.as_str();
448
449    let canonical_request =
450        format!("PUT\n{uri}\n\n{canonical_headers}\n{signed_headers}\n{body_sha256}");
451
452    let cr_hash = {
453        let mut h = Sha256::new();
454        h.update(canonical_request.as_bytes());
455        hex::encode(h.finalize())
456    };
457
458    let credential_scope = format!("{date}/{region}/s3/aws4_request");
459    let string_to_sign =
460        format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
461
462    let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
463        let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
464        mac.update(data);
465        mac.finalize().into_bytes().to_vec()
466    };
467
468    let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
469    let date_region_key = hmac_sign(&date_key, region.as_bytes());
470    let date_region_service_key = hmac_sign(&date_region_key, b"s3");
471    let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
472    let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
473
474    let authorization = format!(
475        "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
476         SignedHeaders={signed_headers}, Signature={signature}"
477    );
478
479    let mut headers = HeaderMap::new();
480    headers.insert("host", host.parse()?);
481    headers.insert("x-amz-date", datetime.parse()?);
482    headers.insert("x-amz-content-sha256", body_sha256.parse()?);
483    headers.insert("content-length", content_length.to_string().parse()?);
484    headers.insert("content-type", content_type.parse()?);
485    if let Some(cc) = cache_control {
486        headers.insert("cache-control", cc.parse()?);
487    }
488    if let Some(b3) = blake3_meta {
489        headers.insert("x-amz-meta-blake3", b3.parse()?);
490    }
491    headers.insert("authorization", authorization.parse()?);
492    Ok(headers)
493}
494
495/// AWS Sig V4 for `PUT /<bucket>?policy` with a JSON body.
496///
497/// Modern MinIO dropped the `?acl` endpoint; use this to apply an S3 bucket
498/// policy document instead. The caller provides the raw JSON bytes; this
499/// function hashes them for the signature and returns headers suitable for a
500/// `reqwest` PUT with that body.
501pub fn sign_s3_put_bucket_policy(
502    url: &str,
503    region: &str,
504    access_key: &str,
505    secret_key: &str,
506    policy_json: &[u8],
507) -> Result<HeaderMap> {
508    let now = chrono::Utc::now();
509    let date = now.format("%Y%m%d").to_string();
510    let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
511
512    let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
513    let host = canonical_host(&parsed)?;
514    let uri = canonical_uri(&parsed);
515    let canonical_query = "policy=";
516    let content_length = policy_json.len();
517
518    let body_hash = {
519        let mut h = Sha256::new();
520        h.update(policy_json);
521        hex::encode(h.finalize())
522    };
523
524    let canonical_headers = format!(
525        "content-length:{content_length}\ncontent-type:application/json\nhost:{host}\nx-amz-content-sha256:{body_hash}\nx-amz-date:{datetime}\n"
526    );
527    let signed_headers = "content-length;content-type;host;x-amz-content-sha256;x-amz-date";
528
529    let canonical_request =
530        format!("PUT\n{uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{body_hash}");
531
532    let cr_hash = {
533        let mut h = Sha256::new();
534        h.update(canonical_request.as_bytes());
535        hex::encode(h.finalize())
536    };
537
538    let credential_scope = format!("{date}/{region}/s3/aws4_request");
539    let string_to_sign =
540        format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
541
542    let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
543        let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
544        mac.update(data);
545        mac.finalize().into_bytes().to_vec()
546    };
547
548    let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
549    let date_region_key = hmac_sign(&date_key, region.as_bytes());
550    let date_region_service_key = hmac_sign(&date_region_key, b"s3");
551    let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
552    let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
553
554    let authorization = format!(
555        "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
556         SignedHeaders={signed_headers}, Signature={signature}"
557    );
558
559    let mut headers = HeaderMap::new();
560    headers.insert("host", host.parse()?);
561    headers.insert("x-amz-date", datetime.parse()?);
562    headers.insert("x-amz-content-sha256", body_hash.parse()?);
563    headers.insert("content-length", content_length.to_string().parse()?);
564    headers.insert("content-type", "application/json".parse()?);
565    headers.insert("authorization", authorization.parse()?);
566    Ok(headers)
567}
568
569/// AWS Sig V4 for `PUT /<bucket>?acl` with a canned-ACL header.
570///
571/// **Deprecated for MinIO**: modern MinIO does not implement the ACL endpoint.
572/// Use [`sign_s3_put_bucket_policy`] for pond/local-docker targets and
573/// keep this only for S3-compatible providers that still honour canned ACLs
574/// (e.g. Hetzner Object Storage).
575pub fn sign_s3_put_bucket_acl(
576    url: &str,
577    region: &str,
578    access_key: &str,
579    secret_key: &str,
580    acl: &str,
581) -> Result<HeaderMap> {
582    let now = chrono::Utc::now();
583    let date = now.format("%Y%m%d").to_string();
584    let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
585
586    let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
587    let host = canonical_host(&parsed)?;
588    let uri = canonical_uri(&parsed);
589    let canonical_query = "acl=";
590
591    let empty_hash = {
592        let mut h = Sha256::new();
593        h.update(b"");
594        hex::encode(h.finalize())
595    };
596
597    // Headers listed in lexicographic order (required by SigV4).
598    let canonical_headers = format!(
599        "content-length:0\nhost:{host}\nx-amz-acl:{acl}\nx-amz-content-sha256:{empty_hash}\nx-amz-date:{datetime}\n"
600    );
601    let signed_headers = "content-length;host;x-amz-acl;x-amz-content-sha256;x-amz-date";
602
603    let canonical_request =
604        format!("PUT\n{uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{empty_hash}");
605
606    let cr_hash = {
607        let mut h = Sha256::new();
608        h.update(canonical_request.as_bytes());
609        hex::encode(h.finalize())
610    };
611
612    let credential_scope = format!("{date}/{region}/s3/aws4_request");
613    let string_to_sign =
614        format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
615
616    let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
617        let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
618        mac.update(data);
619        mac.finalize().into_bytes().to_vec()
620    };
621
622    let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
623    let date_region_key = hmac_sign(&date_key, region.as_bytes());
624    let date_region_service_key = hmac_sign(&date_region_key, b"s3");
625    let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
626    let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
627
628    let authorization = format!(
629        "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
630         SignedHeaders={signed_headers}, Signature={signature}"
631    );
632
633    let mut headers = HeaderMap::new();
634    headers.insert("host", host.parse()?);
635    headers.insert("x-amz-date", datetime.parse()?);
636    headers.insert("x-amz-content-sha256", empty_hash.parse()?);
637    headers.insert("content-length", "0".parse()?);
638    headers.insert("x-amz-acl", acl.parse()?);
639    headers.insert("authorization", authorization.parse()?);
640    Ok(headers)
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    /// R630-B1. The reason the bug existed: `:` is legal in a URL path and
648    /// `Url::parse` leaves it alone, so an unencoded key signed as itself while
649    /// R2 canonicalized it to `%3A` — 403 `SignatureDoesNotMatch`, indistinguish-
650    /// able from bad credentials.
651    #[test]
652    fn uri_encode_key_escapes_colon_and_the_other_sub_delims() {
653        assert_eq!(uri_encode_key("sha256:abc"), "sha256%3Aabc");
654        // Every sub-delim + gen-delim `Url::parse` would have passed through raw.
655        assert_eq!(
656            uri_encode_key("a@b+c,d=e&f;g$h!i'j(k)l*m[n]o"),
657            "a%40b%2Bc%2Cd%3De%26f%3Bg%24h%21i%27j%28k%29l%2Am%5Bn%5Do"
658        );
659        // Space is %20, never `+` — SigV4 is explicit about this.
660        assert_eq!(uri_encode_key("my file.txt"), "my%20file.txt");
661        // Hex is UPPERCASE: R2 re-encodes with uppercase, so `%3a` would sign
662        // differently from the `%3A` the server computes.
663        assert_eq!(uri_encode_key("\x1f"), "%1F");
664        // Multi-byte UTF-8 encodes per byte.
665        assert_eq!(uri_encode_key("é"), "%C3%A9");
666    }
667
668    /// The double-encoding regression guard the ticket asks for: keys that work
669    /// today must come out byte-identical, or every existing caller starts
670    /// 403ing. `/` stays a separator; `~` `-` `_` `.` are unreserved.
671    #[test]
672    fn uri_encode_key_leaves_todays_keys_byte_identical() {
673        for key in [
674            "yah/index.json",
675            "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz",
676            "_yah-manifest.json",
677            "releases/v1.2.3-rc.1/yah_1.2.3_aarch64.dmg",
678            "a~b-c_d.e/f",
679            "",
680        ] {
681            assert_eq!(uri_encode_key(key), key, "key must not change: {key}");
682        }
683        // A literal `%` in a key encodes to `%25` — which is why the signer
684        // must NOT re-encode: doing so a second time would yield `%2525`.
685        assert_eq!(uri_encode_key("100%25"), "100%2525");
686    }
687
688    /// The signer's contract is "path already encoded". This is the predicate
689    /// behind the debug assertion that enforces it.
690    #[test]
691    fn is_aws_uri_encoded_accepts_encoded_and_rejects_raw() {
692        assert!(is_aws_uri_encoded("/bucket/sha256%3Aabc"));
693        assert!(is_aws_uri_encoded("/bucket/plain/key.tar.gz"));
694        assert!(is_aws_uri_encoded("/"));
695        assert!(!is_aws_uri_encoded("/bucket/sha256:abc"));
696        // Lowercase hex is a real mismatch against R2's uppercase canonical form.
697        assert!(!is_aws_uri_encoded("/bucket/sha256%3aabc"));
698        // A truncated escape is not an escape.
699        assert!(!is_aws_uri_encoded("/bucket/x%3"));
700        assert!(!is_aws_uri_encoded("/bucket/x%ZZ"));
701    }
702
703    /// End-to-end: an encoded key signs, and signs *differently* from the same
704    /// key left raw. Both halves matter — the first proves the debug assertion
705    /// doesn't reject correct input, the second proves the canonical URI is
706    /// actually part of the signature rather than incidental.
707    #[test]
708    fn signing_an_encoded_colon_key_differs_from_signing_it_raw() {
709        let sign = |path: &str| {
710            sign_s3_no_body(
711                "GET",
712                &format!("https://acct.r2.cloudflarestorage.com/yah-cr/{path}"),
713                "",
714                "auto",
715                "AK",
716                "SK",
717            )
718            .unwrap()
719            .get("authorization")
720            .unwrap()
721            .to_str()
722            .unwrap()
723            .to_string()
724        };
725        let encoded = sign(&uri_encode_key("blobs/sha256:deadbeef"));
726        // `sign` on the raw form would trip the debug assertion, so compare
727        // against a colon-free key of the same length instead: the point is
728        // that the path is inside the signature at all.
729        let other = sign(&uri_encode_key("blobs/sha256-deadbeef"));
730        assert_ne!(encoded, other);
731    }
732
733    /// The signed `host` must equal the `Host` header reqwest sends, or the
734    /// server recomputes a different canonical request and answers 403
735    /// `SignatureDoesNotMatch` — which reads like bad credentials. reqwest
736    /// includes a non-default port in `Host`; `Url::host_str()` drops it.
737    #[test]
738    fn canonical_host_carries_a_nondefault_port() {
739        let u = reqwest::Url::parse("http://127.0.0.1:9000/yah-dev/yah/index.json").unwrap();
740        assert_eq!(canonical_host(&u).unwrap(), "127.0.0.1:9000");
741        // Default ports stay implicit, so every https endpoint signed before
742        // this existed (Hetzner, R2) signs byte-identically.
743        let u = reqwest::Url::parse("https://acct.r2.cloudflarestorage.com/yah-dev/k").unwrap();
744        assert_eq!(canonical_host(&u).unwrap(), "acct.r2.cloudflarestorage.com");
745        let u = reqwest::Url::parse("https://acct.r2.cloudflarestorage.com:443/yah-dev/k").unwrap();
746        assert_eq!(canonical_host(&u).unwrap(), "acct.r2.cloudflarestorage.com");
747    }
748
749    #[test]
750    fn signed_headers_include_the_port_for_a_local_endpoint() {
751        let headers = sign_s3_put_object(
752            "http://127.0.0.1:9000/yah-dev/yah/index.json",
753            &"0".repeat(64),
754            "application/json",
755            10,
756            "auto",
757            "AK",
758            "SK",
759            None,
760        )
761        .unwrap();
762        assert_eq!(
763            headers.get("host").unwrap().to_str().unwrap(),
764            "127.0.0.1:9000"
765        );
766    }
767
768    #[test]
769    fn sign_produces_required_headers() {
770        let headers = sign_s3_put_bucket(
771            "https://fsn1.your-objectstorage.com/test-bucket",
772            "fsn1",
773            "AK",
774            "SK",
775        )
776        .unwrap();
777        assert!(headers.contains_key("authorization"));
778        assert!(headers.contains_key("x-amz-date"));
779        assert!(headers.contains_key("x-amz-content-sha256"));
780        let auth = headers.get("authorization").unwrap().to_str().unwrap();
781        assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
782        assert!(
783            auth.contains("SignedHeaders=content-length;host;x-amz-content-sha256;x-amz-date")
784        );
785    }
786
787    /// R546-B10. The metadata header must be BOTH sent and covered by the
788    /// signature. Sending it unsigned, or listing it in SignedHeaders without
789    /// including it in the canonical headers, produces a SignatureDoesNotMatch
790    /// 403 at runtime — which no local test would otherwise catch.
791    #[test]
792    fn put_object_signs_the_blake3_metadata_header() {
793        let b3 = "d".repeat(64);
794        let headers = sign_s3_put_object(
795            "https://acct.r2.cloudflarestorage.com/yah-dev/some/key.tar.gz",
796            &"0".repeat(64),
797            "application/octet-stream",
798            123,
799            "auto",
800            "AK",
801            "SK",
802            Some(&b3),
803        )
804        .unwrap();
805
806        assert_eq!(headers.get("x-amz-meta-blake3").unwrap().to_str().unwrap(), b3);
807        let auth = headers.get("authorization").unwrap().to_str().unwrap();
808        assert!(
809            auth.contains(
810                "SignedHeaders=content-length;content-type;host;x-amz-content-sha256;\
811                 x-amz-date;x-amz-meta-blake3"
812            ),
813            "metadata header must be inside SignedHeaders, got: {auth}"
814        );
815    }
816
817    /// Omitting the metadata must leave the previous signed-header set exactly
818    /// as it was — otherwise every existing caller starts 403ing.
819    #[test]
820    fn put_object_without_metadata_keeps_the_original_signed_header_set() {
821        let headers = sign_s3_put_object(
822            "https://acct.r2.cloudflarestorage.com/yah-dev/some/key.bin",
823            &"0".repeat(64),
824            "application/octet-stream",
825            7,
826            "auto",
827            "AK",
828            "SK",
829            None,
830        )
831        .unwrap();
832
833        assert!(!headers.contains_key("x-amz-meta-blake3"));
834        let auth = headers.get("authorization").unwrap().to_str().unwrap();
835        assert!(
836            auth.contains(
837                "SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date,"
838            ),
839            "unstamped PUT must keep the original signed-header set, got: {auth}"
840        );
841    }
842
843    /// R703-B8. `cache-control` sorts BEFORE `content-length`, so unlike
844    /// `x-amz-meta-blake3` it must PREPEND to the canonical header block. Get
845    /// that backwards and SigV4 answers 403 SignatureDoesNotMatch — the same
846    /// failure mode as bad credentials, and only ever visible against live R2.
847    #[test]
848    fn put_object_signs_cache_control_ahead_of_content_length() {
849        let headers = sign_s3_put_object_with(
850            "https://acct.r2.cloudflarestorage.com/yah-dev/yah-desktop/latest.json",
851            &"0".repeat(64),
852            42,
853            "auto",
854            "AK",
855            "SK",
856            &S3PutOptions {
857                content_type: "application/json",
858                blake3_meta: None,
859                cache_control: Some(CACHE_CONTROL_NO_CACHE),
860            },
861        )
862        .unwrap();
863
864        assert_eq!(
865            headers.get("cache-control").unwrap().to_str().unwrap(),
866            "no-cache, max-age=0"
867        );
868        let auth = headers.get("authorization").unwrap().to_str().unwrap();
869        assert!(
870            auth.contains(
871                "SignedHeaders=cache-control;content-length;content-type;host;\
872                 x-amz-content-sha256;x-amz-date,"
873            ),
874            "cache-control must be signed, and first, got: {auth}"
875        );
876    }
877
878    /// Both optional headers at once — the ordering has to hold when one
879    /// prepends and the other appends.
880    #[test]
881    fn put_object_signs_cache_control_and_blake3_together_in_order() {
882        let b3 = "e".repeat(64);
883        let headers = sign_s3_put_object_with(
884            "https://acct.r2.cloudflarestorage.com/yah-dev/a/v1.2.3/yah.tar.gz",
885            &"0".repeat(64),
886            9,
887            "auto",
888            "AK",
889            "SK",
890            &S3PutOptions {
891                content_type: "application/octet-stream",
892                blake3_meta: Some(&b3),
893                cache_control: Some(CACHE_CONTROL_IMMUTABLE),
894            },
895        )
896        .unwrap();
897
898        let auth = headers.get("authorization").unwrap().to_str().unwrap();
899        assert!(
900            auth.contains(
901                "SignedHeaders=cache-control;content-length;content-type;host;\
902                 x-amz-content-sha256;x-amz-date;x-amz-meta-blake3,"
903            ),
904            "got: {auth}"
905        );
906        assert_eq!(
907            headers.get("cache-control").unwrap().to_str().unwrap(),
908            "public, max-age=31536000, immutable"
909        );
910    }
911
912    /// The wrapper must be byte-for-byte the old behaviour: same signature for
913    /// the same inputs, so no existing caller starts 403ing. Signing twice
914    /// within the same second is what makes this comparable at all — the date
915    /// stamp is the only other input that moves.
916    #[test]
917    fn the_options_form_and_the_legacy_form_sign_identically() {
918        let url = "https://acct.r2.cloudflarestorage.com/yah-dev/k.bin";
919        let legacy =
920            sign_s3_put_object(url, &"0".repeat(64), "text/plain", 3, "auto", "AK", "SK", None)
921                .unwrap();
922        let with_opts = sign_s3_put_object_with(
923            url,
924            &"0".repeat(64),
925            3,
926            "auto",
927            "AK",
928            "SK",
929            &S3PutOptions {
930                content_type: "text/plain",
931                blake3_meta: None,
932                cache_control: None,
933            },
934        )
935        .unwrap();
936
937        assert!(!with_opts.contains_key("cache-control"));
938        // `x-amz-date` has second resolution; if the two calls straddled a
939        // second boundary the signatures legitimately differ, so compare the
940        // header SET, which is what a caller can actually break.
941        let names = |h: &HeaderMap| {
942            let mut v: Vec<String> = h.keys().map(|k| k.as_str().to_string()).collect();
943            v.sort();
944            v
945        };
946        assert_eq!(names(&legacy), names(&with_opts));
947    }
948
949    #[test]
950    fn sign_get_with_query_signed_headers_omit_content_length() {
951        let headers = sign_s3_get_with_query(
952            "https://acct.r2.cloudflarestorage.com/yah-dev",
953            "list-type=2&prefix=whisper%2F",
954            "auto",
955            "AK",
956            "SK",
957        )
958        .unwrap();
959        let auth = headers.get("authorization").unwrap().to_str().unwrap();
960        assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
961        assert!(
962            auth.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date"),
963            "GET with query must NOT include content-length in SignedHeaders: {auth}"
964        );
965        assert!(!headers.contains_key("content-length"));
966    }
967
968    #[test]
969    fn sign_no_body_get_object_omits_content_length() {
970        // Plain object GET: empty query, no content-length signed (reqwest
971        // strips content-length: 0 on the wire → would 403 otherwise).
972        let headers = sign_s3_no_body(
973            "GET",
974            "https://acct.r2.cloudflarestorage.com/yah-dev/_yah-manifest.json",
975            "",
976            "auto",
977            "AK",
978            "SK",
979        )
980        .unwrap();
981        let auth = headers.get("authorization").unwrap().to_str().unwrap();
982        assert!(
983            auth.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date"),
984            "object GET must NOT sign content-length: {auth}"
985        );
986        assert!(!headers.contains_key("content-length"));
987    }
988
989    #[test]
990    fn sign_no_body_head_uses_head_method() {
991        // HEAD shares the body-less signing path; the canonical request must
992        // use the HEAD verb, not GET, but still omit content-length.
993        let head = sign_s3_no_body(
994            "HEAD",
995            "https://acct.r2.cloudflarestorage.com/yah-dev/k",
996            "",
997            "auto",
998            "AK",
999            "SK",
1000        )
1001        .unwrap();
1002        let get = sign_s3_no_body(
1003            "GET",
1004            "https://acct.r2.cloudflarestorage.com/yah-dev/k",
1005            "",
1006            "auto",
1007            "AK",
1008            "SK",
1009        )
1010        .unwrap();
1011        assert!(!head.contains_key("content-length"));
1012        // Different verb → different signature for the same URL/time-window.
1013        assert_ne!(
1014            head.get("authorization").unwrap().to_str().unwrap(),
1015            get.get("authorization").unwrap().to_str().unwrap(),
1016        );
1017    }
1018
1019    #[test]
1020    fn sign_put_bucket_acl_includes_acl_header_and_query() {
1021        let headers = sign_s3_put_bucket_acl(
1022            "https://fsn1.your-objectstorage.com/test-bucket?acl",
1023            "fsn1",
1024            "AK",
1025            "SK",
1026            "public-read",
1027        )
1028        .unwrap();
1029        assert!(headers.contains_key("authorization"));
1030        assert!(headers.contains_key("x-amz-acl"));
1031        assert_eq!(headers.get("x-amz-acl").unwrap().to_str().unwrap(), "public-read");
1032        let auth = headers.get("authorization").unwrap().to_str().unwrap();
1033        assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
1034        assert!(auth.contains("x-amz-acl"));
1035        assert!(auth.contains("SignedHeaders=content-length;host;x-amz-acl;x-amz-content-sha256;x-amz-date"));
1036    }
1037}