use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq as _;
#[must_use]
pub fn seal_challenge(challenge: &[u8], context: &[u8], secret: &[u8]) -> String {
let challenge_b64 = URL_SAFE_NO_PAD.encode(challenge);
let context_b64 = URL_SAFE_NO_PAD.encode(context);
let payload = format!("{challenge_b64}.{context_b64}");
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret).expect("HMAC key of any size");
mac.update(payload.as_bytes());
let sig = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
format!("{payload}.{sig}")
}
#[must_use]
pub fn open_challenge(sealed: &str, secret: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
let last_dot = sealed.rfind('.')?;
let (payload, sig_b64) = (&sealed[..last_dot], &sealed[last_dot + 1..]);
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret).expect("HMAC key of any size");
mac.update(payload.as_bytes());
let expected = mac.finalize().into_bytes();
let provided = URL_SAFE_NO_PAD.decode(sig_b64).ok()?;
if expected.ct_eq(&provided).unwrap_u8() == 0 {
return None;
}
let (challenge_b64, context_b64) = payload.split_once('.')?;
let challenge = URL_SAFE_NO_PAD.decode(challenge_b64).ok()?;
let context = URL_SAFE_NO_PAD.decode(context_b64).ok()?;
Some((challenge, context))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_open_round_trip_with_context() {
let secret = b"signing-secret";
let challenge = b"\x00\x01\x02 random-32-byte-ish challenge";
let token = seal_challenge(challenge, b"user-42", secret);
let (got_challenge, got_ctx) = open_challenge(&token, secret).expect("opens");
assert_eq!(got_challenge, challenge);
assert_eq!(got_ctx, b"user-42");
}
#[test]
fn empty_context_round_trips() {
let secret = b"k";
let token = seal_challenge(b"chal", b"", secret);
let (c, ctx) = open_challenge(&token, secret).unwrap();
assert_eq!(c, b"chal");
assert!(ctx.is_empty());
}
#[test]
fn wrong_secret_is_rejected() {
let token = seal_challenge(b"chal", b"ctx", b"secret-A");
assert!(open_challenge(&token, b"secret-B").is_none());
}
#[test]
fn tampered_token_is_rejected() {
let token = seal_challenge(b"chal", b"ctx", b"secret");
let mut bytes = token.into_bytes();
bytes[0] ^= 0x01;
let tampered = String::from_utf8(bytes).unwrap();
assert!(open_challenge(&tampered, b"secret").is_none());
}
#[test]
fn malformed_token_is_none() {
assert!(open_challenge("nodothere", b"s").is_none());
assert!(open_challenge("only.onedot", b"s").is_none());
}
}