Skip to main content

iceberg_catalog_rest/auth/
sigv4.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! AWS SigV4 request signing for the REST catalog.
19
20use chrono::{DateTime, Utc};
21use iceberg::{Error, ErrorKind, Result};
22use sha2::{Digest, Sha256};
23use typed_builder::TypedBuilder;
24
25use super::Credentials;
26
27/// How the payload hash is encoded in the `x-amz-content-sha256` header.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum PayloadHashMode {
31    /// Iceberg Java's style: a base64 header when there is a body, hex when
32    /// there is none, and hex in the canonical request. The base64 comes from
33    /// how Java configures the AWS SDK, not from SigV4, so a verifier that
34    /// trusts the header instead of hashing the body rejects it.
35    IcebergRest,
36    /// Standard SigV4: hex everywhere.
37    StandardAws,
38}
39
40fn encode_hex(bytes: &[u8]) -> String {
41    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
42}
43
44fn base64_encode(bytes: &[u8]) -> String {
45    base64::engine::Engine::encode(&base64::engine::general_purpose::STANDARD, bytes)
46}
47
48/// The content header and canonical payload hash, computed from one digest.
49fn payload_hashes(body: Option<&[u8]>, mode: PayloadHashMode) -> (String, String) {
50    let digest = Sha256::digest(body.unwrap_or_default());
51    let hex = encode_hex(&digest);
52    let header = match mode {
53        PayloadHashMode::IcebergRest if body.is_some() => base64_encode(&digest),
54        _ => hex.clone(),
55    };
56    (header, hex)
57}
58
59/// Signs REST catalog requests the way Iceberg Java's `RESTSigV4AuthSession`
60/// does. Carries no credentials, so one signer serves every session.
61///
62/// Built with [`SigV4Signer::builder`].
63#[derive(Clone, Debug, TypedBuilder)]
64pub struct SigV4Signer {
65    /// The signing region, e.g. `us-east-1`.
66    #[builder(setter(into))]
67    region: String,
68    /// The signing name, e.g. `execute-api`.
69    #[builder(setter(into))]
70    service: String,
71    /// How the payload hash is encoded.
72    mode: PayloadHashMode,
73}
74
75impl SigV4Signer {
76    /// Signs `request` in place. An existing `Authorization` moves to
77    /// `Original-Authorization`, and a caller's `x-amz-date`,
78    /// `x-amz-content-sha256` or `x-amz-security-token` that the signer
79    /// overwrites moves to `Original-<name>` (Java keeps a caller's content
80    /// hash when there is a body). Userinfo leaves the URL, and a `+` in the
81    /// query becomes `%20`, so write a literal plus as `%2B`.
82    ///
83    /// Uses `credentials` as given and never refreshes them: resolve temporary
84    /// ones from their provider before each call.
85    ///
86    /// On error, including for a streaming body or a non-UTF-8 header, the
87    /// request is left unchanged.
88    ///
89    /// Send the result through a client that does not follow redirects: a
90    /// redirect replays the signature, and across hosts reqwest drops
91    /// `Authorization` but keeps `Original-Authorization`.
92    ///
93    /// `aws_sigv4` traces requests without redacting `Original-Authorization`.
94    /// A `tracing` subscriber is muted for the call, but the `log` bridge (no
95    /// subscriber, or `log-always`) still forwards those events, so keep
96    /// `aws_sigv4` below trace level there.
97    pub fn sign(&self, request: &mut crate::HttpRequest, credentials: &Credentials) -> Result<()> {
98        self.sign_at(request, credentials, Utc::now())
99    }
100
101    fn sign_at(
102        &self,
103        request: &mut crate::HttpRequest,
104        credentials: &Credentials,
105        now: DateTime<Utc>,
106    ) -> Result<()> {
107        let headers = request.headers().clone();
108        let url = request.url().clone();
109        let signed = self.sign_in_place(request, credentials, now);
110        if signed.is_err() {
111            *request.headers_mut() = headers;
112            *request.url_mut() = url;
113        }
114        signed
115    }
116
117    fn sign_in_place(
118        &self,
119        request: &mut crate::HttpRequest,
120        credentials: &Credentials,
121        now: DateTime<Utc>,
122    ) -> Result<()> {
123        use aws_sigv4::http_request::{SignableBody, SignableRequest, sign};
124        use aws_sigv4::sign::v4;
125        use tracing::dispatcher::Dispatch;
126        use tracing::level_filters::LevelFilter;
127
128        // Before the content hash is displaced, which takes it out of signing.
129        signable_headers(request).try_for_each(|header| header.map(|_| ()))?;
130        let (content_header, payload_hash) = payload_hashes(signable_body(request)?, self.mode);
131
132        convert_headers(request);
133
134        // Relocated after signing, so the `Original-` copy is not signed.
135        let displaced_content_hash: Vec<_> = request
136            .headers()
137            .get_all(CONTENT_SHA256)
138            .iter()
139            .filter(|v| v.as_bytes() != content_header.as_bytes())
140            .cloned()
141            .collect();
142        let content_value = content_header.parse().map_err(|e| {
143            Error::new(ErrorKind::Unexpected, "invalid x-amz-content-sha256 value").with_source(e)
144        })?;
145        request.headers_mut().insert(CONTENT_SHA256, content_value);
146
147        rewrite_url_for_signing(request)?;
148
149        let identity = credentials.clone().into();
150        let params = v4::SigningParams::builder()
151            .identity(&identity)
152            .region(&self.region)
153            .name(&self.service)
154            .time(now.into())
155            .settings(signing_settings())
156            .build()
157            .map_err(|e| {
158                Error::new(ErrorKind::Unexpected, "failed to build SigV4 params").with_source(e)
159            })?
160            .into();
161
162        let headers = signable_headers(request).collect::<Result<Vec<_>>>()?;
163        let signable = SignableRequest::new(
164            request.method().as_str(),
165            request.url_str(),
166            headers.into_iter(),
167            SignableBody::Precomputed(payload_hash),
168        )
169        .map_err(|e| {
170            Error::new(ErrorKind::DataInvalid, "request is not signable").with_source(e)
171        })?;
172
173        // `aws_sigv4` traces the request, `Original-Authorization` included.
174        // Mute it only when a subscriber could record that (the max level is
175        // `OFF` until one is registered): `with_default` also sets
176        // tracing-core's `EXISTS` flag, which nothing clears, and with the
177        // `log` feature tracing then stops forwarding events to `log` for good.
178        let signed = if LevelFilter::current() == LevelFilter::TRACE {
179            tracing::dispatcher::with_default(&Dispatch::none(), || sign(signable, &params))
180        } else {
181            sign(signable, &params)
182        };
183        let (instructions, _signature) = signed
184            .map_err(|e| Error::new(ErrorKind::Unexpected, "SigV4 signing failed").with_source(e))?
185            .into_parts();
186
187        update_request_headers(request, instructions, displaced_content_hash)
188    }
189}
190
191/// The body to sign; as in Java, an absent body and an empty one differ.
192fn signable_body(request: &crate::HttpRequest) -> Result<Option<&[u8]>> {
193    match request.body() {
194        crate::HttpRequestBody::Empty => Ok(None),
195        crate::HttpRequestBody::Buffered(bytes) => Ok(Some(bytes)),
196        crate::HttpRequestBody::Streaming => Err(Error::new(
197            ErrorKind::FeatureUnsupported,
198            "cannot sign a streaming request body",
199        )),
200    }
201}
202
203/// The headers to sign. A non-UTF-8 one is an error: skipping it would send
204/// it unsigned.
205fn signable_headers(request: &crate::HttpRequest) -> impl Iterator<Item = Result<(&str, &str)>> {
206    request.headers().iter().map(|(n, v)| {
207        let v = std::str::from_utf8(v.as_bytes()).map_err(|e| {
208            Error::new(
209                ErrorKind::DataInvalid,
210                format!("cannot sign non-UTF-8 header value for `{n}`"),
211            )
212            .with_source(e)
213        })?;
214        Ok((n.as_str(), v))
215    })
216}
217
218/// Drops userinfo, which the wire `Host` never carries, and rewrites `+` in the
219/// query as `%20`: a space to AWS and Java either way, but unambiguous to any
220/// verifier.
221fn rewrite_url_for_signing(request: &mut crate::HttpRequest) -> Result<()> {
222    if !request.url().username().is_empty() || request.url().password().is_some() {
223        let url = request.url_mut();
224        url.set_username("")
225            .and_then(|()| url.set_password(None))
226            .map_err(|()| {
227                Error::new(
228                    ErrorKind::DataInvalid,
229                    "cannot strip userinfo from the request URL",
230                )
231            })?;
232    }
233    if let Some(query) = request.url().query().filter(|q| q.contains('+')) {
234        let unambiguous = query.replace('+', "%20");
235        request.url_mut().set_query(Some(&unambiguous));
236    }
237    Ok(())
238}
239
240/// `Aws4Signer`'s settings: normalized double-encoded path, and Java's ignore
241/// list.
242fn signing_settings() -> aws_sigv4::http_request::SigningSettings {
243    use aws_sigv4::http_request::{
244        PayloadChecksumKind, PercentEncodingMode, SigningSettings, UriPathNormalizationMode,
245    };
246
247    let mut settings = SigningSettings::default();
248    settings.percent_encoding_mode = PercentEncodingMode::Double;
249    settings.uri_path_normalization_mode = UriPathNormalizationMode::Enabled;
250    // We set and relocate the content hash ourselves, outside the instructions.
251    settings.payload_checksum_kind = PayloadChecksumKind::NoHeader;
252    let mut excluded = settings.excluded_headers.take().unwrap_or_default();
253    excluded.extend([
254        // Java's list, spelled out rather than left to the crate's defaults.
255        "connection".into(),
256        "expect".into(),
257        "transfer-encoding".into(),
258        "user-agent".into(),
259        "x-amzn-trace-id".into(),
260        // Not Java's, but proxies append to it too.
261        "x-forwarded-for".into(),
262        // Relocation appends to these after signing.
263        "original-x-amz-date".into(),
264        "original-x-amz-content-sha256".into(),
265        "original-x-amz-security-token".into(),
266    ]);
267    settings.excluded_headers = Some(excluded);
268    settings
269}
270
271/// Java's `convertHeaders`: moves `Authorization` aside before signing, so the
272/// moved copy is signed.
273fn convert_headers(request: &mut crate::HttpRequest) {
274    let displaced: Vec<_> = request
275        .headers()
276        .get_all(reqwest::header::AUTHORIZATION)
277        .iter()
278        .cloned()
279        .collect();
280    if displaced.is_empty() {
281        return;
282    }
283    request.headers_mut().remove(reqwest::header::AUTHORIZATION);
284    for mut value in displaced {
285        value.set_sensitive(true);
286        request.headers_mut().append(RELOCATED_AUTHORIZATION, value);
287    }
288}
289
290/// Java's `updateRequestHeaders`: installs the signed headers, moving
291/// conflicting caller values aside.
292fn update_request_headers(
293    request: &mut crate::HttpRequest,
294    instructions: aws_sigv4::http_request::SigningInstructions,
295    displaced_content_hash: Vec<reqwest::header::HeaderValue>,
296) -> Result<()> {
297    let (signed_headers, _params) = instructions.into_parts();
298    let h = request.headers_mut();
299    for mut value in displaced_content_hash {
300        // The original may carry a credential.
301        value.set_sensitive(true);
302        h.append(RELOCATED_CONTENT_SHA256, value);
303    }
304    for header in signed_headers {
305        let name: reqwest::header::HeaderName = header.name().parse().map_err(|e| {
306            Error::new(ErrorKind::Unexpected, "invalid signed header name").with_source(e)
307        })?;
308        if let Some(relocated) = relocated_name(name.as_str()) {
309            relocate_conflicting(h, name.as_str(), header.value(), relocated);
310        }
311        let mut value: reqwest::header::HeaderValue = header.value().parse().map_err(|e| {
312            Error::new(ErrorKind::Unexpected, "invalid signed header value").with_source(e)
313        })?;
314        if name == reqwest::header::AUTHORIZATION || name == SECURITY_TOKEN {
315            value.set_sensitive(true);
316        }
317        h.insert(name, value);
318    }
319    Ok(())
320}
321
322const CONTENT_SHA256: reqwest::header::HeaderName =
323    reqwest::header::HeaderName::from_static("x-amz-content-sha256");
324const AMZ_DATE: reqwest::header::HeaderName =
325    reqwest::header::HeaderName::from_static("x-amz-date");
326const SECURITY_TOKEN: reqwest::header::HeaderName =
327    reqwest::header::HeaderName::from_static("x-amz-security-token");
328
329const RELOCATED_AUTHORIZATION: reqwest::header::HeaderName =
330    reqwest::header::HeaderName::from_static("original-authorization");
331const RELOCATED_AMZ_DATE: reqwest::header::HeaderName =
332    reqwest::header::HeaderName::from_static("original-x-amz-date");
333const RELOCATED_CONTENT_SHA256: reqwest::header::HeaderName =
334    reqwest::header::HeaderName::from_static("original-x-amz-content-sha256");
335const RELOCATED_SECURITY_TOKEN: reqwest::header::HeaderName =
336    reqwest::header::HeaderName::from_static("original-x-amz-security-token");
337
338/// The `Original-<name>` counterpart of a header the signer generates.
339fn relocated_name(name: &str) -> Option<reqwest::header::HeaderName> {
340    match name {
341        n if n == AMZ_DATE => Some(RELOCATED_AMZ_DATE),
342        n if n == SECURITY_TOKEN => Some(RELOCATED_SECURITY_TOKEN),
343        _ => None,
344    }
345}
346
347/// Moves `name`'s values that differ from `signed` to `relocated`.
348fn relocate_conflicting(
349    headers: &mut reqwest::header::HeaderMap,
350    name: &str,
351    signed: &str,
352    relocated: reqwest::header::HeaderName,
353) {
354    let conflicting: Vec<_> = headers
355        .get_all(name)
356        .iter()
357        .filter(|value| value.as_bytes() != signed.as_bytes())
358        .cloned()
359        .collect();
360    for mut value in conflicting {
361        // It may carry a credential, e.g. a session token.
362        value.set_sensitive(true);
363        headers.append(relocated.clone(), value);
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use chrono::TimeZone;
370
371    use super::*;
372    use crate::HttpRequest;
373
374    const EMPTY_HEX: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
375
376    #[test]
377    fn signing_rewrites_an_ambiguous_plus_out_of_the_query() {
378        // reqwest writes a space as `+`; signing makes it `%20`.
379        let mut request = HttpRequest::new(
380            reqwest::Client::new()
381                .get("https://rest.example.com/v1/namespaces")
382                .query(&[("parent", "my ns")])
383                .build()
384                .unwrap(),
385        );
386        assert!(request.url().query().unwrap().contains("my+ns"));
387
388        let signer = test_signer(PayloadHashMode::StandardAws);
389        let now = test_time();
390        signer
391            .sign_at(&mut request, &test_credentials(), now)
392            .unwrap();
393
394        let query = request.url().query().unwrap();
395        assert!(!query.contains('+'), "{query}");
396        assert!(query.contains("my%20ns"), "{query}");
397        assert_signature_is(
398            &request,
399            "b7bb5a323a1ce0ace18454171084deef2dac44c933c3949771fc70179d3cce2b",
400        );
401    }
402
403    #[test]
404    fn payload_hashes_iceberg_mode() {
405        let v = payload_hashes(Some(b"hello"), PayloadHashMode::IcebergRest).0;
406        assert_eq!(v, "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=");
407        let e = payload_hashes(None, PayloadHashMode::IcebergRest).0;
408        assert_eq!(e, EMPTY_HEX);
409    }
410
411    /// As in Java, an empty body is hashed, unlike an absent one.
412    #[test]
413    fn signing_separates_an_empty_body_from_an_absent_one() {
414        let signer = test_signer(PayloadHashMode::IcebergRest);
415        let now = test_time();
416        let hash_of = |builder: reqwest::RequestBuilder| {
417            let mut req = HttpRequest::new(builder.build().unwrap());
418            signer.sign_at(&mut req, &test_credentials(), now).unwrap();
419            req.headers()
420                .get("x-amz-content-sha256")
421                .unwrap()
422                .to_str()
423                .unwrap()
424                .to_string()
425        };
426
427        let client = reqwest::Client::new();
428        let url = "https://rest.example.com/v1/namespaces";
429        assert_eq!(
430            hash_of(client.post(url).body("")),
431            "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
432        );
433        assert_eq!(hash_of(client.post(url)), EMPTY_HEX);
434    }
435
436    #[test]
437    fn payload_hashes_standard_mode() {
438        let v = payload_hashes(Some(b"hello"), PayloadHashMode::StandardAws).0;
439        assert_eq!(
440            v,
441            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
442        );
443    }
444
445    /// Pins a signature this crate produced; `signatures_match_iceberg_java`
446    /// checks against Java.
447    fn assert_signature_is(req: &HttpRequest, expected: &str) {
448        let auth = req
449            .headers()
450            .get("authorization")
451            .unwrap()
452            .to_str()
453            .unwrap();
454        assert!(auth.ends_with(&format!("Signature={expected}")), "{auth}");
455    }
456
457    fn test_signer(mode: PayloadHashMode) -> SigV4Signer {
458        SigV4Signer::builder()
459            .region("us-east-1")
460            .service("execute-api")
461            .mode(mode)
462            .build()
463    }
464
465    fn test_credentials() -> Credentials {
466        Credentials::new("ak", "sk", None::<String>, None, "test")
467    }
468
469    fn example_credentials(session_token: Option<&str>) -> Credentials {
470        Credentials::new(
471            "AKIDEXAMPLE",
472            "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
473            session_token.map(str::to_string),
474            None,
475            "test",
476        )
477    }
478
479    fn test_time() -> DateTime<Utc> {
480        Utc.with_ymd_and_hms(2015, 8, 30, 12, 36, 0).unwrap()
481    }
482
483    fn signed_headers(req: &HttpRequest) -> Vec<String> {
484        let auth = req
485            .headers()
486            .get("authorization")
487            .unwrap()
488            .to_str()
489            .unwrap();
490        let signed = auth.split("SignedHeaders=").nth(1).unwrap();
491        let signed = signed.split(',').next().unwrap();
492        signed.split(';').map(str::to_string).collect()
493    }
494
495    /// Every header value as `(name, value)`, sorted.
496    fn header_list(req: &HttpRequest) -> Vec<(String, String)> {
497        let mut headers: Vec<_> = req
498            .headers()
499            .iter()
500            .map(|(n, v)| (n.to_string(), v.to_str().unwrap().to_string()))
501            .collect();
502        headers.sort();
503        headers
504    }
505
506    fn sorted_pairs(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
507        let mut pairs: Vec<_> = pairs
508            .iter()
509            .map(|(n, v)| (n.to_string(), v.to_string()))
510            .collect();
511        pairs.sort();
512        pairs
513    }
514
515    /// Records every event field.
516    #[derive(Clone, Default)]
517    struct CapturedLog(std::sync::Arc<std::sync::Mutex<String>>);
518
519    impl tracing::field::Visit for CapturedLog {
520        fn record_debug(&mut self, _: &tracing::field::Field, value: &dyn std::fmt::Debug) {
521            self.0.lock().unwrap().push_str(&format!("{value:?}"));
522        }
523    }
524
525    impl tracing::Subscriber for CapturedLog {
526        fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
527            true
528        }
529        fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::Id {
530            tracing::Id::from_u64(1)
531        }
532        fn record(&self, _: &tracing::Id, _: &tracing::span::Record<'_>) {}
533        fn record_follows_from(&self, _: &tracing::Id, _: &tracing::Id) {}
534        fn event(&self, event: &tracing::Event<'_>) {
535            event.record(&mut self.clone());
536        }
537        fn enter(&self, _: &tracing::Id) {}
538        fn exit(&self, _: &tracing::Id) {}
539    }
540
541    /// `aws_sigv4` does not redact `Original-Authorization` itself.
542    #[test]
543    fn signing_does_not_trace_a_relocated_bearer_token() {
544        const TOKEN: &str = "Bearer topsecretdelegatetoken";
545        let signer = test_signer(PayloadHashMode::IcebergRest);
546        let mut req = HttpRequest::new(
547            reqwest::Client::new()
548                .get("https://rest.example.com/v1/config")
549                .header(reqwest::header::AUTHORIZATION, TOKEN)
550                .build()
551                .unwrap(),
552        );
553
554        let log = CapturedLog::default();
555        tracing::subscriber::with_default(log.clone(), || {
556            // `sign_at` mutes only at this level, which is what is under test.
557            assert_eq!(
558                tracing::level_filters::LevelFilter::current(),
559                tracing::level_filters::LevelFilter::TRACE
560            );
561            signer
562                .sign_at(&mut req, &test_credentials(), test_time())
563                .unwrap();
564            tracing::trace!(canary = "subscriber-is-live");
565        });
566
567        let captured = log.0.lock().unwrap().clone();
568        assert!(captured.contains("subscriber-is-live"), "captured nothing");
569        assert!(!captured.contains(TOKEN), "{captured}");
570        assert_eq!(req.headers().get(RELOCATED_AUTHORIZATION).unwrap(), TOKEN);
571    }
572
573    #[test]
574    fn a_non_utf8_header_is_rejected_without_changing_the_request() {
575        let signer = test_signer(PayloadHashMode::IcebergRest);
576        for name in ["x-amz-meta-tenant", "authorization", "x-amz-content-sha256"] {
577            let mut req = HttpRequest::new(reqwest::Request::new(
578                reqwest::Method::GET,
579                "https://user:pw@rest.example.com/v1/config?warehouse=my+catalog"
580                    .parse()
581                    .unwrap(),
582            ));
583            req.headers_mut()
584                .insert("authorization", "Bearer delegate-token".parse().unwrap());
585            req.headers_mut()
586                .insert("x-amz-content-sha256", "caller-hash".parse().unwrap());
587            req.headers_mut().insert(
588                name,
589                reqwest::header::HeaderValue::from_bytes(b"acme\xfa").unwrap(),
590            );
591            let headers = req.headers().clone();
592            let url = req.url().clone();
593
594            let err = signer
595                .sign_at(&mut req, &test_credentials(), test_time())
596                .unwrap_err();
597            assert_eq!(err.kind(), ErrorKind::DataInvalid);
598            assert!(err.message().contains(name), "{err}");
599            assert_eq!(req.headers(), &headers, "{name}");
600            assert_eq!(req.url(), &url, "{name}");
601        }
602    }
603
604    #[test]
605    fn a_non_ascii_utf8_header_is_signed() {
606        let signer = test_signer(PayloadHashMode::StandardAws);
607        let mut req = HttpRequest::new(reqwest::Request::new(
608            reqwest::Method::GET,
609            "https://rest.example.com/v1/config".parse().unwrap(),
610        ));
611        req.headers_mut().insert(
612            "x-tenant",
613            reqwest::header::HeaderValue::from_bytes("Zürich".as_bytes()).unwrap(),
614        );
615
616        signer
617            .sign_at(&mut req, &test_credentials(), test_time())
618            .unwrap();
619
620        assert_eq!(signed_headers(&req), [
621            "host",
622            "x-amz-content-sha256",
623            "x-amz-date",
624            "x-tenant"
625        ]);
626    }
627
628    #[test]
629    fn a_signing_failure_after_relocation_restores_the_request() {
630        let signer = test_signer(PayloadHashMode::IcebergRest);
631        // Longer than `http::Uri` accepts, so signing fails after the headers
632        // and query were rewritten.
633        let url = format!(
634            "https://rest.example.com/v1/config?warehouse=my+catalog&pad={}",
635            "a".repeat(70_000)
636        );
637        let mut req = HttpRequest::new(
638            reqwest::Client::new()
639                .get(url)
640                .header("authorization", "Bearer delegate-token")
641                .header("x-amz-content-sha256", "caller-hash")
642                .build()
643                .unwrap(),
644        );
645        let headers = req.headers().clone();
646        let url = req.url().clone();
647
648        let err = signer
649            .sign_at(&mut req, &test_credentials(), test_time())
650            .unwrap_err();
651
652        assert_eq!(err.kind(), ErrorKind::DataInvalid, "{err}");
653        assert_eq!(req.headers(), &headers);
654        assert_eq!(req.url(), &url);
655    }
656
657    /// Relocation appends to a caller's `Original-x-amz-*` after signing, so it
658    /// must stay unsigned.
659    #[test]
660    fn a_caller_supplied_relocation_header_is_not_signed() {
661        let signer = test_signer(PayloadHashMode::StandardAws);
662        let mut req = HttpRequest::new(
663            reqwest::Client::new()
664                .get("https://rest.example.com/v1/config")
665                .header("x-amz-content-sha256", "caller-hash")
666                .header("original-x-amz-content-sha256", "previous")
667                .build()
668                .unwrap(),
669        );
670
671        signer
672            .sign_at(&mut req, &test_credentials(), test_time())
673            .unwrap();
674
675        assert_eq!(signed_headers(&req), [
676            "host",
677            "x-amz-content-sha256",
678            "x-amz-date"
679        ]);
680        let relocated: Vec<_> = req
681            .headers()
682            .get_all("original-x-amz-content-sha256")
683            .iter()
684            .map(|v| v.to_str().unwrap())
685            .collect();
686        assert_eq!(relocated, ["previous", "caller-hash"]);
687    }
688
689    #[test]
690    fn userinfo_is_stripped_before_signing() {
691        // A hand-built request can carry userinfo; the wire `Host` never does.
692        let signer = test_signer(PayloadHashMode::StandardAws);
693        let mut req = HttpRequest::new(reqwest::Request::new(
694            reqwest::Method::GET,
695            "https://user:pw@rest.example.com/v1/config"
696                .parse()
697                .unwrap(),
698        ));
699        assert_eq!(req.url().username(), "user");
700        let now = test_time();
701
702        signer.sign_at(&mut req, &test_credentials(), now).unwrap();
703
704        assert_eq!(req.url().username(), "");
705        assert_eq!(req.url().password(), None);
706        let mut plain = HttpRequest::new(reqwest::Request::new(
707            reqwest::Method::GET,
708            "https://rest.example.com/v1/config".parse().unwrap(),
709        ));
710        signer
711            .sign_at(&mut plain, &test_credentials(), now)
712            .unwrap();
713        assert_eq!(
714            req.headers().get("authorization"),
715            plain.headers().get("authorization"),
716        );
717    }
718
719    #[test]
720    fn a_doubled_slash_in_the_path_is_normalized() {
721        // A trailing slash on the catalog URI gives `//v1/...`, which
722        // `Aws4Signer` collapses.
723        let signer = test_signer(PayloadHashMode::StandardAws);
724        let mut req = HttpRequest::new(reqwest::Request::new(
725            reqwest::Method::GET,
726            "https://rest.example.com//v1//config".parse().unwrap(),
727        ));
728        let now = test_time();
729
730        signer.sign_at(&mut req, &test_credentials(), now).unwrap();
731
732        let mut plain = HttpRequest::new(reqwest::Request::new(
733            reqwest::Method::GET,
734            "https://rest.example.com/v1/config".parse().unwrap(),
735        ));
736        signer
737            .sign_at(&mut plain, &test_credentials(), now)
738            .unwrap();
739        assert_eq!(
740            req.headers().get("authorization"),
741            plain.headers().get("authorization"),
742        );
743    }
744
745    #[test]
746    fn caller_headers_the_signer_overwrites_are_relocated() {
747        // As in Java, conflicting caller values move to `Original-<name>`.
748        let creds = Credentials::new(
749            "ak".to_string(),
750            "sk".to_string(),
751            Some("signer-token".to_string()),
752            None,
753            "test",
754        );
755        let signer = test_signer(PayloadHashMode::StandardAws);
756        let mut req = HttpRequest::new(
757            reqwest::Client::new()
758                .get("https://rest.example.com/v1/config")
759                .header("authorization", "Bearer caller-token")
760                .header("x-amz-date", "19700101T000000Z")
761                .header("x-amz-security-token", "caller-session")
762                .header("x-amz-content-sha256", "caller-hash")
763                .build()
764                .unwrap(),
765        );
766
767        signer.sign_at(&mut req, &creds, test_time()).unwrap();
768
769        assert_eq!(
770            header_list(&req),
771            sorted_pairs(&[
772                (
773                    "authorization",
774                    "AWS4-HMAC-SHA256 Credential=ak/20150830/us-east-1/execute-api/aws4_request, \
775                     SignedHeaders=host;original-authorization;x-amz-content-sha256;x-amz-date;\
776                     x-amz-security-token, \
777                     Signature=87560cb735277b284a547d63f57cfe3a721f9bce0b08a9402a58d754d2ed707c"
778                ),
779                ("original-authorization", "Bearer caller-token"),
780                ("original-x-amz-content-sha256", "caller-hash"),
781                ("original-x-amz-date", "19700101T000000Z"),
782                ("original-x-amz-security-token", "caller-session"),
783                ("x-amz-content-sha256", EMPTY_HEX),
784                ("x-amz-date", "20150830T123600Z"),
785                ("x-amz-security-token", "signer-token"),
786            ])
787        );
788        // Relocated originals may be credentials themselves.
789        for relocated in [RELOCATED_SECURITY_TOKEN, RELOCATED_CONTENT_SHA256] {
790            assert!(req.headers().get(relocated).unwrap().is_sensitive());
791        }
792    }
793
794    #[test]
795    fn an_existing_authorization_is_never_signed() {
796        // The signer replaces `authorization`, and a proxy may rewrite
797        // `user-agent`, so neither is signed.
798        let signer = test_signer(PayloadHashMode::StandardAws);
799        let mut req = HttpRequest::new(
800            reqwest::Client::new()
801                .get("https://rest.example.com/v1/config")
802                .header("authorization", "Bearer caller-token")
803                .header("user-agent", "example/1.0")
804                .build()
805                .unwrap(),
806        );
807
808        signer
809            .sign_at(&mut req, &test_credentials(), test_time())
810            .unwrap();
811
812        assert_eq!(signed_headers(&req), [
813            "host",
814            "original-authorization",
815            "x-amz-content-sha256",
816            "x-amz-date"
817        ]);
818    }
819
820    /// As in Java, every `Authorization` value is relocated, and stays redacted.
821    #[test]
822    fn every_repeated_authorization_is_relocated_and_kept_sensitive() {
823        let signer = test_signer(PayloadHashMode::StandardAws);
824        let mut req = HttpRequest::new(
825            reqwest::Client::new()
826                .get("https://rest.example.com/v1/config")
827                .header("authorization", "Bearer first")
828                .header("authorization", "Bearer second")
829                .build()
830                .unwrap(),
831        );
832
833        signer
834            .sign_at(&mut req, &test_credentials(), test_time())
835            .unwrap();
836
837        let relocated: Vec<_> = req
838            .headers()
839            .get_all(RELOCATED_AUTHORIZATION)
840            .iter()
841            .collect();
842        assert_eq!(relocated.len(), 2, "{relocated:?}");
843        assert_eq!(relocated[0], "Bearer first");
844        assert_eq!(relocated[1], "Bearer second");
845        assert!(relocated.iter().all(|v| v.is_sensitive()), "{relocated:?}");
846    }
847
848    #[test]
849    fn hop_by_hop_headers_are_not_signed() {
850        // A proxy may drop or rewrite these, so they are not signed.
851        let signer = test_signer(PayloadHashMode::StandardAws);
852        let mut req = HttpRequest::new(
853            reqwest::Client::new()
854                .get("https://rest.example.com/v1/config")
855                .header("expect", "100-continue")
856                .header("connection", "keep-alive")
857                .header("x-forwarded-for", "203.0.113.7")
858                .header("x-tenant", "acme")
859                .build()
860                .unwrap(),
861        );
862        let now = test_time();
863
864        signer.sign_at(&mut req, &test_credentials(), now).unwrap();
865
866        assert_eq!(signed_headers(&req), [
867            "host",
868            "x-amz-content-sha256",
869            "x-amz-date",
870            "x-tenant"
871        ]);
872        assert_signature_is(
873            &req,
874            "f938221412ed6b55cf3db380ce6ded476419ad3d7db4c76d932031c98465ce79",
875        );
876    }
877
878    #[test]
879    fn signed_credentials_are_marked_sensitive() {
880        // Both carry a credential, so `Debug` must not print them.
881        let creds = Credentials::new(
882            "ak".to_string(),
883            "sk".to_string(),
884            Some("session-token".to_string()),
885            None,
886            "test",
887        );
888        let signer = test_signer(PayloadHashMode::StandardAws);
889        let mut req = HttpRequest::new(
890            reqwest::Client::new()
891                .get("https://rest.example.com/v1/config")
892                .build()
893                .unwrap(),
894        );
895
896        signer.sign_at(&mut req, &creds, test_time()).unwrap();
897
898        assert!(req.headers().get("authorization").unwrap().is_sensitive());
899        assert!(
900            req.headers()
901                .get("x-amz-security-token")
902                .unwrap()
903                .is_sensitive()
904        );
905        let debug = format!("{:?}", req.headers());
906        assert!(!debug.contains("session-token"), "{debug}");
907    }
908
909    #[test]
910    fn signs_with_a_non_default_service_and_session_token() {
911        // As a non-AWS catalog might vend: its own signing name, and STS
912        // credentials.
913        let creds = Credentials::new(
914            "STS.EXAMPLEACCESSKEYID",
915            "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
916            Some("example-session-token".to_string()),
917            None,
918            "test",
919        );
920        let signer = SigV4Signer::builder()
921            .region("us-east-1")
922            .service("custom-service")
923            .mode(PayloadHashMode::IcebergRest)
924            .build();
925        let mut req = HttpRequest::new(
926            reqwest::Client::new()
927                .get("https://catalog.example.com/v1/config?warehouse=my-catalog")
928                .build()
929                .unwrap(),
930        );
931        let now = Utc.with_ymd_and_hms(2026, 8, 26, 12, 0, 0).unwrap();
932
933        signer.sign_at(&mut req, &creds, now).unwrap();
934
935        assert_signature_is(
936            &req,
937            "6b7065e5f44da4f5c3654126b8d5fe29599905afb6b98f4de65b6ec6e1be783f",
938        );
939        let auth = req
940            .headers()
941            .get("authorization")
942            .unwrap()
943            .to_str()
944            .unwrap();
945        assert!(
946            auth.contains("/us-east-1/custom-service/aws4_request"),
947            "{auth}"
948        );
949        assert_eq!(
950            req.headers().get("x-amz-security-token").unwrap(),
951            "example-session-token"
952        );
953    }
954
955    #[test]
956    fn signs_request_iceberg_mode() {
957        let creds = example_credentials(Some("SESSIONTOKEN"));
958        let signer = SigV4Signer::builder()
959            .region("us-east-1")
960            .service("glue")
961            .mode(PayloadHashMode::IcebergRest)
962            .build();
963        let client = reqwest::Client::new();
964        let mut req = HttpRequest::new(
965            client
966                .post("https://rest.example.com/v1/namespaces")
967                .body("{}")
968                .build()
969                .unwrap(),
970        );
971
972        signer.sign_at(&mut req, &creds, test_time()).unwrap();
973
974        assert_eq!(
975            header_list(&req),
976            sorted_pairs(&[
977                (
978                    "authorization",
979                    "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/glue/aws4_request, \
980                     SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, \
981                     Signature=effad6acde583dd14ba7aff52b2b83776a54421c010fe82067f166819057cb32"
982                ),
983                (
984                    "x-amz-content-sha256",
985                    "RBNvo1WzZ4oRRq0W9+hknpT7T8If536DEMBg9hyq/4o="
986                ),
987                ("x-amz-date", "20150830T123600Z"),
988                ("x-amz-security-token", "SESSIONTOKEN"),
989            ])
990        );
991    }
992
993    /// An empty body hashes to the hex constant, and caller headers are signed
994    /// (Java's `authenticateWithoutBody`).
995    #[test]
996    fn signs_empty_body_and_all_headers() {
997        let creds = example_credentials(None);
998        let signer = SigV4Signer::builder()
999            .region("us-east-1")
1000            .service("glue")
1001            .mode(PayloadHashMode::IcebergRest)
1002            .build();
1003        let client = reqwest::Client::new();
1004        let mut req = HttpRequest::new(
1005            client
1006                .get("https://rest.example.com/v1/config")
1007                .header("content-type", "application/json")
1008                .header("content-encoding", "gzip")
1009                .build()
1010                .unwrap(),
1011        );
1012
1013        signer.sign_at(&mut req, &creds, test_time()).unwrap();
1014
1015        assert_eq!(
1016            header_list(&req),
1017            sorted_pairs(&[
1018                (
1019                    "authorization",
1020                    "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/glue/aws4_request, \
1021                     SignedHeaders=content-encoding;content-type;host;x-amz-content-sha256;x-amz-date, \
1022                     Signature=eaef7eb88d9cd810031684748671d8a3c9394ea5168622a212ed041b670b9777"
1023                ),
1024                ("content-encoding", "gzip"),
1025                ("content-type", "application/json"),
1026                ("x-amz-content-sha256", EMPTY_HEX),
1027                ("x-amz-date", "20150830T123600Z"),
1028            ])
1029        );
1030    }
1031
1032    #[test]
1033    fn iceberg_mode_signs_the_hex_payload_hash_not_the_base64_header() {
1034        // Needs a body: without one the header is the hex constant too.
1035        let creds = example_credentials(None);
1036        let signer = test_signer(PayloadHashMode::IcebergRest);
1037        let body = br#"{"namespace":["ns"]}"#;
1038        let mut req = HttpRequest::new(
1039            reqwest::Client::new()
1040                .post("https://rest.example.com/v1/namespaces")
1041                .body(body.to_vec())
1042                .build()
1043                .unwrap(),
1044        );
1045        let now = test_time();
1046
1047        signer.sign_at(&mut req, &creds, now).unwrap();
1048
1049        assert_eq!(
1050            req.headers().get("x-amz-content-sha256").unwrap(),
1051            payload_hashes(Some(body), PayloadHashMode::IcebergRest)
1052                .0
1053                .as_str()
1054        );
1055        assert_ne!(
1056            req.headers().get("x-amz-content-sha256").unwrap(),
1057            encode_hex(&Sha256::digest(body)).as_str()
1058        );
1059        assert_signature_is(
1060            &req,
1061            "c68682c26cab6a781256f83b0076f50014f4922c3907f4ff09c204a74d61fc1d",
1062        );
1063    }
1064
1065    /// What Iceberg Java's `RESTSigV4AuthSession` (iceberg-aws 1.10.1) sent for
1066    /// these requests, given these credentials as `rest.*` properties.
1067    #[test]
1068    fn signatures_match_iceberg_java() {
1069        let creds = example_credentials(Some("example-session-token"));
1070        let signer = test_signer(PayloadHashMode::IcebergRest);
1071        let now = Utc.with_ymd_and_hms(2026, 10, 2, 11, 40, 30).unwrap();
1072        let client = reqwest::Client::new();
1073
1074        // A body, so the header carries base64, and a token to relocate.
1075        let mut post = HttpRequest::new(
1076            client
1077                .post("https://rest.example.com/v1/namespaces")
1078                .header("content-type", "application/json")
1079                .header("authorization", "Bearer delegate-token")
1080                .body(r#"{"namespace":["a","b"],"properties":{}}"#)
1081                .build()
1082                .unwrap(),
1083        );
1084        signer.sign_at(&mut post, &creds, now).unwrap();
1085        assert_eq!(
1086            post.headers().get("authorization").unwrap(),
1087            "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20261002/us-east-1/execute-api/aws4_request, \
1088             SignedHeaders=content-type;host;original-authorization;\
1089             x-amz-content-sha256;x-amz-date;x-amz-security-token, \
1090             Signature=c5d6bfcecd19c5421e8696c67465f6909b5e09398608d8e79a9eb391e04de556"
1091        );
1092        assert_eq!(
1093            post.headers().get("x-amz-content-sha256").unwrap(),
1094            "n1CB8Yl4L77sBwHTc5qTpUEhlsHHOXFzg0dV0fbOwXs="
1095        );
1096
1097        // No body, a multi-level namespace and an encoded query.
1098        let mut get = HttpRequest::new(
1099            client
1100                .get("https://rest.example.com/v1/namespaces/a%1Fb/tables/x,y?pageToken=a%20b%2Fc")
1101                .build()
1102                .unwrap(),
1103        );
1104        signer.sign_at(&mut get, &creds, now).unwrap();
1105        assert_eq!(
1106            get.headers().get("authorization").unwrap(),
1107            "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20261002/us-east-1/execute-api/aws4_request, \
1108             SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, \
1109             Signature=5f6832ec82fc821b08cd3eac3a86ffc3e0c333571fd0a5796e43f2534f23329f"
1110        );
1111    }
1112
1113    /// The signed `host` keeps a non-default port, as on the wire.
1114    #[test]
1115    fn signs_host_with_non_default_port() {
1116        let creds = example_credentials(None);
1117        let signer = SigV4Signer::builder()
1118            .region("us-east-1")
1119            .service("glue")
1120            .mode(PayloadHashMode::IcebergRest)
1121            .build();
1122        let client = reqwest::Client::new();
1123        let mut req = HttpRequest::new(
1124            client
1125                .get("https://rest.example.com:8181/v1/config")
1126                .build()
1127                .unwrap(),
1128        );
1129        let now = test_time();
1130
1131        signer.sign_at(&mut req, &creds, now).unwrap();
1132        assert_signature_is(
1133            &req,
1134            "f7801a4ecac5fe6dcc4ee385223428ec4833f21d0c2fc10d2fb00694b2c0def7",
1135        );
1136    }
1137
1138    /// Like `Aws4Signer`, the canonical path is encoded again: `,` becomes `%2C`
1139    /// and `%2C` becomes `%252C`.
1140    #[test]
1141    fn canonical_uri_is_aws_double_encoded() {
1142        let creds = example_credentials(None);
1143        let signer = SigV4Signer::builder()
1144            .region("us-east-1")
1145            .service("glue")
1146            .mode(PayloadHashMode::IcebergRest)
1147            .build();
1148        let client = reqwest::Client::new();
1149        let mut req = HttpRequest::new(
1150            client
1151                .get("https://rest.example.com/v1/namespaces/a%2Cb/tables/x,y")
1152                .build()
1153                .unwrap(),
1154        );
1155        let now = test_time();
1156
1157        signer.sign_at(&mut req, &creds, now).unwrap();
1158        assert_signature_is(
1159            &req,
1160            "4d966b6fc2dfb62be5a603e4e07e5dc85b2af1c7e6a181c5646bfbf38ddfa543",
1161        );
1162    }
1163
1164    /// The AWS SigV4 test suite's `post-x-www-form-urlencoded`, which signs a
1165    /// hex `x-amz-content-sha256` like this mode.
1166    #[test]
1167    fn standard_mode_matches_the_aws_test_suite() {
1168        let creds = example_credentials(None);
1169        let signer = SigV4Signer::builder()
1170            .region("us-east-1")
1171            .service("service")
1172            .mode(PayloadHashMode::StandardAws)
1173            .build();
1174        let mut req = HttpRequest::new(
1175            reqwest::Client::new()
1176                .post("https://example.amazonaws.com/")
1177                .header("content-type", "application/x-www-form-urlencoded")
1178                .header("content-length", "13")
1179                .body("Param1=value1")
1180                .build()
1181                .unwrap(),
1182        );
1183
1184        signer.sign_at(&mut req, &creds, test_time()).unwrap();
1185
1186        assert_eq!(
1187            req.headers().get("x-amz-content-sha256").unwrap(),
1188            "9095672bbd1f56dfc5b65f3e153adc8731a4a654192329106275f4c7b24d0b6e"
1189        );
1190        assert_eq!(
1191            req.headers().get("authorization").unwrap(),
1192            "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \
1193             SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date, \
1194             Signature=d3875051da38690788ef43de4db0d8f280229d82040bfac253562e56c3f20e0b"
1195        );
1196    }
1197
1198    /// The one test on the live clock: everything else pins the time.
1199    #[test]
1200    fn sign_stamps_the_current_time() {
1201        use chrono::SubsecRound;
1202
1203        let signer = test_signer(PayloadHashMode::IcebergRest);
1204        let mut req = HttpRequest::new(
1205            reqwest::Client::new()
1206                .get("https://rest.example.com/v1/config")
1207                .build()
1208                .unwrap(),
1209        );
1210        let before = Utc::now();
1211        signer.sign(&mut req, &test_credentials()).unwrap();
1212        let after = Utc::now();
1213
1214        let date = req.headers().get("x-amz-date").unwrap().to_str().unwrap();
1215        let stamped = chrono::NaiveDateTime::parse_from_str(date, "%Y%m%dT%H%M%SZ")
1216            .unwrap()
1217            .and_utc();
1218        assert_eq!(date.len(), "YYYYMMDDTHHMMSSZ".len(), "{date}");
1219        // The header drops sub-seconds, so compare against a truncated start.
1220        assert!(
1221            before.trunc_subsecs(0) <= stamped && stamped <= after,
1222            "{date}"
1223        );
1224    }
1225}