Skip to main content

pamoja_dashboard/
auth.rs

1//! Authenticating control over an open hotspot.
2//!
3//! Reading the dashboard is anonymous; moving an actuator or changing the fleet is not.
4//! Because the serving hotspot is unencrypted, control cannot rely on a bearer token a
5//! sniffer could capture and replay. Instead the device holds a pairing secret shown out
6//! of band (its own screen, a QR code, or the dev server's console). A client that knows
7//! the secret derives a per-session key from it and a server nonce, and authenticates
8//! every command with a counter and an HMAC, so an on-network attacker can neither forge
9//! a command nor replay a captured one. The secret itself never crosses the network.
10//!
11//! The keyed-hash primitives are reused from [`pamoja_session`] so this shares one
12//! audited, vector-pinned crypto path. Sessions live in memory; a server restart simply
13//! requires re-pairing.
14
15use std::collections::HashMap;
16use std::sync::Mutex;
17use std::time::{Duration, Instant};
18
19use pamoja_session::{hkdf_sha256, hmac_sha256};
20
21/// The HKDF context binding a derived key to this protocol and version.
22const INFO: &[u8] = b"pamoja/dashboard/cmd v1";
23
24/// How long a paired session stays valid before the client must pair again.
25const SESSION_TTL: Duration = Duration::from_secs(30 * 60);
26
27/// Why a control request was refused. The [`code`](AuthError::code) is a stable,
28/// language-neutral string the page localizes.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum AuthError {
31    /// No session with that id exists; the client must pair first.
32    UnknownSession,
33    /// The session exists but has not completed pairing.
34    NotPaired,
35    /// The session has expired; the client must pair again.
36    Expired,
37    /// The counter is not greater than the last accepted one (a replay or reorder).
38    Replayed,
39    /// The supplied MAC does not match; the client does not hold the session key.
40    BadMac,
41}
42
43impl AuthError {
44    /// Returns the stable error code for this failure.
45    ///
46    /// # Returns
47    ///
48    /// A dotted, language-neutral code such as `"auth.bad_mac"`.
49    pub fn code(self) -> &'static str {
50        match self {
51            AuthError::UnknownSession => "auth.unknown_session",
52            AuthError::NotPaired => "auth.not_paired",
53            AuthError::Expired => "auth.expired",
54            AuthError::Replayed => "auth.replayed",
55            AuthError::BadMac => "auth.bad_mac",
56        }
57    }
58}
59
60// One pairing session: the derived key, the highest command counter accepted, whether
61// pairing has been confirmed, and when it lapses.
62struct Entry {
63    key: [u8; 32],
64    last_counter: u64,
65    paired: bool,
66    expires: Instant,
67}
68
69/// A pairing challenge handed to a client in the clear.
70pub struct Challenge {
71    /// The opaque session identifier the client echoes on confirm and every command.
72    pub session_id: String,
73    /// The per-session salt the client mixes with the pairing secret to derive the key.
74    pub nonce: String,
75}
76
77/// Gatekeeper for control actions: it issues pairing challenges and verifies commands.
78pub struct Auth {
79    secret: Vec<u8>,
80    sessions: Mutex<HashMap<String, Entry>>,
81}
82
83impl Auth {
84    /// Creates an authenticator for a pairing secret.
85    ///
86    /// # Arguments
87    ///
88    /// * `secret` - the canonical pairing secret string (the client normalizes a typed
89    ///   code to the same value).
90    ///
91    /// # Returns
92    ///
93    /// An authenticator with no sessions yet.
94    pub fn new(secret: impl Into<String>) -> Self {
95        Self {
96            secret: secret.into().into_bytes(),
97            sessions: Mutex::new(HashMap::new()),
98        }
99    }
100
101    /// Generates a fresh high-entropy pairing secret as lowercase hex.
102    ///
103    /// # Returns
104    ///
105    /// A 128-bit secret rendered as 32 hex characters.
106    pub fn generate_secret() -> String {
107        to_hex(&random_bytes::<16>())
108    }
109
110    /// Starts a pairing exchange, returning a challenge and recording an unconfirmed
111    /// session.
112    ///
113    /// # Returns
114    ///
115    /// The [`Challenge`] to send to the client.
116    pub fn challenge(&self) -> Challenge {
117        let session_id = to_hex(&random_bytes::<16>());
118        let nonce = to_hex(&random_bytes::<16>());
119        let mut key = [0u8; 32];
120        hkdf_sha256(nonce.as_bytes(), &self.secret, INFO, &mut key);
121        let mut sessions = self.sessions.lock().expect("sessions lock");
122        prune(&mut sessions);
123        sessions.insert(
124            session_id.clone(),
125            Entry {
126                key,
127                last_counter: 0,
128                paired: false,
129                expires: Instant::now() + SESSION_TTL,
130            },
131        );
132        Challenge { session_id, nonce }
133    }
134
135    /// Confirms a pairing by checking the client proved it derived the session key.
136    ///
137    /// # Arguments
138    ///
139    /// * `session_id` - the challenge's session id.
140    /// * `mac_hex` - `HMAC(key, "confirm\n" + session_id)` as lowercase hex.
141    ///
142    /// # Returns
143    ///
144    /// `Ok(())` if the proof is valid and the session is now paired.
145    ///
146    /// # Errors
147    ///
148    /// [`AuthError::UnknownSession`], [`AuthError::Expired`], or [`AuthError::BadMac`].
149    pub fn confirm(&self, session_id: &str, mac_hex: &str) -> Result<(), AuthError> {
150        let mut sessions = self.sessions.lock().expect("sessions lock");
151        let entry = sessions
152            .get_mut(session_id)
153            .ok_or(AuthError::UnknownSession)?;
154        if Instant::now() > entry.expires {
155            sessions.remove(session_id);
156            return Err(AuthError::Expired);
157        }
158        let expected = hmac_hex(&entry.key, format!("confirm\n{session_id}").as_bytes());
159        if !ct_eq(expected.as_bytes(), mac_hex.as_bytes()) {
160            return Err(AuthError::BadMac);
161        }
162        entry.paired = true;
163        Ok(())
164    }
165
166    /// Verifies an authenticated command and advances the session's replay counter.
167    ///
168    /// The MAC covers the counter and the exact command string, so the server checks the
169    /// same bytes the client signed without re-serializing.
170    ///
171    /// # Arguments
172    ///
173    /// * `session_id` - the paired session's id.
174    /// * `counter` - the strictly increasing per-session command counter.
175    /// * `command` - the exact command payload string the client signed.
176    /// * `mac_hex` - `HMAC(key, counter + "\n" + command)` as lowercase hex.
177    ///
178    /// # Returns
179    ///
180    /// `Ok(())` if the command is authentic and fresh; the counter is then recorded.
181    ///
182    /// # Errors
183    ///
184    /// [`AuthError::UnknownSession`], [`AuthError::NotPaired`], [`AuthError::Expired`],
185    /// [`AuthError::Replayed`], or [`AuthError::BadMac`].
186    pub fn verify_command(
187        &self,
188        session_id: &str,
189        counter: u64,
190        command: &str,
191        mac_hex: &str,
192    ) -> Result<(), AuthError> {
193        let mut sessions = self.sessions.lock().expect("sessions lock");
194        let entry = sessions
195            .get_mut(session_id)
196            .ok_or(AuthError::UnknownSession)?;
197        if Instant::now() > entry.expires {
198            sessions.remove(session_id);
199            return Err(AuthError::Expired);
200        }
201        if !entry.paired {
202            return Err(AuthError::NotPaired);
203        }
204        if counter <= entry.last_counter {
205            return Err(AuthError::Replayed);
206        }
207        let expected = hmac_hex(&entry.key, format!("{counter}\n{command}").as_bytes());
208        if !ct_eq(expected.as_bytes(), mac_hex.as_bytes()) {
209            return Err(AuthError::BadMac);
210        }
211        entry.last_counter = counter;
212        Ok(())
213    }
214}
215
216// Drops sessions whose time has passed, so the table cannot grow without bound.
217fn prune(sessions: &mut HashMap<String, Entry>) {
218    let now = Instant::now();
219    sessions.retain(|_, entry| entry.expires > now);
220}
221
222fn random_bytes<const N: usize>() -> [u8; N] {
223    let mut bytes = [0u8; N];
224    getrandom::fill(&mut bytes).expect("system RNG");
225    bytes
226}
227
228fn hmac_hex(key: &[u8], message: &[u8]) -> String {
229    to_hex(&hmac_sha256(key, message))
230}
231
232fn to_hex(bytes: &[u8]) -> String {
233    let mut out = String::with_capacity(bytes.len() * 2);
234    for byte in bytes {
235        out.push(char::from_digit((byte >> 4) as u32, 16).expect("nibble"));
236        out.push(char::from_digit((byte & 0xf) as u32, 16).expect("nibble"));
237    }
238    out
239}
240
241// Length-checked constant-time comparison, so a MAC check does not leak via timing.
242fn ct_eq(a: &[u8], b: &[u8]) -> bool {
243    if a.len() != b.len() {
244        return false;
245    }
246    let mut diff = 0u8;
247    for (x, y) in a.iter().zip(b) {
248        diff |= x ^ y;
249    }
250    diff == 0
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    // Mirrors what the browser does: derive the session key from the secret and nonce.
258    fn client_key(secret: &str, nonce: &str) -> [u8; 32] {
259        let mut key = [0u8; 32];
260        hkdf_sha256(nonce.as_bytes(), secret.as_bytes(), INFO, &mut key);
261        key
262    }
263
264    #[test]
265    fn a_correct_secret_pairs_and_commands() {
266        let secret = "s3cret";
267        let auth = Auth::new(secret);
268        let challenge = auth.challenge();
269        let key = client_key(secret, &challenge.nonce);
270
271        let confirm = to_hex(&hmac_sha256(
272            &key,
273            format!("confirm\n{}", challenge.session_id).as_bytes(),
274        ));
275        auth.confirm(&challenge.session_id, &confirm)
276            .expect("paired");
277
278        let cmd = r#"{"type":"actuate"}"#;
279        let mac = to_hex(&hmac_sha256(&key, format!("1\n{cmd}").as_bytes()));
280        auth.verify_command(&challenge.session_id, 1, cmd, &mac)
281            .expect("first command");
282    }
283
284    #[test]
285    fn a_wrong_secret_cannot_pair() {
286        let auth = Auth::new("right");
287        let challenge = auth.challenge();
288        let key = client_key("wrong", &challenge.nonce);
289        let confirm = to_hex(&hmac_sha256(
290            &key,
291            format!("confirm\n{}", challenge.session_id).as_bytes(),
292        ));
293        assert_eq!(
294            auth.confirm(&challenge.session_id, &confirm),
295            Err(AuthError::BadMac)
296        );
297    }
298
299    #[test]
300    fn a_replayed_counter_is_refused() {
301        let secret = "s3cret";
302        let auth = Auth::new(secret);
303        let challenge = auth.challenge();
304        let key = client_key(secret, &challenge.nonce);
305        let confirm = to_hex(&hmac_sha256(
306            &key,
307            format!("confirm\n{}", challenge.session_id).as_bytes(),
308        ));
309        auth.confirm(&challenge.session_id, &confirm)
310            .expect("paired");
311
312        let cmd = r#"{"type":"actuate"}"#;
313        let mac = to_hex(&hmac_sha256(&key, format!("5\n{cmd}").as_bytes()));
314        auth.verify_command(&challenge.session_id, 5, cmd, &mac)
315            .expect("counter 5");
316        // Replaying counter 5, or any counter at or below it, is rejected.
317        assert_eq!(
318            auth.verify_command(&challenge.session_id, 5, cmd, &mac),
319            Err(AuthError::Replayed)
320        );
321    }
322
323    #[test]
324    fn an_unpaired_session_cannot_command() {
325        let auth = Auth::new("s3cret");
326        let challenge = auth.challenge();
327        let key = client_key("s3cret", &challenge.nonce);
328        let cmd = "{}";
329        let mac = to_hex(&hmac_sha256(&key, format!("1\n{cmd}").as_bytes()));
330        assert_eq!(
331            auth.verify_command(&challenge.session_id, 1, cmd, &mac),
332            Err(AuthError::NotPaired)
333        );
334    }
335
336    #[test]
337    fn an_unknown_session_is_refused() {
338        let auth = Auth::new("s3cret");
339        assert_eq!(auth.confirm("nope", "00"), Err(AuthError::UnknownSession));
340    }
341
342    #[test]
343    fn a_tampered_command_fails_the_mac() {
344        let secret = "s3cret";
345        let auth = Auth::new(secret);
346        let challenge = auth.challenge();
347        let key = client_key(secret, &challenge.nonce);
348        let confirm = to_hex(&hmac_sha256(
349            &key,
350            format!("confirm\n{}", challenge.session_id).as_bytes(),
351        ));
352        auth.confirm(&challenge.session_id, &confirm)
353            .expect("paired");
354
355        let cmd = r#"{"type":"actuate","action":"open"}"#;
356        let mac = to_hex(&hmac_sha256(&key, format!("1\n{cmd}").as_bytes()));
357        let tampered = r#"{"type":"actuate","action":"close"}"#;
358        assert_eq!(
359            auth.verify_command(&challenge.session_id, 1, tampered, &mac),
360            Err(AuthError::BadMac)
361        );
362    }
363}