use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HashBindingError {
ClaimMissing,
Mismatch,
}
#[must_use]
pub(crate) fn compute(subject: &[u8]) -> String {
let digest = Sha256::digest(subject);
B64.encode(&digest[..16])
}
pub(crate) fn verify_match(
payload: &serde_json::Value,
claim_name: &str,
expected_subject: &[u8],
) -> Result<(), HashBindingError> {
let claim = payload
.get(claim_name)
.and_then(|v| v.as_str())
.ok_or(HashBindingError::ClaimMissing)?;
let expected = compute(expected_subject);
if claim != expected {
return Err(HashBindingError::Mismatch);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn compute_empty_input_matches_rfc_6234_leftmost_128b_b64url() {
let got = compute(b"");
assert_eq!(
got, "47DEQpj8HBSa-_TImW-5JA",
"SHA-256(\"\") leftmost 128 bits base64url no-pad must match RFC 6234"
);
}
#[test]
fn compute_output_is_always_22_chars() {
for input in [b"".as_slice(), b"a", b"hello", b"a long subject string with lots of bytes"] {
let h = compute(input);
assert_eq!(h.len(), 22, "leftmost-128-bit base64url-no-pad is 22 chars");
assert!(!h.contains('='), "no padding allowed");
assert!(!h.contains('+') && !h.contains('/'), "URL-safe alphabet only");
}
}
#[test]
fn compute_is_deterministic() {
let a = compute(b"some-access-token-string.with.three.dots");
let b = compute(b"some-access-token-string.with.three.dots");
assert_eq!(a, b);
}
#[test]
fn verify_match_passes_when_claim_equals_compute() {
let subject = b"forged-access-token-jws-compact";
let expected = compute(subject);
let payload = json!({ "at_hash": expected });
assert_eq!(verify_match(&payload, "at_hash", subject), Ok(()));
}
#[test]
fn verify_match_returns_claim_missing_when_absent() {
let payload = json!({ "iss": "x" });
assert_eq!(
verify_match(&payload, "at_hash", b"any"),
Err(HashBindingError::ClaimMissing)
);
}
#[test]
fn verify_match_returns_claim_missing_when_non_string() {
let payload = json!({ "at_hash": 42 });
assert_eq!(
verify_match(&payload, "at_hash", b"any"),
Err(HashBindingError::ClaimMissing)
);
}
#[test]
fn verify_match_returns_mismatch_when_disagree() {
let payload = json!({ "at_hash": compute(b"token-A") });
assert_eq!(
verify_match(&payload, "at_hash", b"token-B"),
Err(HashBindingError::Mismatch)
);
}
#[test]
fn verify_match_works_for_both_at_hash_and_c_hash_claims() {
let token = b"forged-access-token";
let code = b"oauth2-authorization-code-xyz";
let payload = json!({
"at_hash": compute(token),
"c_hash": compute(code),
});
assert_eq!(verify_match(&payload, "at_hash", token), Ok(()));
assert_eq!(verify_match(&payload, "c_hash", code), Ok(()));
assert_eq!(
verify_match(&payload, "at_hash", code),
Err(HashBindingError::Mismatch)
);
}
}