use std::{
collections::{HashMap, hash_map::Entry},
time::Duration,
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, KeyInit, Mac};
use serde::{Serialize, de::DeserializeOwned};
use sha2::Sha256;
use thiserror::Error;
use zeroize::Zeroizing;
type HmacSha256 = Hmac<Sha256>;
const VERSION_V1: &str = "rs1";
const VERSION_V2: &str = "rs2";
const DOMAIN_V1: &[u8] = b"rmcp/mrtr/request-state/v1";
const DOMAIN_V2: &[u8] = b"rmcp/mrtr/request-state/v2";
const EXPIRY_LEN: usize = 8;
const MAX_KID_LEN: usize = 255;
const MAX_ENCODED_KID_LEN: usize = 340;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RequestStateError {
#[error(
"request state signing key is too short: expected at least {minimum} bytes, got {actual}"
)]
KeyTooShort { minimum: usize, actual: usize },
#[error("request state is malformed or uses an unsupported format")]
MalformedFormat,
#[error("request state is not valid base64url")]
InvalidEncoding,
#[error("request state failed integrity verification")]
IntegrityCheckFailed,
#[error("request state has expired")]
Expired,
#[error("request state was sealed with an unknown key")]
UnknownKeyId,
#[error("request state contains an invalid key identifier")]
InvalidKeyId,
#[error("invalid keyring configuration: {0}")]
InvalidKeyring(&'static str),
#[error("failed to serialize request state payload: {0}")]
Serialization(#[source] serde_json::Error),
#[error("failed to deserialize request state payload: {0}")]
Deserialization(#[source] serde_json::Error),
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SealOptions<'a> {
associated_data: &'a [u8],
ttl: Option<Duration>,
}
impl<'a> SealOptions<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn associated_data(mut self, associated_data: &'a [u8]) -> Self {
self.associated_data = associated_data;
self
}
pub fn ttl(mut self, ttl: Duration) -> Self {
self.ttl = Some(ttl);
self
}
}
#[derive(Clone)]
pub struct RequestStateCodec {
keys: Keys,
}
#[derive(Clone)]
enum Keys {
Single(Zeroizing<Vec<u8>>),
Ring {
keys: HashMap<String, Zeroizing<Vec<u8>>>,
seal_mode: SealMode,
rs1_fallbacks: Vec<String>,
},
}
#[derive(Clone, Debug)]
enum SealMode {
Rs1 { key_id: String },
Rs2 { key_id: String },
}
impl std::fmt::Debug for RequestStateCodec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.keys {
Keys::Single(_) => f
.debug_struct("RequestStateCodec")
.field("mode", &"single")
.field("key", &"<redacted>")
.finish(),
Keys::Ring {
keys,
seal_mode,
rs1_fallbacks,
} => f
.debug_struct("RequestStateCodec")
.field("mode", &"ring")
.field("key_count", &keys.len())
.field("keys", &"<redacted>")
.field("seal_mode", seal_mode)
.field("rs1_fallbacks", rs1_fallbacks)
.finish(),
}
}
}
impl RequestStateCodec {
pub const MIN_KEY_LENGTH: usize = 32;
#[deprecated(
since = "3.1.4",
note = "use RequestStateCodec::try_new to enforce the minimum signing-key length"
)]
pub fn new(key: impl Into<Vec<u8>>) -> Self {
Self::new_unchecked(key)
}
pub fn new_unchecked(key: impl Into<Vec<u8>>) -> Self {
Self {
keys: Keys::Single(Zeroizing::new(key.into())),
}
}
pub fn try_new(key: impl Into<Vec<u8>>) -> Result<Self, RequestStateError> {
let key = Zeroizing::new(key.into());
Self::validate_key_length(&key)?;
Ok(Self {
keys: Keys::Single(key),
})
}
pub fn new_with_keyring<K, V>(
active_kid: impl Into<String>,
keys: impl IntoIterator<Item = (K, V)>,
) -> Result<Self, RequestStateError>
where
K: Into<String>,
V: Into<Vec<u8>>,
{
let mut ring = HashMap::new();
for (kid, key) in keys {
let kid = kid.into();
let key = Zeroizing::new(key.into());
Self::validate_config_kid(&kid)?;
Self::validate_key_length(&key)?;
match ring.entry(kid) {
Entry::Vacant(entry) => {
entry.insert(key);
}
Entry::Occupied(_) => {
return Err(RequestStateError::InvalidKeyring("duplicate key id"));
}
}
}
if ring.is_empty() {
return Err(RequestStateError::InvalidKeyring(
"keyring must contain at least one key",
));
}
let active_kid = active_kid.into();
Self::validate_config_kid(&active_kid)?;
if !ring.contains_key(&active_kid) {
return Err(RequestStateError::InvalidKeyring(
"active key id is not present in the keyring",
));
}
Ok(Self {
keys: Keys::Ring {
keys: ring,
seal_mode: SealMode::Rs2 { key_id: active_kid },
rs1_fallbacks: Vec::new(),
},
})
}
pub fn with_rs1_signing(mut self, kid: impl AsRef<str>) -> Result<Self, RequestStateError> {
let kid = kid.as_ref();
match &mut self.keys {
Keys::Single(_) => Err(RequestStateError::InvalidKeyring(
"rs1 transitional signing requires a keyring",
)),
Keys::Ring {
keys,
seal_mode,
rs1_fallbacks,
} => {
if !keys.contains_key(kid) {
return Err(RequestStateError::InvalidKeyring(
"rs1 signing key id is not present in the keyring",
));
}
let kid = kid.to_owned();
*seal_mode = SealMode::Rs1 {
key_id: kid.clone(),
};
if !rs1_fallbacks.iter().any(|existing| existing == &kid) {
rs1_fallbacks.push(kid);
}
Ok(self)
}
}
}
pub fn with_rs1_fallback(mut self, kid: impl AsRef<str>) -> Result<Self, RequestStateError> {
let kid = kid.as_ref();
match &mut self.keys {
Keys::Single(_) => Err(RequestStateError::InvalidKeyring(
"rs1 fallbacks require a keyring",
)),
Keys::Ring {
keys,
rs1_fallbacks,
..
} => {
if !keys.contains_key(kid) {
return Err(RequestStateError::InvalidKeyring(
"rs1 fallback key id is not present in the keyring",
));
}
if !rs1_fallbacks.iter().any(|existing| existing == kid) {
rs1_fallbacks.push(kid.to_owned());
}
Ok(self)
}
}
}
pub fn seal(&self, payload: &[u8]) -> String {
self.seal_with(payload, &SealOptions::default())
}
pub fn seal_with(&self, payload: &[u8], options: &SealOptions<'_>) -> String {
self.seal_at(payload, options, Self::now_ms())
}
pub fn seal_json<T: Serialize>(&self, value: &T) -> Result<String, RequestStateError> {
self.seal_json_with(value, &SealOptions::default())
}
pub fn seal_json_with<T: Serialize>(
&self,
value: &T,
options: &SealOptions<'_>,
) -> Result<String, RequestStateError> {
let payload = serde_json::to_vec(value).map_err(RequestStateError::Serialization)?;
Ok(self.seal_with(&payload, options))
}
pub fn open(&self, sealed: &str) -> Result<Vec<u8>, RequestStateError> {
self.open_with(sealed, &[])
}
pub fn open_with(
&self,
sealed: &str,
associated_data: &[u8],
) -> Result<Vec<u8>, RequestStateError> {
self.open_at(sealed, associated_data, Self::now_ms())
}
pub fn open_json<T: DeserializeOwned>(&self, sealed: &str) -> Result<T, RequestStateError> {
self.open_json_with(sealed, &[])
}
pub fn open_json_with<T: DeserializeOwned>(
&self,
sealed: &str,
associated_data: &[u8],
) -> Result<T, RequestStateError> {
let payload = self.open_with(sealed, associated_data)?;
serde_json::from_slice(&payload).map_err(RequestStateError::Deserialization)
}
fn seal_at(&self, payload: &[u8], options: &SealOptions<'_>, now_ms: i64) -> String {
let expiry = match options.ttl {
Some(ttl) => now_ms.saturating_add(ttl.as_millis().min(i64::MAX as u128) as i64),
None => 0,
};
let mut body = Vec::with_capacity(EXPIRY_LEN + payload.len());
body.extend_from_slice(&expiry.to_be_bytes());
body.extend_from_slice(payload);
match &self.keys {
Keys::Single(key) => Self::seal_rs1(key, options.associated_data, &body),
Keys::Ring {
keys, seal_mode, ..
} => match seal_mode {
SealMode::Rs1 { key_id } => Self::seal_rs1(
keys.get(key_id).expect("validated rs1 signing key"),
options.associated_data,
&body,
),
SealMode::Rs2 { key_id } => Self::seal_rs2(
key_id,
keys.get(key_id).expect("validated rs2 signing key"),
options.associated_data,
&body,
),
},
}
}
fn open_at(
&self,
sealed: &str,
associated_data: &[u8],
now_ms: i64,
) -> Result<Vec<u8>, RequestStateError> {
match sealed.split('.').next() {
Some(VERSION_V1) => self.open_rs1_at(sealed, associated_data, now_ms),
Some(VERSION_V2) => self.open_rs2_at(sealed, associated_data, now_ms),
_ => Err(RequestStateError::MalformedFormat),
}
}
fn seal_rs1(key: &[u8], associated_data: &[u8], body: &[u8]) -> String {
let tag = Self::mac_v1(key, associated_data, body)
.finalize()
.into_bytes();
let mut out = String::with_capacity(
VERSION_V1.len() + 2 + Self::b64_len(body.len()) + Self::b64_len(tag.len()),
);
out.push_str(VERSION_V1);
out.push('.');
URL_SAFE_NO_PAD.encode_string(body, &mut out);
out.push('.');
URL_SAFE_NO_PAD.encode_string(tag.as_slice(), &mut out);
out
}
fn seal_rs2(kid: &str, key: &[u8], associated_data: &[u8], body: &[u8]) -> String {
let tag = Self::mac_v2(key, kid.as_bytes(), associated_data, body)
.finalize()
.into_bytes();
let mut out = String::with_capacity(
VERSION_V2.len()
+ 3
+ Self::b64_len(kid.len())
+ Self::b64_len(body.len())
+ Self::b64_len(tag.len()),
);
out.push_str(VERSION_V2);
out.push('.');
URL_SAFE_NO_PAD.encode_string(kid.as_bytes(), &mut out);
out.push('.');
URL_SAFE_NO_PAD.encode_string(body, &mut out);
out.push('.');
URL_SAFE_NO_PAD.encode_string(tag.as_slice(), &mut out);
out
}
fn open_rs1_at(
&self,
sealed: &str,
associated_data: &[u8],
now_ms: i64,
) -> Result<Vec<u8>, RequestStateError> {
let mut parts = sealed.split('.');
let version = parts.next().ok_or(RequestStateError::MalformedFormat)?;
let body_b64 = parts.next().ok_or(RequestStateError::MalformedFormat)?;
let tag_b64 = parts.next().ok_or(RequestStateError::MalformedFormat)?;
if parts.next().is_some() || version != VERSION_V1 {
return Err(RequestStateError::MalformedFormat);
}
if matches!(
&self.keys,
Keys::Ring { rs1_fallbacks, .. } if rs1_fallbacks.is_empty()
) {
return Err(RequestStateError::UnknownKeyId);
}
let body = URL_SAFE_NO_PAD
.decode(body_b64)
.map_err(|_| RequestStateError::InvalidEncoding)?;
let tag = URL_SAFE_NO_PAD
.decode(tag_b64)
.map_err(|_| RequestStateError::InvalidEncoding)?;
match &self.keys {
Keys::Single(key) => Self::mac_v1(key, associated_data, &body)
.verify_slice(&tag)
.map_err(|_| RequestStateError::IntegrityCheckFailed)?,
Keys::Ring {
keys,
rs1_fallbacks,
..
} => {
let mut verified = false;
for kid in rs1_fallbacks {
let key = keys.get(kid).expect("validated rs1 fallback key");
let matches = Self::mac_v1(key, associated_data, &body)
.verify_slice(&tag)
.is_ok();
verified |= matches;
}
if !verified {
return Err(RequestStateError::IntegrityCheckFailed);
}
}
}
Self::open_authenticated_body(body, now_ms)
}
fn open_rs2_at(
&self,
sealed: &str,
associated_data: &[u8],
now_ms: i64,
) -> Result<Vec<u8>, RequestStateError> {
let mut parts = sealed.split('.');
let version = parts.next().ok_or(RequestStateError::MalformedFormat)?;
let kid_b64 = parts.next().ok_or(RequestStateError::MalformedFormat)?;
let body_b64 = parts.next().ok_or(RequestStateError::MalformedFormat)?;
let tag_b64 = parts.next().ok_or(RequestStateError::MalformedFormat)?;
if parts.next().is_some() || version != VERSION_V2 {
return Err(RequestStateError::MalformedFormat);
}
if kid_b64.len() > MAX_ENCODED_KID_LEN {
return Err(RequestStateError::InvalidKeyId);
}
let kid = URL_SAFE_NO_PAD
.decode(kid_b64)
.map_err(|_| RequestStateError::InvalidEncoding)?;
if kid.is_empty() || kid.len() > MAX_KID_LEN {
return Err(RequestStateError::InvalidKeyId);
}
let kid = String::from_utf8(kid).map_err(|_| RequestStateError::InvalidKeyId)?;
let key = match &self.keys {
Keys::Single(_) => return Err(RequestStateError::UnknownKeyId),
Keys::Ring { keys, .. } => keys.get(&kid).ok_or(RequestStateError::UnknownKeyId)?,
};
let body = URL_SAFE_NO_PAD
.decode(body_b64)
.map_err(|_| RequestStateError::InvalidEncoding)?;
let tag = URL_SAFE_NO_PAD
.decode(tag_b64)
.map_err(|_| RequestStateError::InvalidEncoding)?;
Self::mac_v2(key, kid.as_bytes(), associated_data, &body)
.verify_slice(&tag)
.map_err(|_| RequestStateError::IntegrityCheckFailed)?;
Self::open_authenticated_body(body, now_ms)
}
fn open_authenticated_body(body: Vec<u8>, now_ms: i64) -> Result<Vec<u8>, RequestStateError> {
if body.len() < EXPIRY_LEN {
return Err(RequestStateError::MalformedFormat);
}
let expiry = i64::from_be_bytes(body[..EXPIRY_LEN].try_into().expect("checked length"));
if expiry != 0 && now_ms > expiry {
return Err(RequestStateError::Expired);
}
Ok(body[EXPIRY_LEN..].to_vec())
}
fn mac_v1(key: &[u8], associated_data: &[u8], body: &[u8]) -> HmacSha256 {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts keys of any length");
mac.update(DOMAIN_V1);
mac.update(&(associated_data.len() as u64).to_be_bytes());
mac.update(associated_data);
mac.update(body);
mac
}
fn mac_v2(key: &[u8], kid: &[u8], associated_data: &[u8], body: &[u8]) -> HmacSha256 {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts keys of any length");
mac.update(DOMAIN_V2);
mac.update(&(kid.len() as u64).to_be_bytes());
mac.update(kid);
mac.update(&(associated_data.len() as u64).to_be_bytes());
mac.update(associated_data);
mac.update(body);
mac
}
fn validate_key_length(key: &[u8]) -> Result<(), RequestStateError> {
if key.len() < Self::MIN_KEY_LENGTH {
return Err(RequestStateError::KeyTooShort {
minimum: Self::MIN_KEY_LENGTH,
actual: key.len(),
});
}
Ok(())
}
fn validate_config_kid(kid: &str) -> Result<(), RequestStateError> {
if kid.is_empty() {
return Err(RequestStateError::InvalidKeyring(
"key id must not be empty",
));
}
if kid.len() > MAX_KID_LEN {
return Err(RequestStateError::InvalidKeyring(
"key id exceeds 255 UTF-8 bytes",
));
}
Ok(())
}
fn b64_len(len: usize) -> usize {
len.div_ceil(3) * 4
}
fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn try_new_accepts_key_at_minimum_length() {
let result = RequestStateCodec::try_new(vec![0; RequestStateCodec::MIN_KEY_LENGTH]);
assert!(result.is_ok(), "unexpected result: {result:?}");
}
#[test]
fn try_new_rejects_key_below_minimum_length() {
let actual = RequestStateCodec::MIN_KEY_LENGTH - 1;
let error = RequestStateCodec::try_new(vec![0; actual]).unwrap_err();
assert!(
matches!(
&error,
RequestStateError::KeyTooShort {
minimum: RequestStateCodec::MIN_KEY_LENGTH,
actual: error_actual,
} if *error_actual == actual
),
"unexpected error: {error}"
);
}
#[test]
#[allow(deprecated)]
fn new_accepts_short_key_for_backward_compatibility() {
let codec = RequestStateCodec::new(b"key".to_vec());
assert_eq!(codec.open(&codec.seal(b"state")).unwrap(), b"state");
}
#[test]
fn seal_open_roundtrips_bytes() {
let codec =
RequestStateCodec::try_new(b"test-key-test-key-test-key-32byte".to_vec()).unwrap();
let sealed = codec.seal(b"hello world");
assert!(sealed.starts_with("rs1."));
assert_eq!(codec.open(&sealed).unwrap(), b"hello world");
}
#[test]
fn seal_open_roundtrips_json() {
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct State {
tool: String,
round: u32,
}
let codec =
RequestStateCodec::try_new(b"another-strong-signing-key-here!!".to_vec()).unwrap();
let state = State {
tool: "weather".into(),
round: 3,
};
let sealed = codec.seal_json(&state).unwrap();
let opened: State = codec.open_json(&sealed).unwrap();
assert_eq!(opened, state);
}
#[test]
fn empty_payload_roundtrips() {
let codec =
RequestStateCodec::try_new(b"empty-payload-test-signing-key!!".to_vec()).unwrap();
let sealed = codec.seal(b"");
assert_eq!(codec.open(&sealed).unwrap(), b"");
}
#[test]
fn tampered_payload_is_rejected() {
let codec =
RequestStateCodec::try_new(b"signing-key-signing-key-signing!!".to_vec()).unwrap();
let sealed = codec.seal(b"amount=100");
let mut parts: Vec<&str> = sealed.split('.').collect();
let forged_body = URL_SAFE_NO_PAD.encode(b"amount=999");
parts[1] = &forged_body;
let forged = parts.join(".");
assert!(matches!(
codec.open(&forged),
Err(RequestStateError::IntegrityCheckFailed)
));
}
#[test]
fn different_key_is_rejected() {
let signer =
RequestStateCodec::try_new(b"the-real-signing-key-value-here!!".to_vec()).unwrap();
let attacker =
RequestStateCodec::try_new(b"a-totally-different-forged-key!!!".to_vec()).unwrap();
let sealed = signer.seal(b"trusted");
assert!(matches!(
attacker.open(&sealed),
Err(RequestStateError::IntegrityCheckFailed)
));
}
#[test]
fn appended_bytes_are_rejected() {
let codec =
RequestStateCodec::try_new(b"key-key-key-key-key-key-key-key!!".to_vec()).unwrap();
let mut sealed = codec.seal(b"state");
sealed.push('x');
assert!(codec.open(&sealed).is_err());
}
#[test]
fn unsupported_version_is_malformed() {
let codec =
RequestStateCodec::try_new(b"wrong-version-test-signing-key!!".to_vec()).unwrap();
let sealed = codec.seal(b"state");
let bumped = sealed.replacen("rs1.", "rs3.", 1);
assert!(matches!(
codec.open(&bumped),
Err(RequestStateError::MalformedFormat)
));
}
#[test]
fn missing_sections_are_malformed() {
let codec =
RequestStateCodec::try_new(b"missing-sections-test-signing-key".to_vec()).unwrap();
assert!(matches!(
codec.open("rs1"),
Err(RequestStateError::MalformedFormat)
));
assert!(matches!(
codec.open("rs1.onlybody"),
Err(RequestStateError::MalformedFormat)
));
assert!(matches!(
codec.open("rs1.a.b.c"),
Err(RequestStateError::MalformedFormat)
));
}
#[test]
fn non_base64_sections_are_invalid_encoding() {
let codec =
RequestStateCodec::try_new(b"invalid-base64-test-signing-key!!".to_vec()).unwrap();
assert!(matches!(
codec.open("rs1.!!!!.!!!!"),
Err(RequestStateError::InvalidEncoding)
));
}
#[test]
fn debug_does_not_leak_key() {
let codec =
RequestStateCodec::try_new(b"super-secret-key-super-secret-key!!".to_vec()).unwrap();
let rendered = format!("{codec:?}");
assert!(!rendered.contains("super-secret-key"));
assert!(rendered.contains("redacted"));
}
mod associated_data {
use super::*;
#[test]
fn matching_context_opens() {
let codec =
RequestStateCodec::try_new(b"key-key-key-key-key-key-key-key!!".to_vec()).unwrap();
let ctx = b"user:alice|tools/call:weather";
let sealed = codec.seal_with(b"state", &SealOptions::new().associated_data(ctx));
assert_eq!(codec.open_with(&sealed, ctx).unwrap(), b"state");
}
#[test]
fn different_context_is_rejected() {
let codec =
RequestStateCodec::try_new(b"key-key-key-key-key-key-key-key!!".to_vec()).unwrap();
let sealed =
codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice"));
assert!(matches!(
codec.open_with(&sealed, b"user:bob"),
Err(RequestStateError::IntegrityCheckFailed)
));
}
#[test]
fn missing_context_is_rejected() {
let codec =
RequestStateCodec::try_new(b"key-key-key-key-key-key-key-key!!".to_vec()).unwrap();
let sealed =
codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice"));
assert!(matches!(
codec.open(&sealed),
Err(RequestStateError::IntegrityCheckFailed)
));
}
}
mod ttl {
use super::*;
const KEY: &[u8] = b"ttl-signing-key-ttl-signing-key!!";
#[test]
fn within_ttl_opens() {
let codec = RequestStateCodec::try_new(KEY.to_vec()).unwrap();
let sealed = codec.seal_at(
b"state",
&SealOptions::new().ttl(Duration::from_secs(60)),
1_000,
);
assert_eq!(codec.open_at(&sealed, &[], 31_000).unwrap(), b"state");
}
#[test]
fn past_ttl_is_expired() {
let codec = RequestStateCodec::try_new(KEY.to_vec()).unwrap();
let sealed = codec.seal_at(
b"state",
&SealOptions::new().ttl(Duration::from_secs(60)),
1_000,
);
assert!(matches!(
codec.open_at(&sealed, &[], 62_000),
Err(RequestStateError::Expired)
));
}
#[test]
fn no_ttl_never_expires() {
let codec = RequestStateCodec::try_new(KEY.to_vec()).unwrap();
let sealed = codec.seal_at(b"state", &SealOptions::new(), 1_000);
assert_eq!(codec.open_at(&sealed, &[], i64::MAX).unwrap(), b"state");
}
#[test]
fn ttl_and_associated_data_combine() {
let codec = RequestStateCodec::try_new(KEY.to_vec()).unwrap();
let ctx = b"user:alice";
let sealed = codec.seal_at(
b"state",
&SealOptions::new()
.associated_data(ctx)
.ttl(Duration::from_secs(60)),
1_000,
);
assert_eq!(codec.open_at(&sealed, ctx, 10_000).unwrap(), b"state");
assert!(matches!(
codec.open_at(&sealed, b"user:bob", 10_000),
Err(RequestStateError::IntegrityCheckFailed)
));
assert!(matches!(
codec.open_at(&sealed, ctx, 99_000),
Err(RequestStateError::Expired)
));
}
}
mod key_rotation {
use super::*;
const KEY_A: &[u8] = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const KEY_B: &[u8] = b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const KEY_C: &[u8] = b"cccccccccccccccccccccccccccccccc";
const KAT_KEY: &[u8] = b"0123456789abcdef0123456789abcdef";
const KAT_KID: &str = "rotation-key-2026-08";
const KAT_AD: &[u8] = b"user:alice|request:weather";
const KAT_NOW_MS: i64 = 1_700_000_000_000;
fn two_key_ring(active: &str) -> RequestStateCodec {
RequestStateCodec::new_with_keyring(active, [("a", KEY_A), ("b", KEY_B)]).unwrap()
}
fn body(expiry: i64, payload: &[u8]) -> Vec<u8> {
let mut body = expiry.to_be_bytes().to_vec();
body.extend_from_slice(payload);
body
}
fn replace_segment(token: &str, index: usize, replacement: &str) -> String {
let mut parts: Vec<String> = token.split('.').map(str::to_owned).collect();
parts[index] = replacement.to_owned();
parts.join(".")
}
fn test_mac(
key: &[u8],
domain: &[u8],
kid: Option<&[u8]>,
associated_data: &[u8],
body: &[u8],
) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts keys of any length");
mac.update(domain);
if let Some(kid) = kid {
mac.update(&(kid.len() as u64).to_be_bytes());
mac.update(kid);
}
mac.update(&(associated_data.len() as u64).to_be_bytes());
mac.update(associated_data);
mac.update(body);
mac.finalize().into_bytes().to_vec()
}
fn raw_rs1(body: &[u8], tag: &[u8]) -> String {
format!(
"rs1.{}.{}",
URL_SAFE_NO_PAD.encode(body),
URL_SAFE_NO_PAD.encode(tag)
)
}
fn raw_rs2(kid: &[u8], body: &[u8], tag: &[u8]) -> String {
format!(
"rs2.{}.{}.{}",
URL_SAFE_NO_PAD.encode(kid),
URL_SAFE_NO_PAD.encode(body),
URL_SAFE_NO_PAD.encode(tag)
)
}
#[test]
fn rs1_known_answer_is_unchanged() {
let codec = RequestStateCodec::try_new(KAT_KEY).unwrap();
let sealed = codec.seal_at(
b"step=2",
&SealOptions::new()
.associated_data(KAT_AD)
.ttl(Duration::from_secs(90)),
KAT_NOW_MS,
);
assert_eq!(
sealed,
"rs1.AAABi8_mx5BzdGVwPTI.GQgS0X7mtSz8ZOy_kld2Zjuc4gAMGpBL74EghWI36IQ"
);
}
#[test]
fn rs2_known_answer_matches_wire_specification() {
let codec = RequestStateCodec::new_with_keyring(KAT_KID, [(KAT_KID, KAT_KEY)]).unwrap();
let sealed = codec.seal_at(
b"step=2",
&SealOptions::new()
.associated_data(KAT_AD)
.ttl(Duration::from_secs(90)),
KAT_NOW_MS,
);
assert_eq!(
sealed,
"rs2.cm90YXRpb24ta2V5LTIwMjYtMDg.AAABi8_mx5BzdGVwPTI.twv2acu7lKXqebyrmit-JrHHZm-BkKlBQEMMtL3lHk8"
);
}
#[test]
fn rs2_roundtrips_with_associated_data_and_ttl() {
let codec = two_key_ring("a");
let options = SealOptions::new()
.associated_data(b"user:alice")
.ttl(Duration::from_secs(60));
let sealed = codec.seal_at(b"state", &options, 1_000);
assert!(sealed.starts_with("rs2.YQ."));
assert_eq!(
codec.open_at(&sealed, b"user:alice", 30_000).unwrap(),
b"state"
);
assert!(matches!(
codec.open_at(&sealed, b"user:bob", 30_000),
Err(RequestStateError::IntegrityCheckFailed)
));
assert!(matches!(
codec.open_at(&sealed, b"user:alice", 70_000),
Err(RequestStateError::Expired)
));
}
#[test]
fn rotation_keeps_previous_rs2_key_verifiable() {
let signer_a = two_key_ring("a");
let token_a = signer_a.seal(b"state-a");
let signer_b = two_key_ring("b");
assert_eq!(signer_b.open(&token_a).unwrap(), b"state-a");
let token_b = signer_b.seal(b"state-b");
assert!(token_b.starts_with("rs2.Yg."));
assert_eq!(signer_b.open(&token_b).unwrap(), b"state-b");
let without_a = RequestStateCodec::new_with_keyring("b", [("b", KEY_B)]).unwrap();
assert!(matches!(
without_a.open(&token_a),
Err(RequestStateError::UnknownKeyId)
));
}
#[test]
fn rolling_migration_is_bidirectionally_compatible() {
let old = RequestStateCodec::try_new(KEY_A).unwrap();
let transitional =
RequestStateCodec::new_with_keyring("new", [("old", KEY_A), ("new", KEY_B)])
.unwrap()
.with_rs1_signing("old")
.unwrap();
let promoted =
RequestStateCodec::new_with_keyring("new", [("old", KEY_A), ("new", KEY_B)])
.unwrap()
.with_rs1_fallback("old")
.unwrap();
let retired = RequestStateCodec::new_with_keyring("new", [("new", KEY_B)]).unwrap();
let old_token = old.seal(b"old");
assert_eq!(transitional.open(&old_token).unwrap(), b"old");
let transitional_token = transitional.seal(b"transition");
assert!(transitional_token.starts_with("rs1."));
assert_eq!(old.open(&transitional_token).unwrap(), b"transition");
assert_eq!(promoted.open(&transitional_token).unwrap(), b"transition");
let promoted_token = promoted.seal(b"promoted");
assert!(promoted_token.starts_with("rs2."));
assert_eq!(transitional.open(&promoted_token).unwrap(), b"promoted");
assert_eq!(retired.open(&promoted_token).unwrap(), b"promoted");
assert!(matches!(
retired.open(&old_token),
Err(RequestStateError::UnknownKeyId)
));
}
#[test]
fn multiple_legacy_fallbacks_are_supported() {
let legacy_a = RequestStateCodec::try_new(KEY_A).unwrap().seal(b"a");
let legacy_b = RequestStateCodec::try_new(KEY_B).unwrap().seal(b"b");
let legacy_c = RequestStateCodec::try_new(KEY_C).unwrap().seal(b"c");
let ring = RequestStateCodec::new_with_keyring(
"new",
[("a", KEY_A), ("b", KEY_B), ("new", KEY_C)],
)
.unwrap()
.with_rs1_fallback("a")
.unwrap()
.with_rs1_fallback("b")
.unwrap();
assert_eq!(ring.open(&legacy_a).unwrap(), b"a");
assert_eq!(ring.open(&legacy_b).unwrap(), b"b");
assert!(matches!(
ring.open(&legacy_c),
Err(RequestStateError::IntegrityCheckFailed)
));
}
#[test]
fn kid_is_authenticated_even_when_ids_share_key_bytes() {
let codec =
RequestStateCodec::new_with_keyring("a", [("a", KEY_A), ("b", KEY_A)]).unwrap();
let sealed = codec.seal(b"state");
let swapped = replace_segment(&sealed, 1, &URL_SAFE_NO_PAD.encode(b"b"));
assert!(matches!(
codec.open(&swapped),
Err(RequestStateError::IntegrityCheckFailed)
));
}
#[test]
fn independent_rs2_segment_tampering_is_rejected() {
let codec = two_key_ring("a");
let sealed = codec.seal(b"state");
let swapped_kid = replace_segment(&sealed, 1, &URL_SAFE_NO_PAD.encode(b"b"));
let tampered_body = replace_segment(&sealed, 2, &URL_SAFE_NO_PAD.encode(b"changed"));
let tampered_tag = replace_segment(&sealed, 3, &URL_SAFE_NO_PAD.encode([0_u8; 32]));
for tampered in [swapped_kid, tampered_body, tampered_tag] {
assert!(matches!(
codec.open(&tampered),
Err(RequestStateError::IntegrityCheckFailed)
));
}
}
#[test]
fn version_domains_are_cryptographically_separate() {
let token_body = body(0, b"state");
let wrong_v2_tag = test_mac(KEY_A, DOMAIN_V1, Some(b"a"), b"", &token_body);
let wrong_v2 = raw_rs2(b"a", &token_body, &wrong_v2_tag);
assert!(matches!(
two_key_ring("a").open(&wrong_v2),
Err(RequestStateError::IntegrityCheckFailed)
));
let wrong_v1_tag = test_mac(KEY_A, DOMAIN_V2, None, b"", &token_body);
let wrong_v1 = raw_rs1(&token_body, &wrong_v1_tag);
assert!(matches!(
RequestStateCodec::try_new(KEY_A).unwrap().open(&wrong_v1),
Err(RequestStateError::IntegrityCheckFailed)
));
}
#[test]
fn constructor_invariants_are_enforced() {
let empty =
RequestStateCodec::new_with_keyring("a", std::iter::empty::<(&str, &[u8])>());
assert!(matches!(empty, Err(RequestStateError::InvalidKeyring(_))));
let duplicate = RequestStateCodec::new_with_keyring("a", [("a", KEY_A), ("a", KEY_B)]);
assert!(matches!(
duplicate,
Err(RequestStateError::InvalidKeyring(_))
));
let empty_kid = RequestStateCodec::new_with_keyring("", [("", KEY_A)]);
assert!(matches!(
empty_kid,
Err(RequestStateError::InvalidKeyring(_))
));
let oversized = "x".repeat(MAX_KID_LEN + 1);
let oversized_kid =
RequestStateCodec::new_with_keyring(oversized.clone(), [(oversized, KEY_A)]);
assert!(matches!(
oversized_kid,
Err(RequestStateError::InvalidKeyring(_))
));
let absent_active = RequestStateCodec::new_with_keyring("missing", [("a", KEY_A)]);
assert!(matches!(
absent_active,
Err(RequestStateError::InvalidKeyring(_))
));
let short_key = RequestStateCodec::new_with_keyring(
"a",
[("a", vec![0; RequestStateCodec::MIN_KEY_LENGTH - 1])],
);
assert!(matches!(
short_key,
Err(RequestStateError::KeyTooShort { .. })
));
assert!(matches!(
RequestStateCodec::try_new(KEY_A)
.unwrap()
.with_rs1_signing("a"),
Err(RequestStateError::InvalidKeyring(_))
));
assert!(matches!(
two_key_ring("a").with_rs1_signing("missing"),
Err(RequestStateError::InvalidKeyring(_))
));
assert!(matches!(
RequestStateCodec::try_new(KEY_A)
.unwrap()
.with_rs1_fallback("a"),
Err(RequestStateError::InvalidKeyring(_))
));
assert!(matches!(
two_key_ring("a").with_rs1_fallback("missing"),
Err(RequestStateError::InvalidKeyring(_))
));
}
#[test]
fn maximum_length_kid_roundtrips_and_oversized_wire_kid_is_rejected() {
let max_kid = "x".repeat(MAX_KID_LEN);
let codec =
RequestStateCodec::new_with_keyring(max_kid.clone(), [(max_kid, KEY_A)]).unwrap();
let token = codec.seal(b"state");
assert_eq!(codec.open(&token).unwrap(), b"state");
let encoded_oversized = URL_SAFE_NO_PAD.encode("x".repeat(MAX_KID_LEN + 1));
assert!(encoded_oversized.len() > MAX_ENCODED_KID_LEN);
let oversized = format!("rs2.{encoded_oversized}.!!!!.!!!!");
assert!(matches!(
codec.open(&oversized),
Err(RequestStateError::InvalidKeyId)
));
let overlong_invalid_base64 =
format!("rs2.{}.!!!!.!!!!", "!".repeat(MAX_ENCODED_KID_LEN + 1));
assert!(matches!(
codec.open(&overlong_invalid_base64),
Err(RequestStateError::InvalidKeyId)
));
}
#[test]
fn parser_rejects_invalid_tokens_and_unavailable_keys() {
let codec = two_key_ring("a");
for malformed in [
"rs2",
"rs2.YQ",
"rs2.YQ.body",
"rs2.YQ.body.tag.extra",
"rs3.YQ.body.tag",
] {
assert!(matches!(
codec.open(malformed),
Err(RequestStateError::MalformedFormat)
));
}
let valid_rs2 = codec.seal(b"state");
assert!(matches!(
codec.open(&replace_segment(&valid_rs2, 1, "!!!!")),
Err(RequestStateError::InvalidEncoding)
));
assert!(matches!(
codec.open(&replace_segment(&valid_rs2, 1, "")),
Err(RequestStateError::InvalidKeyId)
));
let non_utf8 = URL_SAFE_NO_PAD.encode([0xff]);
assert!(matches!(
codec.open(&replace_segment(&valid_rs2, 1, &non_utf8)),
Err(RequestStateError::InvalidKeyId)
));
assert!(matches!(
RequestStateCodec::try_new(KEY_A).unwrap().open(&valid_rs2),
Err(RequestStateError::UnknownKeyId)
));
let valid_rs1 = RequestStateCodec::try_new(KEY_A).unwrap().seal(b"state");
assert!(matches!(
codec.open(&valid_rs1),
Err(RequestStateError::UnknownKeyId)
));
}
#[test]
fn authenticated_short_body_is_malformed() {
let short_body = b"short";
let tag = RequestStateCodec::mac_v2(KEY_A, b"a", b"", short_body)
.finalize()
.into_bytes();
let token = raw_rs2(b"a", short_body, tag.as_slice());
assert!(matches!(
two_key_ring("a").open(&token),
Err(RequestStateError::MalformedFormat)
));
}
#[test]
fn serde_is_reached_only_after_integrity_verification() {
let codec = two_key_ring("a");
let invalid_json = codec.seal(b"{not-json");
let opened: Result<serde_json::Value, _> = codec.open_json(&invalid_json);
assert!(matches!(opened, Err(RequestStateError::Deserialization(_))));
let valid_json = codec.seal(b"{}");
let tampered_body = body(0, b"{not-json");
let tampered = replace_segment(&valid_json, 2, &URL_SAFE_NO_PAD.encode(tampered_body));
let opened: Result<serde_json::Value, _> = codec.open_json(&tampered);
assert!(matches!(
opened,
Err(RequestStateError::IntegrityCheckFailed)
));
}
#[test]
fn ring_debug_redacts_all_key_material() {
let codec = two_key_ring("b").with_rs1_fallback("a").unwrap();
let rendered = format!("{codec:?}");
assert!(!rendered.contains(std::str::from_utf8(KEY_A).unwrap()));
assert!(!rendered.contains(std::str::from_utf8(KEY_B).unwrap()));
assert!(rendered.contains("redacted"));
}
#[test]
fn mutated_tokens_do_not_panic() {
let codec = two_key_ring("a").with_rs1_fallback("a").unwrap();
let valid = [
RequestStateCodec::try_new(KEY_A).unwrap().seal(b"state"),
codec.seal(b"state"),
];
let mut corpus = vec![
String::new(),
".".to_owned(),
"...".to_owned(),
"rs1".to_owned(),
"rs2".to_owned(),
"💥".to_owned(),
"rs2.💥...".to_owned(),
];
for token in valid {
for end in 0..=token.len() {
corpus.push(token[..end].to_owned());
}
for index in 0..token.len() {
let mut bytes = token.as_bytes().to_vec();
bytes[index] = b'!';
corpus.push(String::from_utf8(bytes).expect("token is ASCII"));
}
}
for candidate in corpus {
let _ = codec.open(&candidate);
let _ = codec.open_with(&candidate, b"context");
}
}
}
}