use std::collections::HashMap;
use std::sync::{Mutex, PoisonError};
use std::time::Duration;
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
use serde::{Deserialize, Serialize};
use tokio::time::Instant;
pub(crate) const LIFETIME: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct State {
pub(crate) args: String,
pub(crate) preview: String,
pub(crate) arm: Option<u32>,
nonce: String,
expires_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Unsealed {
Forged,
Expired,
Replayed,
}
impl Unsealed {
pub(crate) fn reason(self) -> &'static str {
match self {
Self::Forged => "confirmation-invalid",
Self::Expired => "confirmation-expired",
Self::Replayed => "confirmation-replayed",
}
}
}
pub(crate) struct Sealer {
cipher: XChaCha20Poly1305,
epoch: Instant,
used: Mutex<HashMap<String, u64>>,
}
impl Sealer {
pub(crate) fn new() -> Self {
let mut key = [0; 32];
getrandom::fill(&mut key).expect("the OS random source for the confirmation key");
let cipher = XChaCha20Poly1305::new_from_slice(&key).expect("a 32-octet key");
Self {
cipher,
epoch: Instant::now(),
used: Mutex::new(HashMap::new()),
}
}
fn now_ms(&self) -> u64 {
u64::try_from(self.epoch.elapsed().as_millis()).unwrap_or(u64::MAX)
}
fn aad(principal: &str, tool: &str) -> Vec<u8> {
let mut aad = b"bacnet-mcp confirmation v1\0".to_vec();
aad.extend_from_slice(principal.as_bytes());
aad.push(0);
aad.extend_from_slice(tool.as_bytes());
aad
}
pub(crate) fn seal(
&self,
principal: &str,
tool: &str,
args: String,
preview: String,
arm: Option<u32>,
) -> String {
let mut random = [0u8; 40];
getrandom::fill(&mut random).expect("the OS random source for a nonce");
let (aead_nonce, state_nonce) = random.split_at(24);
let state = State {
args,
preview,
arm,
nonce: hex(state_nonce),
expires_ms: self.now_ms().saturating_add(LIFETIME.as_millis() as u64),
};
let body = serde_json::to_vec(&state).expect("a state serializes");
let nonce = XNonce::try_from(aead_nonce).expect("24 octets");
let sealed = self
.cipher
.encrypt(
&nonce,
Payload {
msg: &body,
aad: &Self::aad(principal, tool),
},
)
.expect("sealing never fails for a short body");
format!("{}.{}", hex(aead_nonce), hex(&sealed))
}
pub(crate) fn open(&self, token: &str, principal: &str, tool: &str) -> Result<State, Unsealed> {
let (nonce, sealed) = token.split_once('.').ok_or(Unsealed::Forged)?;
let nonce = unhex(nonce).ok_or(Unsealed::Forged)?;
let sealed = unhex(sealed).ok_or(Unsealed::Forged)?;
let nonce = XNonce::try_from(nonce.as_slice()).map_err(|_| Unsealed::Forged)?;
let body = self
.cipher
.decrypt(
&nonce,
Payload {
msg: &sealed,
aad: &Self::aad(principal, tool),
},
)
.map_err(|_| Unsealed::Forged)?;
let state: State = serde_json::from_slice(&body).map_err(|_| Unsealed::Forged)?;
let now = self.now_ms();
let mut used = self.used.lock().unwrap_or_else(PoisonError::into_inner);
used.retain(|_, expires| *expires >= now);
if used.contains_key(&state.nonce) {
return Err(Unsealed::Replayed);
}
used.insert(state.nonce.clone(), state.expires_ms);
if state.expires_ms < now {
return Err(Unsealed::Expired);
}
Ok(state)
}
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn unhex(text: &str) -> Option<Vec<u8>> {
if !text.len().is_multiple_of(2) || !text.is_ascii() {
return None;
}
(0..text.len())
.step_by(2)
.map(|i| u8::from_str_radix(&text[i..i + 2], 16).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn sealed(sealer: &Sealer) -> String {
sealer.seal("alice", "bacnet_write", "a".into(), "p".into(), None)
}
#[tokio::test(start_paused = true)]
async fn a_state_opens_once_for_its_caller_until_it_expires() {
let sealer = Sealer::new();
let token = sealed(&sealer);
let state = sealer.open(&token, "alice", "bacnet_write").unwrap();
assert_eq!((state.args.as_str(), state.preview.as_str()), ("a", "p"));
assert_eq!(
sealer.open(&token, "alice", "bacnet_write"),
Err(Unsealed::Replayed)
);
let token = sealed(&sealer);
assert_eq!(
sealer.open(&token, "mallory", "bacnet_write"),
Err(Unsealed::Forged)
);
assert_eq!(
sealer.open(&token, "alice", "bacnet_revert"),
Err(Unsealed::Forged)
);
assert_eq!(
Sealer::new().open(&token, "alice", "bacnet_write"),
Err(Unsealed::Forged)
);
let mut tampered = sealed(&sealer).into_bytes();
let last = tampered.len() - 1;
tampered[last] = if tampered[last] == b'0' { b'1' } else { b'0' };
let tampered = String::from_utf8(tampered).unwrap();
assert_eq!(
sealer.open(&tampered, "alice", "bacnet_write"),
Err(Unsealed::Forged)
);
let token = sealed(&sealer);
tokio::time::advance(LIFETIME + Duration::from_millis(1)).await;
assert_eq!(
sealer.open(&token, "alice", "bacnet_write"),
Err(Unsealed::Expired)
);
}
#[test]
fn the_body_is_not_readable() {
let sealer = Sealer::new();
let token = sealer.seal(
"alice",
"bacnet_write",
"args-digest".into(),
"p".into(),
None,
);
let (_, body) = token.split_once('.').unwrap();
let text = String::from_utf8_lossy(&unhex(body).unwrap()).to_string();
assert!(!text.contains("args-digest"), "{text}");
}
}