1use std::collections::HashMap;
16use std::sync::Mutex;
17use std::time::{Duration, Instant};
18
19use pamoja_session::{hkdf_sha256, hmac_sha256};
20
21const INFO: &[u8] = b"pamoja/dashboard/cmd v1";
23
24const SESSION_TTL: Duration = Duration::from_secs(30 * 60);
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum AuthError {
31 UnknownSession,
33 NotPaired,
35 Expired,
37 Replayed,
39 BadMac,
41}
42
43impl AuthError {
44 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
60struct Entry {
63 key: [u8; 32],
64 last_counter: u64,
65 paired: bool,
66 expires: Instant,
67}
68
69pub struct Challenge {
71 pub session_id: String,
73 pub nonce: String,
75}
76
77pub struct Auth {
79 secret: Vec<u8>,
80 sessions: Mutex<HashMap<String, Entry>>,
81}
82
83impl Auth {
84 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 pub fn generate_secret() -> String {
107 to_hex(&random_bytes::<16>())
108 }
109
110 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 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 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
216fn 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
241fn 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 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]
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 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}