Skip to main content

mur_common/hitl/
signed_time.rs

1//! Time checks on the signed `issued_at` of HITL payloads (#1764 option C).
2//!
3//! Both `issued_at` values are inside the signature, so nobody without the
4//! router's key can move them. What these checks guard against is not an
5//! attacker but disagreeing clocks: the request is stamped by the router
6//! host, the answer possibly by a phone. [`HITL_CLOCK_SKEW_SECS`] is the
7//! tolerance for that disagreement.
8
9use chrono::{DateTime, Duration, Utc};
10
11use super::APPROVAL_TTL_SECS;
12
13/// Clock-skew tolerance between HITL writers, in seconds.
14///
15/// **A fault tolerance, not a security boundary.** Every time it widens is
16/// signed, so shrinking it buys no security; it only decides how far apart
17/// two honest clocks may drift before a legitimate answer is refused. It
18/// covers NTP-level drift (seconds), not a clock set by hand: an answer
19/// from a device whose clock is days off is refused, by design. Keep it
20/// small — a large value makes "answered before it was asked" meaningless.
21///
22/// Used by all three checks in [`check_answer_time`], with this one meaning:
23/// - from the future: `issued_at <= now + skew` (a fast clock cannot sign an
24///   answer that stays valid into the future);
25/// - answered before asked: `answer >= request - skew` (only the early
26///   direction gets slack; late is normal);
27/// - expired: `answer - request <= TTL + skew` (skew is added to the TTL,
28///   never subtracted from `now`).
29pub const HITL_CLOCK_SKEW_SECS: i64 = 60;
30
31/// Why a signed time rules an answer out.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum TimeFault {
34    /// Stamped later than `now + skew`.
35    FromTheFuture,
36    /// The answer predates its request by more than the skew.
37    BeforeRequest,
38    /// The answer came more than `TTL + skew` after its request.
39    AfterExpiry,
40}
41
42/// Is `answered` a timely answer to a request `asked`, judged at `now`?
43pub fn check_answer_time(
44    asked: DateTime<Utc>,
45    answered: DateTime<Utc>,
46    now: DateTime<Utc>,
47) -> Result<(), TimeFault> {
48    let skew = Duration::seconds(HITL_CLOCK_SKEW_SECS);
49    if asked > now + skew || answered > now + skew {
50        return Err(TimeFault::FromTheFuture);
51    }
52    if answered < asked - skew {
53        return Err(TimeFault::BeforeRequest);
54    }
55    if answered - asked > Duration::seconds(APPROVAL_TTL_SECS) + skew {
56        return Err(TimeFault::AfterExpiry);
57    }
58    Ok(())
59}
60
61/// Is something signed at `issued_at` still within the TTL at `now`?
62///
63/// Two uses, one rule: a request is still answerable (the rule an answer
64/// stamped `now` would face, so `mur channel approve` and the gate agree on
65/// which requests are open), and a settled decision may still be reused.
66pub fn check_fresh(issued_at: DateTime<Utc>, now: DateTime<Utc>) -> Result<(), TimeFault> {
67    check_answer_time(issued_at, now, now)
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    fn secs(s: i64) -> Duration {
75        Duration::seconds(s)
76    }
77
78    const SKEW: i64 = HITL_CLOCK_SKEW_SECS;
79
80    #[test]
81    fn an_answer_a_little_before_its_request_is_clock_noise() {
82        let now = Utc::now();
83        let asked = now - secs(10);
84        assert_eq!(check_answer_time(asked, asked - secs(SKEW), now), Ok(()));
85        assert_eq!(
86            check_answer_time(asked, asked - secs(SKEW + 1), now),
87            Err(TimeFault::BeforeRequest)
88        );
89    }
90
91    #[test]
92    fn expiry_adds_the_skew_to_the_ttl() {
93        let now = Utc::now();
94        let asked = now - secs(APPROVAL_TTL_SECS + SKEW + 10);
95        let edge = asked + secs(APPROVAL_TTL_SECS + SKEW);
96        assert_eq!(check_answer_time(asked, edge, now), Ok(()));
97        assert_eq!(
98            check_answer_time(asked, edge + secs(1), now),
99            Err(TimeFault::AfterExpiry)
100        );
101    }
102
103    #[test]
104    fn a_fast_clock_is_bounded_by_the_skew() {
105        let now = Utc::now();
106        assert_eq!(check_answer_time(now, now + secs(SKEW), now), Ok(()));
107        assert_eq!(
108            check_answer_time(now, now + secs(SKEW + 1), now),
109            Err(TimeFault::FromTheFuture)
110        );
111        assert_eq!(
112            check_answer_time(now + secs(SKEW + 1), now + secs(SKEW + 1), now),
113            Err(TimeFault::FromTheFuture),
114            "a future-dated request is refused too"
115        );
116        // Thirty days fast: the exact failure the bound exists for.
117        assert_eq!(
118            check_answer_time(now, now + Duration::days(30), now),
119            Err(TimeFault::FromTheFuture)
120        );
121    }
122
123    #[test]
124    fn fresh_until_ttl_plus_skew_and_never_from_the_future() {
125        let now = Utc::now();
126        assert_eq!(
127            check_fresh(now - secs(APPROVAL_TTL_SECS + SKEW), now),
128            Ok(())
129        );
130        assert_eq!(
131            check_fresh(now - secs(APPROVAL_TTL_SECS + SKEW + 1), now),
132            Err(TimeFault::AfterExpiry)
133        );
134        assert_eq!(
135            check_fresh(now + secs(SKEW + 1), now),
136            Err(TimeFault::FromTheFuture)
137        );
138    }
139}