1use std::fmt;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use hmac::{Hmac, KeyInit, Mac};
29use sha2::Sha256;
30
31type HmacSha256 = Hmac<Sha256>;
32
33pub const DEFAULT_TICKET_TTL_SECS: u64 = 600;
35
36#[derive(Debug, PartialEq, Eq)]
40pub enum TicketError {
41 Malformed,
43 Expired,
45 InvalidSignature,
47}
48
49impl fmt::Display for TicketError {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match self {
52 TicketError::Malformed => f.write_str("read ticket is malformed"),
53 TicketError::Expired => f.write_str("read ticket has expired"),
54 TicketError::InvalidSignature => f.write_str("read ticket has an invalid signature"),
55 }
56 }
57}
58
59impl std::error::Error for TicketError {}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct TicketClaims {
64 content_sha256: Option<String>,
65}
66
67impl TicketClaims {
68 pub fn content_sha256(&self) -> Option<&str> {
70 self.content_sha256.as_deref()
71 }
72}
73
74pub struct TicketSigner {
80 key: [u8; 32],
81}
82
83impl TicketSigner {
84 pub fn new() -> Self {
90 let mut key = [0u8; 32];
91 getrandom::fill(&mut key).unwrap_or_else(|e| panic!("OS CSPRNG unavailable: {e}"));
92 Self { key }
93 }
94
95 pub fn issue(&self, path: &str, content_sha256: &str, ttl_secs: u64) -> String {
99 let exp = now_epoch_secs().saturating_add(ttl_secs);
100 let mac_hex = compute_hmac_hex_v2(&self.key, path, content_sha256, exp);
101 format!("rt2.{exp}.{content_sha256}.{mac_hex}")
102 }
103
104 pub fn verify(&self, ticket: &str, expected_path: &str) -> Result<TicketClaims, TicketError> {
108 let parts: Vec<&str> = ticket.split('.').collect();
109 match parts.as_slice() {
110 ["rt1", exp_raw, signature_hex] => {
111 let exp = parse_unexpired_expiry(exp_raw)?;
112 verify_hmac_hex(
113 &self.key,
114 hmac_message_v1(expected_path, exp),
115 signature_hex,
116 )?;
117 Ok(TicketClaims {
118 content_sha256: None,
119 })
120 }
121 ["rt2", exp_raw, content_sha256, signature_hex] => {
122 let exp = parse_unexpired_expiry(exp_raw)?;
123 if content_sha256.len() != 64 || !hex::is_valid_hex(content_sha256) {
124 return Err(TicketError::Malformed);
125 }
126 verify_hmac_hex(
127 &self.key,
128 hmac_message_v2(expected_path, content_sha256, exp),
129 signature_hex,
130 )?;
131 Ok(TicketClaims {
132 content_sha256: Some((*content_sha256).to_string()),
133 })
134 }
135 _ => Err(TicketError::Malformed),
136 }
137 }
138}
139
140impl Default for TicketSigner {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146fn now_epoch_secs() -> u64 {
150 SystemTime::now()
151 .duration_since(UNIX_EPOCH)
152 .map(|d| d.as_secs())
153 .unwrap_or(0)
154}
155
156fn parse_unexpired_expiry(exp_raw: &str) -> Result<u64, TicketError> {
157 let exp: u64 = exp_raw.parse().map_err(|_| TicketError::Malformed)?;
158 if exp <= now_epoch_secs() {
159 return Err(TicketError::Expired);
160 }
161 Ok(exp)
162}
163
164fn hmac_message_v1(path: &str, exp: u64) -> String {
166 format!("rt1\0{path}\0{exp}")
167}
168
169fn hmac_message_v2(path: &str, content_sha256: &str, exp: u64) -> String {
171 format!("rt2\0{path}\0{content_sha256}\0{exp}")
172}
173
174fn compute_hmac_hex(message: &str, key: &[u8; 32]) -> String {
176 let mut mac = HmacSha256::new_from_slice(key)
179 .unwrap_or_else(|_| unreachable!("HMAC-SHA256 accepts any key length"));
180 mac.update(message.as_bytes());
181 let result = mac.finalize();
182 let bytes = result.into_bytes();
183 bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
184 use std::fmt::Write as _;
185 let _ = write!(acc, "{b:02x}");
186 acc
187 })
188}
189
190fn compute_hmac_hex_v2(key: &[u8; 32], path: &str, content_sha256: &str, exp: u64) -> String {
191 compute_hmac_hex(&hmac_message_v2(path, content_sha256, exp), key)
192}
193
194fn verify_hmac_hex(
195 key: &[u8; 32],
196 message: String,
197 signature_hex: &str,
198) -> Result<(), TicketError> {
199 if signature_hex.len() != 64 {
200 return Err(TicketError::Malformed);
201 }
202 let expected_bytes =
203 hex::decode_hmac_input(signature_hex).ok_or(TicketError::InvalidSignature)?;
204 let mut mac = HmacSha256::new_from_slice(key).map_err(|_| TicketError::InvalidSignature)?;
205 mac.update(message.as_bytes());
206 mac.verify_slice(&expected_bytes)
207 .map_err(|_| TicketError::InvalidSignature)
208}
209
210mod hex {
212 pub fn decode_hmac_input(s: &str) -> Option<[u8; 32]> {
215 debug_assert_eq!(s.len(), 64, "caller must pre-check length");
216 let mut out = [0u8; 32];
217 for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
218 let hi = nibble(chunk[0])?;
219 let lo = nibble(chunk[1])?;
220 out[i] = (hi << 4) | lo;
221 }
222 Some(out)
223 }
224
225 fn nibble(b: u8) -> Option<u8> {
226 match b {
227 b'0'..=b'9' => Some(b - b'0'),
228 b'a'..=b'f' => Some(b - b'a' + 10),
229 b'A'..=b'F' => Some(b - b'A' + 10),
230 _ => None,
231 }
232 }
233
234 pub fn is_valid_hex(s: &str) -> bool {
235 s.as_bytes().iter().all(|b| nibble(*b).is_some())
236 }
237}
238
239#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
248 fn test_issue_verify_roundtrip() {
249 let signer = TicketSigner::new();
250 let path = "/etc/ssh/sshd_config";
251 let sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
252 let ticket = signer.issue(path, sha, DEFAULT_TICKET_TTL_SECS);
253 assert!(
254 ticket.starts_with("rt2."),
255 "ticket must start with version prefix"
256 );
257 assert_eq!(
258 ticket.split('.').count(),
259 4,
260 "ticket must have 4 dot-separated parts"
261 );
262 let claims = signer
263 .verify(&ticket, path)
264 .expect("roundtrip must succeed");
265 assert_eq!(claims.content_sha256(), Some(sha));
266 }
267
268 #[test]
269 fn test_wrong_path_fails() {
270 let signer = TicketSigner::new();
271 let ticket = signer.issue(
272 "/etc/passwd",
273 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
274 DEFAULT_TICKET_TTL_SECS,
275 );
276 let err = signer.verify(&ticket, "/etc/shadow").unwrap_err();
277 assert_eq!(err, TicketError::InvalidSignature);
278 }
279
280 #[test]
281 fn test_expired_ticket() {
282 let signer = TicketSigner::new();
283 let ticket = signer.issue(
285 "/tmp/file",
286 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
287 0,
288 );
289 std::thread::sleep(std::time::Duration::from_millis(10));
291 let err = signer.verify(&ticket, "/tmp/file").unwrap_err();
292 assert_eq!(err, TicketError::Expired);
293 }
294
295 #[test]
296 fn test_malformed_ticket() {
297 let signer = TicketSigner::new();
298 let future_exp = now_epoch_secs().saturating_add(DEFAULT_TICKET_TTL_SECS);
299 let bad_tickets = [
300 String::new(),
301 "rt1".to_string(),
302 "rt1.abc".to_string(),
303 "rt2.12345.aabb".to_string(),
304 format!("rt2.{future_exp}.short.bad"),
305 "notrt1.12345.aabb".to_string(),
306 ];
307 for bad in &bad_tickets {
308 let err = signer.verify(bad, "/any/path").unwrap_err();
309 assert!(
310 matches!(err, TicketError::Malformed | TicketError::InvalidSignature),
311 "expected Malformed or InvalidSignature for {bad:?}, got {err:?}"
312 );
313 }
314 }
315
316 #[test]
317 fn test_tampered_signature() {
318 let signer = TicketSigner::new();
319 let path = "/home/user/.ssh/authorized_keys";
320 let ticket = signer.issue(
321 path,
322 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
323 DEFAULT_TICKET_TTL_SECS,
324 );
325
326 let mut parts: Vec<&str> = ticket.split('.').collect();
328 let sig = parts[3];
329 let mut sig_bytes = sig.as_bytes().to_vec();
330 let last = sig_bytes.len() - 1;
331 sig_bytes[last] = if sig_bytes[last] == b'0' { b'1' } else { b'0' };
332 let bad_sig = String::from_utf8(sig_bytes).expect("ascii only");
333 parts[3] = Box::leak(bad_sig.into_boxed_str());
334 let tampered = parts.join(".");
335
336 let err = signer.verify(&tampered, path).unwrap_err();
337 assert_eq!(err, TicketError::InvalidSignature);
338 }
339
340 #[test]
341 fn test_legacy_rt1_verify_still_works_without_hash_claim() {
342 let signer = TicketSigner::new();
343 let path = "/tmp/legacy.txt";
344 let exp = now_epoch_secs().saturating_add(DEFAULT_TICKET_TTL_SECS);
345 let mac_hex = compute_hmac_hex(&hmac_message_v1(path, exp), &signer.key);
346 let ticket = format!("rt1.{exp}.{mac_hex}");
347
348 let claims = signer
349 .verify(&ticket, path)
350 .expect("legacy rt1 must verify");
351 assert_eq!(claims.content_sha256(), None);
352 }
353
354 #[test]
355 fn test_display_messages() {
356 assert_eq!(
357 TicketError::Malformed.to_string(),
358 "read ticket is malformed"
359 );
360 assert_eq!(TicketError::Expired.to_string(), "read ticket has expired");
361 assert_eq!(
362 TicketError::InvalidSignature.to_string(),
363 "read ticket has an invalid signature"
364 );
365 }
366
367 #[test]
368 fn test_default_ttl_constant() {
369 assert_eq!(DEFAULT_TICKET_TTL_SECS, 600);
370 }
371}