1use 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
22pub 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 pub derivation: Vec<String>,
37 pub scope: String,
40 }
41
42 pub struct Input<'a> {
43 pub method: &'a str,
44 pub path: &'a str,
46 pub query: &'a str,
48 pub headers: &'a [(String, String)],
51 pub payload_hash_hex: &'a str,
53 pub amz_date: &'a str,
55 pub region: &'a str,
56 pub service: &'a str,
57 pub normalize_path: bool,
60 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 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 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 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 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
224pub mod webhook {
228 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
229 pub enum Profile {
230 Github,
232 Stripe,
234 Slack,
236 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 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
274pub mod jwt {
278 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
279 use base64::Engine as _;
280
281 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}