use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub const DEFAULT_TICKET_TTL_SECS: u64 = 600;
#[derive(Debug, PartialEq, Eq)]
pub enum TicketError {
Malformed,
Expired,
InvalidSignature,
}
impl fmt::Display for TicketError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TicketError::Malformed => f.write_str("read ticket is malformed"),
TicketError::Expired => f.write_str("read ticket has expired"),
TicketError::InvalidSignature => f.write_str("read ticket has an invalid signature"),
}
}
}
impl std::error::Error for TicketError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketClaims {
content_sha256: Option<String>,
}
impl TicketClaims {
pub fn content_sha256(&self) -> Option<&str> {
self.content_sha256.as_deref()
}
}
pub struct TicketSigner {
key: [u8; 32],
}
impl TicketSigner {
pub fn new() -> Self {
let mut key = [0u8; 32];
getrandom::fill(&mut key).unwrap_or_else(|e| panic!("OS CSPRNG unavailable: {e}"));
Self { key }
}
pub fn issue(&self, path: &str, content_sha256: &str, ttl_secs: u64) -> String {
let exp = now_epoch_secs().saturating_add(ttl_secs);
let mac_hex = compute_hmac_hex_v2(&self.key, path, content_sha256, exp);
format!("rt2.{exp}.{content_sha256}.{mac_hex}")
}
pub fn verify(&self, ticket: &str, expected_path: &str) -> Result<TicketClaims, TicketError> {
let parts: Vec<&str> = ticket.split('.').collect();
match parts.as_slice() {
["rt1", exp_raw, signature_hex] => {
let exp = parse_unexpired_expiry(exp_raw)?;
verify_hmac_hex(
&self.key,
hmac_message_v1(expected_path, exp),
signature_hex,
)?;
Ok(TicketClaims {
content_sha256: None,
})
}
["rt2", exp_raw, content_sha256, signature_hex] => {
let exp = parse_unexpired_expiry(exp_raw)?;
if content_sha256.len() != 64 || !hex::is_valid_hex(content_sha256) {
return Err(TicketError::Malformed);
}
verify_hmac_hex(
&self.key,
hmac_message_v2(expected_path, content_sha256, exp),
signature_hex,
)?;
Ok(TicketClaims {
content_sha256: Some((*content_sha256).to_string()),
})
}
_ => Err(TicketError::Malformed),
}
}
}
impl Default for TicketSigner {
fn default() -> Self {
Self::new()
}
}
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn parse_unexpired_expiry(exp_raw: &str) -> Result<u64, TicketError> {
let exp: u64 = exp_raw.parse().map_err(|_| TicketError::Malformed)?;
if exp <= now_epoch_secs() {
return Err(TicketError::Expired);
}
Ok(exp)
}
fn hmac_message_v1(path: &str, exp: u64) -> String {
format!("rt1\0{path}\0{exp}")
}
fn hmac_message_v2(path: &str, content_sha256: &str, exp: u64) -> String {
format!("rt2\0{path}\0{content_sha256}\0{exp}")
}
fn compute_hmac_hex(message: &str, key: &[u8; 32]) -> String {
let mut mac = HmacSha256::new_from_slice(key)
.unwrap_or_else(|_| unreachable!("HMAC-SHA256 accepts any key length"));
mac.update(message.as_bytes());
let result = mac.finalize();
let bytes = result.into_bytes();
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
use std::fmt::Write as _;
let _ = write!(acc, "{b:02x}");
acc
})
}
fn compute_hmac_hex_v2(key: &[u8; 32], path: &str, content_sha256: &str, exp: u64) -> String {
compute_hmac_hex(&hmac_message_v2(path, content_sha256, exp), key)
}
fn verify_hmac_hex(
key: &[u8; 32],
message: String,
signature_hex: &str,
) -> Result<(), TicketError> {
if signature_hex.len() != 64 {
return Err(TicketError::Malformed);
}
let expected_bytes =
hex::decode_hmac_input(signature_hex).ok_or(TicketError::InvalidSignature)?;
let mut mac = HmacSha256::new_from_slice(key).map_err(|_| TicketError::InvalidSignature)?;
mac.update(message.as_bytes());
mac.verify_slice(&expected_bytes)
.map_err(|_| TicketError::InvalidSignature)
}
mod hex {
pub fn decode_hmac_input(s: &str) -> Option<[u8; 32]> {
debug_assert_eq!(s.len(), 64, "caller must pre-check length");
let mut out = [0u8; 32];
for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
let hi = nibble(chunk[0])?;
let lo = nibble(chunk[1])?;
out[i] = (hi << 4) | lo;
}
Some(out)
}
fn nibble(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
pub fn is_valid_hex(s: &str) -> bool {
s.as_bytes().iter().all(|b| nibble(*b).is_some())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_issue_verify_roundtrip() {
let signer = TicketSigner::new();
let path = "/etc/ssh/sshd_config";
let sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let ticket = signer.issue(path, sha, DEFAULT_TICKET_TTL_SECS);
assert!(
ticket.starts_with("rt2."),
"ticket must start with version prefix"
);
assert_eq!(
ticket.split('.').count(),
4,
"ticket must have 4 dot-separated parts"
);
let claims = signer
.verify(&ticket, path)
.expect("roundtrip must succeed");
assert_eq!(claims.content_sha256(), Some(sha));
}
#[test]
fn test_wrong_path_fails() {
let signer = TicketSigner::new();
let ticket = signer.issue(
"/etc/passwd",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
DEFAULT_TICKET_TTL_SECS,
);
let err = signer.verify(&ticket, "/etc/shadow").unwrap_err();
assert_eq!(err, TicketError::InvalidSignature);
}
#[test]
fn test_expired_ticket() {
let signer = TicketSigner::new();
let ticket = signer.issue(
"/tmp/file",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
0,
);
std::thread::sleep(std::time::Duration::from_millis(10));
let err = signer.verify(&ticket, "/tmp/file").unwrap_err();
assert_eq!(err, TicketError::Expired);
}
#[test]
fn test_malformed_ticket() {
let signer = TicketSigner::new();
let future_exp = now_epoch_secs().saturating_add(DEFAULT_TICKET_TTL_SECS);
let bad_tickets = [
String::new(),
"rt1".to_string(),
"rt1.abc".to_string(),
"rt2.12345.aabb".to_string(),
format!("rt2.{future_exp}.short.bad"),
"notrt1.12345.aabb".to_string(),
];
for bad in &bad_tickets {
let err = signer.verify(bad, "/any/path").unwrap_err();
assert!(
matches!(err, TicketError::Malformed | TicketError::InvalidSignature),
"expected Malformed or InvalidSignature for {bad:?}, got {err:?}"
);
}
}
#[test]
fn test_tampered_signature() {
let signer = TicketSigner::new();
let path = "/home/user/.ssh/authorized_keys";
let ticket = signer.issue(
path,
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
DEFAULT_TICKET_TTL_SECS,
);
let mut parts: Vec<&str> = ticket.split('.').collect();
let sig = parts[3];
let mut sig_bytes = sig.as_bytes().to_vec();
let last = sig_bytes.len() - 1;
sig_bytes[last] = if sig_bytes[last] == b'0' { b'1' } else { b'0' };
let bad_sig = String::from_utf8(sig_bytes).expect("ascii only");
parts[3] = Box::leak(bad_sig.into_boxed_str());
let tampered = parts.join(".");
let err = signer.verify(&tampered, path).unwrap_err();
assert_eq!(err, TicketError::InvalidSignature);
}
#[test]
fn test_legacy_rt1_verify_still_works_without_hash_claim() {
let signer = TicketSigner::new();
let path = "/tmp/legacy.txt";
let exp = now_epoch_secs().saturating_add(DEFAULT_TICKET_TTL_SECS);
let mac_hex = compute_hmac_hex(&hmac_message_v1(path, exp), &signer.key);
let ticket = format!("rt1.{exp}.{mac_hex}");
let claims = signer
.verify(&ticket, path)
.expect("legacy rt1 must verify");
assert_eq!(claims.content_sha256(), None);
}
#[test]
fn test_display_messages() {
assert_eq!(
TicketError::Malformed.to_string(),
"read ticket is malformed"
);
assert_eq!(TicketError::Expired.to_string(), "read ticket has expired");
assert_eq!(
TicketError::InvalidSignature.to_string(),
"read ticket has an invalid signature"
);
}
#[test]
fn test_default_ttl_constant() {
assert_eq!(DEFAULT_TICKET_TTL_SECS, 600);
}
}