use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::Serialize;
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use crate::protocol::InputResponses;
const TOKEN_VERSION: &str = "v1";
const SHA256_BLOCK_SIZE: usize = 64;
const DEFAULT_MAX_TOKEN_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, Default)]
pub struct MrtrRequest {
input_responses: Option<InputResponses>,
request_state: Option<String>,
}
impl MrtrRequest {
pub(crate) fn new(
input_responses: Option<InputResponses>,
request_state: Option<String>,
) -> Self {
Self {
input_responses,
request_state,
}
}
pub fn input_responses(&self) -> Option<&InputResponses> {
self.input_responses.as_ref()
}
pub fn request_state(&self) -> Option<&str> {
self.request_state.as_deref()
}
pub fn into_parts(self) -> (Option<InputResponses>, Option<String>) {
(self.input_responses, self.request_state)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum RequestStateError {
#[error("request-state key must be at least 32 bytes")]
WeakKey,
#[error("request-state TTL must be greater than zero")]
ZeroTtl,
#[error("request-state token exceeds the configured maximum of {0} bytes")]
TooLarge(usize),
#[error("request-state token is malformed")]
Malformed,
#[error("unsupported request-state token version")]
UnsupportedVersion,
#[error("request-state integrity verification failed")]
Integrity,
#[error("request-state token has expired")]
Expired,
#[error("request-state token is not bound to the current subject")]
SubjectMismatch,
#[error("failed to serialize request state: {0}")]
Encode(#[source] serde_json::Error),
#[error("failed to decode request state: {0}")]
Decode(#[source] serde_json::Error),
#[error("system clock is earlier than the Unix epoch")]
Clock,
}
#[derive(Debug, Serialize, serde::Deserialize)]
struct StateEnvelope<T> {
issued_at: u64,
expires_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
subject: Option<String>,
state: T,
}
#[derive(Clone)]
pub struct RequestStateCodec {
key: std::sync::Arc<[u8]>,
ttl: Duration,
max_token_bytes: usize,
}
impl std::fmt::Debug for RequestStateCodec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RequestStateCodec")
.field("key", &"<redacted>")
.field("ttl", &self.ttl)
.field("max_token_bytes", &self.max_token_bytes)
.finish()
}
}
impl RequestStateCodec {
pub fn new(key: impl AsRef<[u8]>, ttl: Duration) -> Result<Self, RequestStateError> {
let key = key.as_ref();
if key.len() < 32 {
return Err(RequestStateError::WeakKey);
}
if ttl.is_zero() {
return Err(RequestStateError::ZeroTtl);
}
Ok(Self {
key: std::sync::Arc::from(key),
ttl,
max_token_bytes: DEFAULT_MAX_TOKEN_BYTES,
})
}
pub fn with_max_token_bytes(mut self, max_token_bytes: usize) -> Self {
self.max_token_bytes = max_token_bytes;
self
}
pub fn encode<T: Serialize>(&self, state: &T) -> Result<String, RequestStateError> {
self.encode_at(None, state, unix_seconds()?)
}
pub fn encode_for<T: Serialize>(
&self,
subject: impl Into<String>,
state: &T,
) -> Result<String, RequestStateError> {
self.encode_at(Some(subject.into()), state, unix_seconds()?)
}
pub fn decode<T: DeserializeOwned>(&self, token: &str) -> Result<T, RequestStateError> {
self.decode_at(token, None, unix_seconds()?)
}
pub fn decode_for<T: DeserializeOwned>(
&self,
token: &str,
subject: &str,
) -> Result<T, RequestStateError> {
self.decode_at(token, Some(subject), unix_seconds()?)
}
fn encode_at<T: Serialize>(
&self,
subject: Option<String>,
state: &T,
now: u64,
) -> Result<String, RequestStateError> {
let ttl = self.ttl.as_secs();
let envelope = StateEnvelope {
issued_at: now,
expires_at: now.saturating_add(ttl),
subject,
state,
};
let payload = serde_json::to_vec(&envelope).map_err(RequestStateError::Encode)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signed = format!("{TOKEN_VERSION}.{payload}");
let signature = URL_SAFE_NO_PAD.encode(hmac_sha256(&self.key, signed.as_bytes()));
let token = format!("{signed}.{signature}");
if token.len() > self.max_token_bytes {
return Err(RequestStateError::TooLarge(self.max_token_bytes));
}
Ok(token)
}
fn decode_at<T: DeserializeOwned>(
&self,
token: &str,
subject: Option<&str>,
now: u64,
) -> Result<T, RequestStateError> {
if token.len() > self.max_token_bytes {
return Err(RequestStateError::TooLarge(self.max_token_bytes));
}
let mut parts = token.split('.');
let version = parts.next().ok_or(RequestStateError::Malformed)?;
let payload = parts.next().ok_or(RequestStateError::Malformed)?;
let signature = parts.next().ok_or(RequestStateError::Malformed)?;
if parts.next().is_some() {
return Err(RequestStateError::Malformed);
}
if version != TOKEN_VERSION {
return Err(RequestStateError::UnsupportedVersion);
}
let supplied_signature = URL_SAFE_NO_PAD
.decode(signature)
.map_err(|_| RequestStateError::Malformed)?;
let signed = format!("{version}.{payload}");
let expected_signature = hmac_sha256(&self.key, signed.as_bytes());
if !constant_time_eq(&supplied_signature, &expected_signature) {
return Err(RequestStateError::Integrity);
}
let payload = URL_SAFE_NO_PAD
.decode(payload)
.map_err(|_| RequestStateError::Malformed)?;
let envelope: StateEnvelope<T> =
serde_json::from_slice(&payload).map_err(RequestStateError::Decode)?;
if now > envelope.expires_at {
return Err(RequestStateError::Expired);
}
match (envelope.subject.as_deref(), subject) {
(None, None) => {}
(Some(expected), Some(actual)) if expected == actual => {}
_ => return Err(RequestStateError::SubjectMismatch),
}
Ok(envelope.state)
}
}
fn unix_seconds() -> Result<u64, RequestStateError> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|_| RequestStateError::Clock)
}
fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
let mut normalized = [0u8; SHA256_BLOCK_SIZE];
if key.len() > SHA256_BLOCK_SIZE {
normalized[..32].copy_from_slice(&Sha256::digest(key));
} else {
normalized[..key.len()].copy_from_slice(key);
}
let mut inner_pad = [0x36u8; SHA256_BLOCK_SIZE];
let mut outer_pad = [0x5cu8; SHA256_BLOCK_SIZE];
for ((inner, outer), key_byte) in inner_pad
.iter_mut()
.zip(outer_pad.iter_mut())
.zip(normalized)
{
*inner ^= key_byte;
*outer ^= key_byte;
}
let mut inner = Sha256::new();
inner.update(inner_pad);
inner.update(message);
let inner = inner.finalize();
let mut outer = Sha256::new();
outer.update(outer_pad);
outer.update(inner);
outer.finalize().into()
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
left.iter()
.zip(right)
.fold(0u8, |difference, (left, right)| difference | (left ^ right))
== 0
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &[u8; 32] = b"0123456789abcdef0123456789abcdef";
#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
struct State {
round: u8,
value: String,
}
#[test]
fn round_trips_shared_state() {
let first = RequestStateCodec::new(KEY, Duration::from_secs(60)).unwrap();
let second = RequestStateCodec::new(KEY, Duration::from_secs(60)).unwrap();
let state = State {
round: 2,
value: "kept".into(),
};
let token = first.encode_at(None, &state, 100).unwrap();
assert_eq!(second.decode_at::<State>(&token, None, 120).unwrap(), state);
}
#[test]
fn rejects_tampering_expiry_and_wrong_subject() {
let codec = RequestStateCodec::new(KEY, Duration::from_secs(10)).unwrap();
let token = codec
.encode_at(
Some("alice".into()),
&State {
round: 1,
value: "x".into(),
},
100,
)
.unwrap();
assert!(matches!(
codec.decode_at::<State>(&format!("{token}x"), Some("alice"), 101),
Err(RequestStateError::Integrity | RequestStateError::Malformed)
));
assert!(matches!(
codec.decode_at::<State>(&token, Some("bob"), 101),
Err(RequestStateError::SubjectMismatch)
));
assert!(matches!(
codec.decode_at::<State>(&token, Some("alice"), 111),
Err(RequestStateError::Expired)
));
}
#[test]
fn enforces_key_ttl_and_size_limits() {
assert!(matches!(
RequestStateCodec::new(b"short", Duration::from_secs(1)),
Err(RequestStateError::WeakKey)
));
assert!(matches!(
RequestStateCodec::new(KEY, Duration::ZERO),
Err(RequestStateError::ZeroTtl)
));
let codec = RequestStateCodec::new(KEY, Duration::from_secs(1))
.unwrap()
.with_max_token_bytes(8);
assert!(matches!(
codec.encode(&"too large"),
Err(RequestStateError::TooLarge(8))
));
}
}