Skip to content

Commit 34d5dba

Browse files
authored
Merge pull request #92 from barbacane-dev/security/auth-crypto-hardening
Security: auth/crypto hardening (constant-time, JWT/OIDC binding, SigV4, drop hand-rolled crypto)
2 parents bf1d4f8 + 2147b1c commit 34d5dba

10 files changed

Lines changed: 338 additions & 265 deletions

File tree

crates/barbacane-plugin-sdk/src/types.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,23 @@ pub fn streamed_response() -> Response {
143143
}
144144
}
145145

146+
/// Constant-time byte-slice equality, for comparing secrets (API keys,
147+
/// passwords) without leaking how many leading bytes matched through timing.
148+
///
149+
/// Like `subtle`/`ring::constant_time`, unequal lengths return `false` early
150+
/// (only the length is revealed, never the content); equal-length inputs are
151+
/// compared in time independent of where they differ.
152+
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
153+
if a.len() != b.len() {
154+
return false;
155+
}
156+
let mut diff = 0u8;
157+
for (x, y) in a.iter().zip(b.iter()) {
158+
diff |= x ^ y;
159+
}
160+
diff == 0
161+
}
162+
146163
/// Resolve the effective client IP, honoring trusted proxies.
147164
///
148165
/// `req.client_ip` is the peer the gateway actually observed. The forwarded
@@ -526,6 +543,15 @@ mod tests {
526543
}
527544
}
528545

546+
#[test]
547+
fn constant_time_eq_matches_std_equality() {
548+
assert!(constant_time_eq(b"secret-key", b"secret-key"));
549+
assert!(!constant_time_eq(b"secret-key", b"secret-different"));
550+
assert!(!constant_time_eq(b"secret-key", b"secret-keX"));
551+
assert!(!constant_time_eq(b"short", b"longer-value"));
552+
assert!(constant_time_eq(b"", b""));
553+
}
554+
529555
#[test]
530556
fn resolve_client_ip_ignores_xff_from_untrusted_peer() {
531557
// No trusted proxies: a spoofed XFF must be ignored.

crates/barbacane-sigv4/src/lib.rs

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,14 @@ pub fn canonical_query(query: Option<&str>) -> String {
142142
Some(pos) => (&part[..pos], &part[pos + 1..]),
143143
None => (part, ""),
144144
};
145-
Some((percent_encode_query(key), percent_encode_query(value)))
145+
// Decode any existing percent-encoding first, then re-encode per the
146+
// SigV4 rules. This canonicalizes already-encoded input (e.g.
147+
// `logs%2F` stays `logs%2F` instead of double-encoding to `logs%252F`)
148+
// so the signed query equals what callers transmit.
149+
Some((
150+
percent_encode_query(&percent_decode(key)),
151+
percent_encode_query(&percent_decode(value)),
152+
))
146153
})
147154
.collect();
148155

@@ -155,16 +162,25 @@ pub fn canonical_query(query: Option<&str>) -> String {
155162
.join("&")
156163
}
157164

165+
/// Canonicalize a header value per SigV4: trim, and collapse sequential
166+
/// internal whitespace to a single space (for non-quoted values).
167+
fn canonical_header_value(v: &str) -> String {
168+
v.split_whitespace().collect::<Vec<_>>().join(" ")
169+
}
170+
158171
/// Compute the SigV4 `Authorization` header and related signed headers.
159172
///
160173
/// # Panics
161174
/// Never panics — HMAC accepts any key length.
162175
pub fn sign(input: &SigningInput, creds: &Credentials, config: &SigningConfig) -> SignedHeaders {
163176
// BTreeMap guarantees keys are already sorted; keys must be lowercase.
177+
// SigV4 requires header values to be trimmed AND have sequential internal
178+
// whitespace collapsed to a single space; `v.trim()` alone diverges from
179+
// AWS's recomputation for multi-space values.
164180
let canonical_headers_str: String = input
165181
.headers_to_sign
166182
.iter()
167-
.map(|(k, v)| format!("{}:{}\n", k, v.trim()))
183+
.map(|(k, v)| format!("{}:{}\n", k, canonical_header_value(v)))
168184
.collect();
169185

170186
let signed_headers_str: String = input
@@ -232,6 +248,35 @@ fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
232248

233249
/// Percent-encode a query string key or value (SigV4 rules).
234250
/// Encodes all bytes except unreserved characters (`A-Z a-z 0-9 - _ . ~`).
251+
/// Decode percent-escapes (`%XX`) in a query component. Invalid escapes are left
252+
/// as-is. `+` is treated literally (RFC 3986 query semantics), matching SigV4.
253+
fn percent_decode(s: &str) -> String {
254+
let bytes = s.as_bytes();
255+
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
256+
let mut i = 0;
257+
while i < bytes.len() {
258+
if bytes[i] == b'%' && i + 3 <= bytes.len() {
259+
if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
260+
out.push((hi << 4) | lo);
261+
i += 3;
262+
continue;
263+
}
264+
}
265+
out.push(bytes[i]);
266+
i += 1;
267+
}
268+
String::from_utf8_lossy(&out).into_owned()
269+
}
270+
271+
fn hex_val(c: u8) -> Option<u8> {
272+
match c {
273+
b'0'..=b'9' => Some(c - b'0'),
274+
b'a'..=b'f' => Some(c - b'a' + 10),
275+
b'A'..=b'F' => Some(c - b'A' + 10),
276+
_ => None,
277+
}
278+
}
279+
235280
fn percent_encode_query(s: &str) -> String {
236281
let mut result = String::with_capacity(s.len());
237282
for byte in s.bytes() {
@@ -367,6 +412,26 @@ mod tests {
367412
);
368413
}
369414

415+
// CR-5: already-encoded input is decoded then re-encoded (not double-encoded),
416+
// so the signed query matches what is transmitted.
417+
#[test]
418+
fn test_canonical_query_does_not_double_encode() {
419+
assert_eq!(
420+
canonical_query(Some("prefix=logs%2Fdir&list-type=2")),
421+
"list-type=2&prefix=logs%2Fdir"
422+
);
423+
// Idempotent: canonicalizing twice yields the same result.
424+
let once = canonical_query(Some("k=a%2Bb"));
425+
assert_eq!(canonical_query(Some(&once)), once);
426+
}
427+
428+
// CR-5: header values are trimmed AND internal whitespace is collapsed.
429+
#[test]
430+
fn test_canonical_header_value_collapses_whitespace() {
431+
assert_eq!(canonical_header_value(" a b\tc "), "a b c");
432+
assert_eq!(canonical_header_value("single"), "single");
433+
}
434+
370435
#[test]
371436
fn test_canonical_query_no_value() {
372437
// Key without value

0 commit comments

Comments
 (0)