Skip to main content

whipplescript_custody/
canon.rs

1//! Built-in canonicalizers (DR-0053 §7).
2//!
3//! The split falls on secret-freedom: everything here is pure, deterministic,
4//! and never touches material. whip computes the canonical form and the
5//! string-to-sign; the custodian holds the key and folds the derivation
6//! chain. Canonicalization bugs are a classic signature-bypass class, so the
7//! scheme set is **closed** — `aws-sigv4`, `hmac-sha256` (webhook profiles),
8//! `jwt-rs256` — and adding one is a whip release, not a config edit.
9//! Correctness is a gate: the test suite runs these against the vendors'
10//! published vectors.
11
12use sha2::{Digest, Sha256};
13
14fn hex(bytes: &[u8]) -> String {
15    bytes.iter().map(|b| format!("{b:02x}")).collect()
16}
17
18pub fn sha256_hex(data: &[u8]) -> String {
19    hex(&Sha256::digest(data))
20}
21
22/// AWS Signature Version 4, header-auth flavor. whip's half is steps 1–2 of
23/// §7: canonical request and string-to-sign. The output's `derivation` is
24/// the chain the custodian folds (date, region, service, `aws4_request`);
25/// `kSigning` never exists on this side.
26pub mod aws_sigv4 {
27    use super::{hex, sha256_hex};
28
29    #[derive(Debug, Clone, PartialEq, Eq)]
30    pub struct Canonicalized {
31        pub canonical_request: String,
32        pub signed_headers: String,
33        pub string_to_sign: String,
34        /// The derivation chain for `CustodyOp::Sign` — `[date, region,
35        /// service, "aws4_request"]`.
36        pub derivation: Vec<String>,
37        /// The credential scope, for assembling the Authorization header:
38        /// `date/region/service/aws4_request`.
39        pub scope: String,
40    }
41
42    pub struct Input<'a> {
43        pub method: &'a str,
44        /// The request path, before canonicalization (no query).
45        pub path: &'a str,
46        /// The raw query string (no leading `?`), possibly empty.
47        pub query: &'a str,
48        /// All headers to sign, as sent. Must include `host` and
49        /// `x-amz-date`.
50        pub headers: &'a [(String, String)],
51        /// Hex SHA-256 of the request payload (`sha256("")` for none).
52        pub payload_hash_hex: &'a str,
53        /// `YYYYMMDD'T'HHMMSS'Z'`.
54        pub amz_date: &'a str,
55        pub region: &'a str,
56        pub service: &'a str,
57        /// Path normalization (dot-segment removal, slash collapse). True
58        /// for every service except S3.
59        pub normalize_path: bool,
60        /// Double URI-encoding of path segments. True for every service
61        /// except S3.
62        pub double_encode: bool,
63    }
64
65    const UNRESERVED: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
66
67    fn uri_encode(s: &str, keep_slash: bool) -> String {
68        let mut out = String::with_capacity(s.len());
69        for &b in s.as_bytes() {
70            if UNRESERVED.contains(&b) || (keep_slash && b == b'/') {
71                out.push(b as char);
72            } else {
73                out.push_str(&format!("%{b:02X}"));
74            }
75        }
76        out
77    }
78
79    fn normalize_path(path: &str) -> String {
80        // RFC 3986 dot-segment removal over slash-collapsed segments.
81        let mut stack: Vec<&str> = Vec::new();
82        for seg in path.split('/') {
83            match seg {
84                "" | "." => {}
85                ".." => {
86                    stack.pop();
87                }
88                s => stack.push(s),
89            }
90        }
91        let mut out = String::from("/");
92        out.push_str(&stack.join("/"));
93        // A path ending in a slash (or dot-segment) keeps its trailing slash.
94        if out.len() > 1 && (path.ends_with('/') || path.ends_with("/.") || path.ends_with("/..")) {
95            out.push('/');
96        }
97        out
98    }
99
100    fn canonical_uri(path: &str, normalize: bool, double_encode: bool) -> String {
101        let path = if path.is_empty() { "/" } else { path };
102        let path = if normalize {
103            normalize_path(path)
104        } else {
105            path.to_string()
106        };
107        // "Encoded twice for every service except S3" counts the encoding
108        // the request target already carries: an incoming `%20` becomes
109        // `%2520`, an incoming literal space becomes `%20`. So the
110        // canonicalizer applies exactly one encoding pass — or none for S3,
111        // which signs the target as-is.
112        if double_encode {
113            uri_encode(&path, true)
114        } else {
115            path
116        }
117    }
118
119    fn canonical_query(query: &str) -> String {
120        if query.is_empty() {
121            return String::new();
122        }
123        let mut pairs: Vec<(String, String)> = query
124            .split('&')
125            .filter(|p| !p.is_empty())
126            .map(|pair| {
127                let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
128                (uri_encode(k, false), uri_encode(v, false))
129            })
130            .collect();
131        pairs.sort();
132        pairs
133            .into_iter()
134            .map(|(k, v)| format!("{k}={v}"))
135            .collect::<Vec<_>>()
136            .join("&")
137    }
138
139    fn collapse_spaces(value: &str) -> String {
140        let mut out = String::with_capacity(value.len());
141        let mut last_space = false;
142        for c in value.trim().chars() {
143            if c == ' ' {
144                if !last_space {
145                    out.push(' ');
146                }
147                last_space = true;
148            } else {
149                out.push(c);
150                last_space = false;
151            }
152        }
153        out
154    }
155
156    fn canonical_headers(headers: &[(String, String)]) -> (String, String) {
157        let mut named: Vec<(String, Vec<String>)> = Vec::new();
158        for (name, value) in headers {
159            let name = name.to_ascii_lowercase();
160            let value = collapse_spaces(value);
161            match named.iter_mut().find(|(n, _)| *n == name) {
162                Some((_, values)) => values.push(value),
163                None => named.push((name, vec![value])),
164            }
165        }
166        named.sort_by(|a, b| a.0.cmp(&b.0));
167        let block = named
168            .iter()
169            .map(|(n, vs)| format!("{n}:{}\n", vs.join(",")))
170            .collect::<String>();
171        let signed = named
172            .iter()
173            .map(|(n, _)| n.as_str())
174            .collect::<Vec<_>>()
175            .join(";");
176        (block, signed)
177    }
178
179    pub fn canonicalize(input: &Input<'_>) -> Canonicalized {
180        let uri = canonical_uri(input.path, input.normalize_path, input.double_encode);
181        let query = canonical_query(input.query);
182        let (header_block, signed_headers) = canonical_headers(input.headers);
183        let canonical_request = format!(
184            "{}\n{}\n{}\n{}\n{}\n{}",
185            input.method, uri, query, header_block, signed_headers, input.payload_hash_hex
186        );
187        let date = &input.amz_date[..8];
188        let scope = format!("{date}/{}/{}/aws4_request", input.region, input.service);
189        let string_to_sign = format!(
190            "AWS4-HMAC-SHA256\n{}\n{scope}\n{}",
191            input.amz_date,
192            sha256_hex(canonical_request.as_bytes())
193        );
194        Canonicalized {
195            canonical_request,
196            signed_headers,
197            string_to_sign,
198            derivation: vec![
199                date.to_string(),
200                input.region.to_string(),
201                input.service.to_string(),
202                "aws4_request".to_string(),
203            ],
204            scope,
205        }
206    }
207
208    /// The Authorization header value, given the signature the custodian
209    /// returned.
210    pub fn authorization_header(
211        access_key_id: &str,
212        canonicalized: &Canonicalized,
213        signature: &[u8],
214    ) -> String {
215        format!(
216            "AWS4-HMAC-SHA256 Credential={access_key_id}/{}, SignedHeaders={}, Signature={}",
217            canonicalized.scope,
218            canonicalized.signed_headers,
219            hex(signature)
220        )
221    }
222}
223
224/// `hmac-sha256` webhook profiles: how a vendor frames the bytes under the
225/// MAC and spells the signature header. The framing is public protocol
226/// shape; the MAC itself is the custodian's.
227pub mod webhook {
228    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
229    pub enum Profile {
230        /// `X-Hub-Signature-256: sha256=<hex>` over the raw body.
231        Github,
232        /// `Stripe-Signature: t=<ts>,v1=<hex>` over `"{ts}.{body}"`.
233        Stripe,
234        /// `X-Slack-Signature: v0=<hex>` over `"v0:{ts}:{body}"`.
235        Slack,
236        /// The raw body, header spelling left to the caller.
237        Raw,
238    }
239
240    impl Profile {
241        pub fn parse(s: &str) -> Result<Self, String> {
242            match s {
243                "github" => Ok(Profile::Github),
244                "stripe" => Ok(Profile::Stripe),
245                "slack" => Ok(Profile::Slack),
246                "raw" => Ok(Profile::Raw),
247                other => Err(format!("unknown webhook profile {other:?}")),
248            }
249        }
250
251        /// The bytes the MAC covers. Profiles that bind a timestamp require
252        /// one; passing it for the others is an error rather than a silent
253        /// ignore.
254        pub fn signing_payload(
255            &self,
256            timestamp: Option<&str>,
257            body: &str,
258        ) -> Result<String, String> {
259            match (self, timestamp) {
260                (Profile::Github | Profile::Raw, None) => Ok(body.to_string()),
261                (Profile::Github | Profile::Raw, Some(_)) => {
262                    Err("this profile does not bind a timestamp".to_string())
263                }
264                (Profile::Stripe, Some(ts)) => Ok(format!("{ts}.{body}")),
265                (Profile::Slack, Some(ts)) => Ok(format!("v0:{ts}:{body}")),
266                (Profile::Stripe | Profile::Slack, None) => {
267                    Err("this profile requires a timestamp".to_string())
268                }
269            }
270        }
271    }
272}
273
274/// `jwt-rs256`: the JOSE signing input (RFC 7515). whip builds the input;
275/// the custodian signs it RSASSA-PKCS1-v1_5/SHA-256, which is deterministic,
276/// so vendor vectors pin the whole path.
277pub mod jwt {
278    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
279    use base64::Engine as _;
280
281    /// `base64url(header) . base64url(claims)`, unpadded, over the exact
282    /// serialized bytes given — JSON canonicalization is deliberately NOT
283    /// applied, because the signature covers the bytes, not the semantics.
284    pub fn signing_input(header_json: &[u8], claims_json: &[u8]) -> String {
285        format!(
286            "{}.{}",
287            URL_SAFE_NO_PAD.encode(header_json),
288            URL_SAFE_NO_PAD.encode(claims_json)
289        )
290    }
291
292    pub fn assemble(signing_input: &str, signature: &[u8]) -> String {
293        format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature))
294    }
295}