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    // The browser derives this key from the same inputs with its own SHA-256, HMAC and
265    // HKDF, since WebCrypto is unavailable over a plain-http hotspot. Both halves assert
266    // this file, so a change to either one that stops them interoperating fails here or
267    // in vectors.test.mjs rather than in the field.
268    #[test]
269    fn the_pairing_exchange_agrees_with_the_browser() {
270        let vector: serde_json::Value =
271            serde_json::from_str(include_str!("../tests/pairing-vector.json"))
272                .expect("the shared pairing vector parses");
273        let text = |key: &str| -> String {
274            vector[key]
275                .as_str()
276                .unwrap_or_else(|| panic!("{key} is a string"))
277                .to_owned()
278        };
279
280        assert_eq!(
281            text("info").as_bytes(),
282            INFO,
283            "the vector was produced for this info string"
284        );
285
286        let key = client_key(&text("secret"), &text("nonce"));
287        assert_eq!(to_hex(&key), text("key"));
288        assert_eq!(
289            to_hex(&hmac_sha256(
290                &key,
291                format!(
292                    "confirm
293{}",
294                    text("sessionId")
295                )
296                .as_bytes()
297            )),
298            text("confirmMac")
299        );
300
301        let command = &vector["command"];
302        let counter = command["counter"].as_u64().expect("a counter");
303        let payload = command["payload"].as_str().expect("a payload");
304        assert_eq!(
305            to_hex(&hmac_sha256(
306                &key,
307                format!(
308                    "{counter}
309{payload}"
310                )
311                .as_bytes()
312            )),
313            command["mac"].as_str().expect("a mac")
314        );
315    }
316
317    #[test]
318    fn a_correct_secret_pairs_and_commands() {
319        let secret = "s3cret";
320        let auth = Auth::new(secret);
321        let challenge = auth.challenge();
322        let key = client_key(secret, &challenge.nonce);
323
324        let confirm = to_hex(&hmac_sha256(
325            &key,
326            format!("confirm\n{}", challenge.session_id).as_bytes(),
327        ));
328        auth.confirm(&challenge.session_id, &confirm)
329            .expect("paired");
330
331        let cmd = r#"{"type":"actuate"}"#;
332        let mac = to_hex(&hmac_sha256(&key, format!("1\n{cmd}").as_bytes()));
333        auth.verify_command(&challenge.session_id, 1, cmd, &mac)
334            .expect("first command");
335    }
336
337    #[test]
338    fn a_wrong_secret_cannot_pair() {
339        let auth = Auth::new("right");
340        let challenge = auth.challenge();
341        let key = client_key("wrong", &challenge.nonce);
342        let confirm = to_hex(&hmac_sha256(
343            &key,
344            format!("confirm\n{}", challenge.session_id).as_bytes(),
345        ));
346        assert_eq!(
347            auth.confirm(&challenge.session_id, &confirm),
348            Err(AuthError::BadMac)
349        );
350    }
351
352    #[test]
353    fn a_replayed_counter_is_refused() {
354        let secret = "s3cret";
355        let auth = Auth::new(secret);
356        let challenge = auth.challenge();
357        let key = client_key(secret, &challenge.nonce);
358        let confirm = to_hex(&hmac_sha256(
359            &key,
360            format!("confirm\n{}", challenge.session_id).as_bytes(),
361        ));
362        auth.confirm(&challenge.session_id, &confirm)
363            .expect("paired");
364
365        let cmd = r#"{"type":"actuate"}"#;
366        let mac = to_hex(&hmac_sha256(&key, format!("5\n{cmd}").as_bytes()));
367        auth.verify_command(&challenge.session_id, 5, cmd, &mac)
368            .expect("counter 5");
369        // Replaying counter 5, or any counter at or below it, is rejected.
370        assert_eq!(
371            auth.verify_command(&challenge.session_id, 5, cmd, &mac),
372            Err(AuthError::Replayed)
373        );
374    }
375
376    #[test]
377    fn an_unpaired_session_cannot_command() {
378        let auth = Auth::new("s3cret");
379        let challenge = auth.challenge();
380        let key = client_key("s3cret", &challenge.nonce);
381        let cmd = "{}";
382        let mac = to_hex(&hmac_sha256(&key, format!("1\n{cmd}").as_bytes()));
383        assert_eq!(
384            auth.verify_command(&challenge.session_id, 1, cmd, &mac),
385            Err(AuthError::NotPaired)
386        );
387    }
388
389    #[test]
390    fn an_unknown_session_is_refused() {
391        let auth = Auth::new("s3cret");
392        assert_eq!(auth.confirm("nope", "00"), Err(AuthError::UnknownSession));
393    }
394
395    #[test]
396    fn a_tampered_command_fails_the_mac() {
397        let secret = "s3cret";
398        let auth = Auth::new(secret);
399        let challenge = auth.challenge();
400        let key = client_key(secret, &challenge.nonce);
401        let confirm = to_hex(&hmac_sha256(
402            &key,
403            format!("confirm\n{}", challenge.session_id).as_bytes(),
404        ));
405        auth.confirm(&challenge.session_id, &confirm)
406            .expect("paired");
407
408        let cmd = r#"{"type":"actuate","action":"open"}"#;
409        let mac = to_hex(&hmac_sha256(&key, format!("1\n{cmd}").as_bytes()));
410        let tampered = r#"{"type":"actuate","action":"close"}"#;
411        assert_eq!(
412            auth.verify_command(&challenge.session_id, 1, tampered, &mac),
413            Err(AuthError::BadMac)
414        );
415    }
416}