use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
const SEP: u8 = 0x1f;
#[must_use]
pub fn mint_observation_handle(
secret: &[u8],
conversation_id: &str,
routine_uid: &str,
expiry_unix: u64,
) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(conversation_id.as_bytes());
mac.update(&[SEP]);
mac.update(routine_uid.as_bytes());
mac.update(&[SEP]);
mac.update(expiry_unix.to_string().as_bytes());
crate::hex::lower(&mac.finalize().into_bytes())
}
#[must_use]
pub fn verify_observation_handle(
secret: &[u8],
conversation_id: &str,
routine_uid: &str,
expiry_unix: u64,
handle: &str,
now_unix: u64,
) -> bool {
if now_unix > expiry_unix {
return false;
}
let Some(provided) = crate::hex::decode(handle) else {
return false;
};
let expected = mint_observation_handle(secret, conversation_id, routine_uid, expiry_unix);
let expected_raw =
crate::hex::decode(&expected).expect("mint_observation_handle returns valid hex");
expected_raw.as_slice().ct_eq(&provided).into()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
const SECRET: &[u8] = b"routine-observation-secret";
const OTHER_SECRET: &[u8] = b"a-completely-different-secret";
const CONV: &str = "conv-abc";
const ROUTINE_UID: &str = "uid-1234";
const EXPIRY: u64 = 1_700_000_600;
fn good_handle() -> String {
mint_observation_handle(SECRET, CONV, ROUTINE_UID, EXPIRY)
}
#[test]
fn valid_unexpired_handle_verifies() {
let handle = good_handle();
assert!(verify_observation_handle(
SECRET,
CONV,
ROUTINE_UID,
EXPIRY,
&handle,
1_700_000_000,
));
}
#[test]
fn handle_valid_exactly_at_the_expiry_boundary_but_not_after() {
let handle = good_handle();
assert!(
verify_observation_handle(SECRET, CONV, ROUTINE_UID, EXPIRY, &handle, EXPIRY),
"now == expiry must still verify"
);
assert!(
!verify_observation_handle(SECRET, CONV, ROUTINE_UID, EXPIRY, &handle, EXPIRY + 1),
"one second past expiry must not verify"
);
}
#[test]
fn expired_handle_rejected_even_if_authentic() {
let handle = good_handle();
assert!(!verify_observation_handle(
SECRET,
CONV,
ROUTINE_UID,
EXPIRY,
&handle,
1_700_000_601,
));
}
#[test]
fn wrong_conversation_invalidates_the_handle() {
let handle = good_handle();
assert!(!verify_observation_handle(
SECRET,
"conv-other",
ROUTINE_UID,
EXPIRY,
&handle,
1_700_000_000,
));
}
#[test]
fn wrong_routine_uid_invalidates_the_handle() {
let handle = good_handle();
assert!(!verify_observation_handle(
SECRET,
CONV,
"uid-9999",
EXPIRY,
&handle,
1_700_000_000,
));
}
#[test]
fn tampered_handle_is_rejected() {
let mut handle = good_handle();
let last = handle.pop().unwrap();
handle.push(if last == 'f' { '0' } else { 'f' });
assert!(!verify_observation_handle(
SECRET,
CONV,
ROUTINE_UID,
EXPIRY,
&handle,
1_700_000_000,
));
}
#[test]
fn non_hex_handle_rejected() {
assert!(!verify_observation_handle(
SECRET,
CONV,
ROUTINE_UID,
EXPIRY,
"not-hex-zz",
1_700_000_000,
));
}
#[test]
fn field_boundary_shift_resists_collision() {
let a = mint_observation_handle(SECRET, "ab", "c", EXPIRY);
let b = mint_observation_handle(SECRET, "a", "bc", EXPIRY);
assert_ne!(a, b);
}
#[test]
fn a_handle_minted_under_a_different_key_does_not_verify() {
let handle = mint_observation_handle(OTHER_SECRET, CONV, ROUTINE_UID, EXPIRY);
assert!(!verify_observation_handle(
SECRET,
CONV,
ROUTINE_UID,
EXPIRY,
&handle,
1_700_000_000,
));
}
#[test]
fn changing_the_expiry_the_handle_was_minted_for_invalidates_it() {
let handle = mint_observation_handle(SECRET, CONV, ROUTINE_UID, EXPIRY);
assert!(!verify_observation_handle(
SECRET,
CONV,
ROUTINE_UID,
EXPIRY + 1_000,
&handle,
1_700_000_000,
));
}
}