Skip to main content

ssh_mcp/
ticket.rs

1//! Cryptographic read-ticket module.
2//!
3//! A `TicketSigner` issues short-lived HMAC-SHA256 tickets that prove a file
4//! path was read at a known point in time.  The server creates one signer at
5//! startup (ephemeral key); the ticket is opaque to the client.
6//!
7//! # Ticket format
8//!
9//! ```text
10//! rt1.{expiry_epoch_secs}.{hmac_hex}
11//! rt2.{expiry_epoch_secs}.{content_sha256}.{hmac_hex}
12//! ```
13//!
14//! # HMAC input
15//!
16//! ```text
17//! "rt1\0{path}\0{expiry_epoch_secs}"
18//! "rt2\0{path}\0{content_sha256}\0{expiry_epoch_secs}"
19//! ```
20//!
21//! `rt2` is the current format. It binds both path and content SHA-256, so a
22//! write can safely derive an implicit optimistic-lock baseline from the read.
23//! `rt1` remains accepted for backward compatibility within a single process.
24
25use std::fmt;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use hmac::{Hmac, KeyInit, Mac};
29use sha2::Sha256;
30
31type HmacSha256 = Hmac<Sha256>;
32
33/// Default ticket lifetime: 10 minutes.
34pub const DEFAULT_TICKET_TTL_SECS: u64 = 600;
35
36// ── Error type ───────────────────────────────────────────────────────────────
37
38/// Errors returned when verifying a read-ticket.
39#[derive(Debug, PartialEq, Eq)]
40pub enum TicketError {
41    /// The ticket string is syntactically invalid.
42    Malformed,
43    /// The ticket's expiry timestamp is in the past.
44    Expired,
45    /// The HMAC did not match (wrong path, tampered data, or wrong key).
46    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/// Verified read-ticket claims.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct TicketClaims {
64    content_sha256: Option<String>,
65}
66
67impl TicketClaims {
68    /// Returns the content SHA-256 bound into the ticket, when present.
69    pub fn content_sha256(&self) -> Option<&str> {
70        self.content_sha256.as_deref()
71    }
72}
73
74// ── Signer ───────────────────────────────────────────────────────────────────
75
76/// Signs and verifies read-tickets using an ephemeral 256-bit HMAC key.
77///
78/// Create one instance per process lifetime via [`TicketSigner::new`].
79pub struct TicketSigner {
80    key: [u8; 32],
81}
82
83impl TicketSigner {
84    /// Creates a new signer with a cryptographically random key.
85    ///
86    /// # Panics
87    ///
88    /// Panics if the OS CSPRNG is unavailable — an unrecoverable situation.
89    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    /// Issues a ticket for `path` + `content_sha256` that expires after `ttl_secs` seconds.
96    ///
97    /// Returns the opaque ticket string `"rt2.{exp}.{content_sha256}.{hmac_hex}"`.
98    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    /// Verifies that `ticket` was issued for `expected_path` and has not expired.
105    ///
106    /// Returns parsed claims on success, or a [`TicketError`] describing the failure.
107    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
146// ── Helpers ──────────────────────────────────────────────────────────────────
147
148/// Returns seconds since UNIX epoch; saturates to 0 on clock anomalies.
149fn 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
164/// Builds the HMAC message `"rt1\0{path}\0{exp}"`.
165fn hmac_message_v1(path: &str, exp: u64) -> String {
166    format!("rt1\0{path}\0{exp}")
167}
168
169/// Builds the HMAC message `"rt2\0{path}\0{content_sha256}\0{exp}"`.
170fn hmac_message_v2(path: &str, content_sha256: &str, exp: u64) -> String {
171    format!("rt2\0{path}\0{content_sha256}\0{exp}")
172}
173
174/// Computes `HMAC-SHA256(key, message)` and returns the lowercase hex digest.
175fn compute_hmac_hex(message: &str, key: &[u8; 32]) -> String {
176    // SAFETY: HMAC-SHA256 accepts any key length; a 32-byte key is always valid.
177    // The `Err` branch is structurally unreachable.
178    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
210/// Inline hex decoder — avoids pulling in a `hex` crate just for this.
211mod hex {
212    /// Decodes a 64-char lowercase or uppercase hex string into a fixed 32-byte array.
213    /// Returns `None` on any invalid input (wrong length is already checked by caller).
214    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// ── Tests ────────────────────────────────────────────────────────────────────
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    // Allow expect/unwrap in tests — failures should panic visibly.
246
247    #[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        // TTL=0 produces exp == now, which is immediately ≤ now on next check.
284        let ticket = signer.issue(
285            "/tmp/file",
286            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
287            0,
288        );
289        // Give the clock at least a moment to tick past exp.
290        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        // Flip the last hex character of the HMAC.
327        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}