umsh_ulcp_runtime/
ble_security.rs

1//! Pure BLE security-policy helpers shared by embedded code and host tests.
2
3/// Authentication failures allowed in one power cycle before pairing locks.
4pub const MAX_PAIRING_FAILURES: u8 = 3;
5
6/// Non-OOB pairing admission.
7///
8/// Pairing mode is mandatory. A configured PIN selects LESC Passkey Entry;
9/// it does not authorize enrollment, so it can never stand in for the
10/// physical-presence gesture that arms the window.
11///
12/// Lockout is conditioned on `pin_configured` deliberately: it exists to stop
13/// Passkey Entry bit-leak probing, and under Just Works there is no passkey to
14/// leak — gating the unauthenticated path on the same counter would let an
15/// in-range attacker deny pairing for the rest of the power cycle with three
16/// bad confirm values.
17///
18/// Bond-store capacity is deliberately **not** a term. A full store evicts its
19/// least-recently-used bond on the next successful pairing; enrollment is never
20/// refused for lack of space. See [`crate::ble_security`] callers and
21/// `umsh_journal_store::ble::upsert_bond`.
22pub const fn pairing_enabled(pairing_mode: bool, pin_configured: bool, locked_out: bool) -> bool {
23    pairing_mode && (!pin_configured || !locked_out)
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum PairingFailureClass {
28    ConfirmValue,
29    DhKeyCheck,
30    Other,
31}
32
33/// Per-power-cycle pairing policy state.
34///
35/// Keeping the event transitions pure makes the security-sensitive behavior
36/// testable without a controller or real BLE connection. The embedded task
37/// loads this state from atomics, applies one transition, and publishes it
38/// back without awaiting in between.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub struct PairingRuntime {
41    pub pairing_mode: bool,
42    pub failures: u8,
43    pub locked_out: bool,
44}
45
46impl PairingRuntime {
47    pub const fn record_failure(mut self, failure: PairingFailureClass) -> Self {
48        let (failures, locked_out) = record_pairing_failure(self.failures, failure);
49        self.failures = failures;
50        self.locked_out = locked_out;
51        self
52    }
53
54    /// Any successful pairing closes the window and clears prior failures.
55    /// This is independent of whether Trouble includes the completed bond in
56    /// the `PairingComplete` event or exposes it at the protected GATT edge.
57    pub const fn pairing_succeeded(mut self) -> Self {
58        self.pairing_mode = false;
59        self.failures = 0;
60        self.locked_out = false;
61        self
62    }
63
64    /// Re-encryption by a known bond closes an accidentally-open window but
65    /// does not rewrite the current failure counter.
66    pub const fn bonded_reconnect(mut self) -> Self {
67        self.pairing_mode = false;
68        self
69    }
70}
71
72impl PairingFailureClass {
73    pub const fn counts_toward_lockout(self) -> bool {
74        matches!(self, Self::ConfirmValue | Self::DhKeyCheck)
75    }
76}
77
78/// Apply one pairing failure to the per-power-cycle lockout counter.
79///
80/// Protocol errors and policy rejections deliberately leave the counter
81/// unchanged so a remote peer cannot lock out legitimate pairing without
82/// guessing a PIN.
83pub const fn record_pairing_failure(current: u8, failure: PairingFailureClass) -> (u8, bool) {
84    if !failure.counts_toward_lockout() {
85        return (current, current >= MAX_PAIRING_FAILURES);
86    }
87
88    let failures = current.saturating_add(1);
89    (failures, failures >= MAX_PAIRING_FAILURES)
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn only_authentication_failures_advance_lockout() {
98        assert_eq!(
99            record_pairing_failure(0, PairingFailureClass::Other),
100            (0, false)
101        );
102        assert!(PairingFailureClass::ConfirmValue.counts_toward_lockout());
103        assert!(PairingFailureClass::DhKeyCheck.counts_toward_lockout());
104        assert!(!PairingFailureClass::Other.counts_toward_lockout());
105    }
106
107    #[test]
108    fn third_authentication_failure_locks_pairing() {
109        assert_eq!(
110            record_pairing_failure(0, PairingFailureClass::ConfirmValue),
111            (1, false)
112        );
113        assert_eq!(
114            record_pairing_failure(1, PairingFailureClass::DhKeyCheck),
115            (2, false)
116        );
117        assert_eq!(
118            record_pairing_failure(2, PairingFailureClass::ConfirmValue),
119            (3, true)
120        );
121    }
122
123    #[test]
124    fn locked_counter_saturates() {
125        assert_eq!(
126            record_pairing_failure(3, PairingFailureClass::DhKeyCheck),
127            (4, true)
128        );
129        assert_eq!(
130            record_pairing_failure(u8::MAX, PairingFailureClass::ConfirmValue),
131            (u8::MAX, true)
132        );
133        assert_eq!(
134            record_pairing_failure(u8::MAX, PairingFailureClass::Other),
135            (u8::MAX, true)
136        );
137    }
138
139    #[test]
140    fn pairing_requires_pairing_mode_even_with_a_configured_pin() {
141        // A PIN selects Passkey Entry; it does not authorize enrollment.
142        assert!(!pairing_enabled(false, true, false));
143        assert!(!pairing_enabled(false, true, true));
144        assert!(!pairing_enabled(false, false, false));
145
146        assert!(pairing_enabled(true, false, false));
147        assert!(pairing_enabled(true, true, false));
148    }
149
150    #[test]
151    fn lockout_applies_only_where_a_passkey_can_be_probed() {
152        // With a PIN, the failure limit closes the window for the rest of the
153        // power cycle even while pairing mode is armed.
154        assert!(!pairing_enabled(true, true, true));
155        // Under Just Works there is no passkey to leak, so a remote peer
156        // cannot deny pairing by failing the confirm value.
157        assert!(pairing_enabled(true, false, true));
158    }
159
160    #[test]
161    fn successful_pairing_clears_failures_even_without_event_bond() {
162        let state = PairingRuntime {
163            pairing_mode: true,
164            failures: 2,
165            locked_out: false,
166        }
167        .pairing_succeeded();
168
169        assert_eq!(
170            state,
171            PairingRuntime {
172                pairing_mode: false,
173                failures: 0,
174                locked_out: false,
175            }
176        );
177    }
178
179    #[test]
180    fn bonded_reconnect_closes_pairing_mode_without_changing_failures() {
181        let state = PairingRuntime {
182            pairing_mode: true,
183            failures: 1,
184            locked_out: false,
185        }
186        .bonded_reconnect();
187
188        assert!(!state.pairing_mode);
189        assert_eq!(state.failures, 1);
190        assert!(!state.locked_out);
191    }
192}