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]
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 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}