//! Companion device tokens — long-lived bearer credentials minted from an //! authenticated session, so the pairing QR can log a phone in without //! carrying the admin password (which the browser never has anyway). //! //! Only the SHA-256 of each token is persisted (`device-tokens.json` in the //! data dir); the plaintext is returned exactly once at mint time and rides //! the QR as the `tok` param. Verification goes through `auth.login`'s //! `token` param and is covered by the same login rate limiter as passwords. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; use tokio::fs; const TOKENS_FILE: &str = "device-tokens.json"; /// Cap on stored tokens; re-pairing the same device name replaces its entry, /// so this only limits the number of *distinct* device names. const MAX_TOKENS: usize = 32; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceToken { pub name: String, /// Hex SHA-256 of the plaintext token. pub hash: String, /// Unix seconds at mint time. pub created: u64, } fn tokens_path(data_dir: &Path) -> PathBuf { data_dir.join(TOKENS_FILE) } async fn load(data_dir: &Path) -> Vec { match fs::read(tokens_path(data_dir)).await { Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), Err(_) => Vec::new(), } } async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> { let bytes = serde_json::to_vec_pretty(tokens)?; fs::write(tokens_path(data_dir), bytes) .await .context("write device-tokens.json") } fn hash_hex(token: &str) -> String { hex::encode(Sha256::digest(token.as_bytes())) } fn ct_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } /// Mint a new token for `name`. An existing token with the same name is /// replaced, so re-showing the pairing QR never piles up stale entries. /// Returns the plaintext token — the only time it ever exists outside the QR. pub async fn create(data_dir: &Path, name: &str) -> Result { let token_bytes: [u8; 32] = rand::random(); let token = hex::encode(token_bytes); let mut tokens = load(data_dir).await; tokens.retain(|t| t.name != name); if tokens.len() >= MAX_TOKENS { tokens.remove(0); } tokens.push(DeviceToken { name: name.to_string(), hash: hash_hex(&token), created: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0), }); save(data_dir, &tokens).await?; Ok(token) } /// Verify a candidate token. Returns the device name it was minted for. pub async fn verify(data_dir: &Path, candidate: &str) -> Option { let candidate_hash = hash_hex(candidate); load(data_dir) .await .iter() .find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes())) .map(|t| t.name.clone()) } /// List stored tokens (hashes only — plaintexts are unrecoverable). pub async fn list(data_dir: &Path) -> Vec { load(data_dir).await } /// Remove the token minted for `name`. Returns whether one existed. pub async fn remove(data_dir: &Path, name: &str) -> Result { let mut tokens = load(data_dir).await; let before = tokens.len(); tokens.retain(|t| t.name != name); let removed = tokens.len() != before; if removed { save(data_dir, &tokens).await?; } Ok(removed) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn mint_verify_replace_remove() { let dir = tempfile::tempdir().unwrap(); let token = create(dir.path(), "phone").await.unwrap(); assert_eq!(verify(dir.path(), &token).await.as_deref(), Some("phone")); assert!(verify(dir.path(), "not-a-token").await.is_none()); // Re-minting the same name invalidates the old token. let token2 = create(dir.path(), "phone").await.unwrap(); assert!(verify(dir.path(), &token).await.is_none()); assert_eq!(verify(dir.path(), &token2).await.as_deref(), Some("phone")); assert_eq!(list(dir.path()).await.len(), 1); assert!(remove(dir.path(), "phone").await.unwrap()); assert!(verify(dir.path(), &token2).await.is_none()); } }