use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::Mac as _;
use subtle::ConstantTimeEq as _;
use crate::session_binding::{IdentityFingerprint, SessionBindingSecret, keyed_mac};
const TASK_VERSION: &str = "t1";
const DOMAIN_SEPARATOR: u8 = 0;
const TASK_MAC_DOMAIN: &[u8] = b"rmcp-server-kit/task-id-binding/v1";
const MAX_RAW_TASK_ID_BYTES: usize = 512;
const MAC_LEN: usize = 32;
const MAC_B64_LEN: usize = 43;
const MAX_RAW_TASK_ID_B64_LEN: usize = MAX_RAW_TASK_ID_BYTES.div_ceil(3) * 4;
const MAX_WRAPPED_TASK_TOKEN_LEN: usize =
TASK_VERSION.len() + 1 + MAX_RAW_TASK_ID_B64_LEN + 1 + MAC_B64_LEN;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RawTaskId(String);
impl RawTaskId {
pub(crate) fn parse(raw: &str) -> Option<Self> {
if raw.is_empty() || raw.len() > MAX_RAW_TASK_ID_BYTES {
return None;
}
Some(Self(raw.to_owned()))
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
pub(crate) fn wrap(
secret: &SessionBindingSecret,
raw_id: &RawTaskId,
fp: &IdentityFingerprint,
) -> String {
let mac = compute_mac(secret, raw_id, fp);
format!(
"{TASK_VERSION}.{}.{}",
URL_SAFE_NO_PAD.encode(&raw_id.0),
URL_SAFE_NO_PAD.encode(mac)
)
}
pub(crate) fn unwrap_and_verify(
secret: &SessionBindingSecret,
token: &str,
fp: &IdentityFingerprint,
) -> Option<RawTaskId> {
if token.len() > MAX_WRAPPED_TASK_TOKEN_LEN {
return None;
}
let (raw_part, mac_part) = split_token(token)?;
if mac_part.len() != MAC_B64_LEN || raw_part.is_empty() {
return None;
}
let mut mac = [0u8; MAC_LEN];
let mac_len = URL_SAFE_NO_PAD.decode_slice(mac_part, &mut mac).ok()?;
if mac_len != MAC_LEN {
return None;
}
let raw_bytes = URL_SAFE_NO_PAD.decode(raw_part).ok()?;
let raw_str = std::str::from_utf8(&raw_bytes).ok()?;
let raw_id = RawTaskId::parse(raw_str)?;
let expected = compute_mac(secret, &raw_id, fp);
if expected.ct_eq(&mac).into() {
Some(raw_id)
} else {
None
}
}
fn split_token(token: &str) -> Option<(&str, &str)> {
let mut parts = token.split('.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(TASK_VERSION), Some(raw), Some(mac), None) => Some((raw, mac)),
_ => None,
}
}
fn compute_mac(
secret: &SessionBindingSecret,
raw_id: &RawTaskId,
fp: &IdentityFingerprint,
) -> [u8; MAC_LEN] {
let mut mac = keyed_mac(secret);
mac.update(TASK_MAC_DOMAIN);
mac.update(&[DOMAIN_SEPARATOR]);
mac.update(raw_id.0.as_bytes());
mac.update(&[DOMAIN_SEPARATOR]);
mac.update(fp.as_bytes());
mac.finalize().into_bytes().into()
}
#[cfg(test)]
mod tests {
use secrecy::SecretString;
use super::{
MAX_RAW_TASK_ID_BYTES, MAX_WRAPPED_TASK_TOKEN_LEN, RawTaskId, unwrap_and_verify, wrap,
};
use crate::{
auth::{AuthIdentity, AuthMethod},
session_binding::{SessionBindingSecret, fingerprint},
};
fn secret() -> SessionBindingSecret {
SessionBindingSecret::Configured(SecretString::from(
"test-secret-that-is-at-least-32-bytes-long".to_owned(),
))
}
fn identity(name: &str) -> AuthIdentity {
AuthIdentity {
name: name.to_owned(),
role: "ops".to_owned(),
method: AuthMethod::BearerToken,
raw_token: None,
sub: None,
}
}
#[test]
fn roundtrip_recovers_the_raw_id() {
let s = secret();
let fp = fingerprint(&identity("alice"));
let raw = RawTaskId::parse("task-abc-123").expect("valid id");
let token = wrap(&s, &raw, &fp);
assert!(token.starts_with("t1."), "token must carry the t1 prefix");
assert!(
!token.contains("task-abc-123"),
"raw id must not appear verbatim in the token"
);
assert_eq!(unwrap_and_verify(&s, &token, &fp), Some(raw));
}
#[test]
fn another_identity_cannot_verify_the_token() {
let s = secret();
let alice = fingerprint(&identity("alice"));
let bob = fingerprint(&identity("bob"));
let raw = RawTaskId::parse("task-abc-123").expect("valid id");
let token = wrap(&s, &raw, &alice);
assert_eq!(
unwrap_and_verify(&s, &token, &bob),
None,
"a token minted for alice must not verify for bob"
);
}
#[test]
fn raw_unwrapped_id_is_rejected() {
let s = secret();
let fp = fingerprint(&identity("alice"));
assert_eq!(unwrap_and_verify(&s, "task-abc-123", &fp), None);
assert_eq!(
unwrap_and_verify(&s, "550e8400-e29b-41d4-a716-446655440000", &fp),
None
);
}
#[test]
fn a_different_secret_does_not_verify() {
let fp = fingerprint(&identity("alice"));
let raw = RawTaskId::parse("task-abc-123").expect("valid id");
let token = wrap(&secret(), &raw, &fp);
let rotated = SessionBindingSecret::Configured(SecretString::from(
"a-completely-different-secret-over-32-bytes".to_owned(),
));
assert_eq!(unwrap_and_verify(&rotated, &token, &fp), None);
}
#[test]
fn session_and_task_tokens_are_domain_separated() {
use crate::session_binding::{
RawSessionId, unwrap_and_verify as session_unwrap, wrap as session_wrap,
};
let s = secret();
let fp = fingerprint(&identity("alice"));
let uuid = "550e8400-e29b-41d4-a716-446655440000";
let raw_session = RawSessionId::parse(uuid).expect("valid uuid");
let session_token = session_wrap(&s, &raw_session, &fp);
assert_eq!(
unwrap_and_verify(&s, &session_token, &fp),
None,
"a session token must not verify as a task token"
);
let raw_task = RawTaskId::parse(uuid).expect("valid id");
let task_token = wrap(&s, &raw_task, &fp);
assert!(
session_unwrap(&s, &task_token, &fp).is_err(),
"a task token must not verify as a session token"
);
}
#[test]
fn oversized_token_is_rejected_before_decoding() {
let s = secret();
let fp = fingerprint(&identity("alice"));
let huge = format!(
"t1.{}.{}",
"A".repeat(MAX_WRAPPED_TASK_TOKEN_LEN),
"B".repeat(43)
);
assert_eq!(unwrap_and_verify(&s, &huge, &fp), None);
}
#[test]
fn empty_and_oversized_raw_ids_are_rejected() {
assert_eq!(RawTaskId::parse(""), None);
assert_eq!(
RawTaskId::parse(&"x".repeat(MAX_RAW_TASK_ID_BYTES + 1)),
None
);
assert!(RawTaskId::parse(&"x".repeat(MAX_RAW_TASK_ID_BYTES)).is_some());
}
#[test]
fn malformed_shapes_are_rejected() {
let s = secret();
let fp = fingerprint(&identity("alice"));
for bad in ["", "t1", "t1.", "t1.a", "v1.a.b", "t1.a.b.c", "t2.a.b"] {
assert_eq!(unwrap_and_verify(&s, bad, &fp), None, "must reject {bad:?}");
}
}
}