//! Support session codes management //! //! Handles generation and validation of high-entropy, human-readable support //! codes for one-time remote support sessions. //! //! ## Code format (Task 4) //! //! v1 used a 6-digit numeric code (~20 bits, trivially brute-forceable). v2 uses //! a grouped base32-style code drawn from an UNAMBIGUOUS alphabet (no `0`/`O`, //! `1`/`I`/`L`) so a human reading it aloud cannot mistranscribe it: //! //! ```text //! XXX-XXX-XXX e.g. K7P-3MQ-Z9F //! ``` //! //! 9 symbols over a 31-character alphabet ≈ **44.6 bits** of entropy, generated //! with a CSPRNG ([`OsRng`]). Combined with the per-IP rate limiting + lockout on //! the validate route (Task 4) and single-use consumption on bind, the code space //! is no longer practically brute-forceable. use chrono::{DateTime, Utc}; use rand::rngs::OsRng; use rand::RngCore; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; /// Unambiguous code alphabet: digits 2-9 and A-Z, EXCLUDING the visually /// confusable `0`/`O`, `1`/`I`/`L`. 31 distinct symbols (≈4.954 bits each). const CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ"; /// Number of alphabet symbols in a generated code (excluding group separators). /// 9 symbols × log2(31) ≈ 44.6 bits — comfortably above the 40-bit target. const CODE_SYMBOLS: usize = 9; /// Symbols per visual group (the code is rendered as hyphen-separated groups). const CODE_GROUP_SIZE: usize = 3; /// Draw a single uniformly-distributed symbol from [`CODE_ALPHABET`] using a /// CSPRNG, via rejection sampling so every symbol is equally likely (no modulo /// bias). 31 is not a power of two, so we reject draws in the biased tail. fn random_symbol() -> u8 { let n = CODE_ALPHABET.len() as u32; // 31 // Largest multiple of n that fits in a u8 draw space (256); reject above it. let limit = (256 / n) * n; // 248 let mut rng = OsRng; loop { let mut buf = [0u8; 1]; rng.fill_bytes(&mut buf); let v = buf[0] as u32; if v < limit { return CODE_ALPHABET[(v % n) as usize]; } // else: biased tail — draw again. } } /// Generate a fresh grouped support code, e.g. `K7P-3MQ-Z9F`. CSPRNG-backed. fn generate_code_string() -> String { let mut out = String::with_capacity(CODE_SYMBOLS + CODE_SYMBOLS / CODE_GROUP_SIZE); for i in 0..CODE_SYMBOLS { if i > 0 && i % CODE_GROUP_SIZE == 0 { out.push('-'); } out.push(random_symbol() as char); } out } /// A support session code #[derive(Debug, Clone, Serialize)] pub struct SupportCode { pub code: String, pub session_id: Uuid, pub created_by: String, pub created_at: DateTime, pub status: CodeStatus, pub client_name: Option, pub client_machine: Option, pub connected_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum CodeStatus { Pending, // Waiting for client to connect Connected, // Client connected, session active Completed, // Session ended normally Cancelled, // Code cancelled by tech } /// Request to create a new support code #[derive(Debug, Deserialize)] pub struct CreateCodeRequest { #[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/ pub technician_id: Option, pub technician_name: Option, } /// Response when a code is validated #[derive(Debug, Serialize)] pub struct CodeValidation { pub valid: bool, pub session_id: Option, pub server_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } /// Manages support codes #[derive(Clone)] pub struct SupportCodeManager { codes: Arc>>, session_to_code: Arc>>, } impl SupportCodeManager { pub fn new() -> Self { Self { codes: Arc::new(RwLock::new(HashMap::new())), session_to_code: Arc::new(RwLock::new(HashMap::new())), } } /// Generate a unique high-entropy support code (see module docs). /// /// Draws CSPRNG-backed grouped codes (`XXX-XXX-XXX`, ≈44.6 bits) until one is /// not already live in the in-memory map. With a 31^9 code space the collision /// probability is negligible; the loop only guards against the (astronomically /// unlikely) duplicate. async fn generate_unique_code(&self) -> String { let codes = self.codes.read().await; loop { let code_str = generate_code_string(); if !codes.contains_key(&code_str) { return code_str; } } } /// Create a new support code pub async fn create_code(&self, request: CreateCodeRequest) -> SupportCode { let code = self.generate_unique_code().await; let session_id = Uuid::new_v4(); let support_code = SupportCode { code: code.clone(), session_id, created_by: request .technician_name .unwrap_or_else(|| "Unknown".to_string()), created_at: Utc::now(), status: CodeStatus::Pending, client_name: None, client_machine: None, connected_at: None, }; let mut codes = self.codes.write().await; codes.insert(code.clone(), support_code.clone()); let mut session_to_code = self.session_to_code.write().await; session_to_code.insert(session_id, code); support_code } /// Validate a code and return session info pub async fn validate_code(&self, code: &str) -> CodeValidation { let codes = self.codes.read().await; match codes.get(code) { Some(support_code) => { if support_code.status == CodeStatus::Pending || support_code.status == CodeStatus::Connected { CodeValidation { valid: true, session_id: Some(support_code.session_id.to_string()), server_url: Some("wss://connect.azcomputerguru.com/ws/support".to_string()), error: None, } } else { CodeValidation { valid: false, session_id: None, server_url: None, error: Some("This code has expired or been used".to_string()), } } } None => CodeValidation { valid: false, session_id: None, server_url: None, error: Some("Invalid code".to_string()), }, } } /// Mark a code as connected. /// /// Superseded by [`SupportCodeManager::consume_for_bind`] for the single-use /// bind path (Task 4). Retained for non-bind callers; not used on the agent /// bind path any longer. #[allow(dead_code)] pub async fn mark_connected( &self, code: &str, client_name: Option, client_machine: Option, ) { let mut codes = self.codes.write().await; if let Some(support_code) = codes.get_mut(code) { support_code.status = CodeStatus::Connected; support_code.client_name = client_name; support_code.client_machine = client_machine; support_code.connected_at = Some(Utc::now()); } } /// Atomically CONSUME a code for a first-time agent bind (single-use, Task 4). /// /// This is the single-use gate for the in-memory layer. Under the write lock, /// it accepts the code ONLY if it is currently `Pending` (never used), flips /// it to `Connected`, and records the binding client. Any other state /// (`Connected` — already bound, `Completed`, `Cancelled`, or a nonexistent /// code) is rejected. Because the transition happens while holding the write /// lock, two concurrent presenters of the same code race for the single /// `Pending → Connected` transition: exactly one wins, the loser is rejected. /// /// Returns `true` if the caller consumed the code (and may proceed to bind), /// `false` if the code was not available for consumption. /// /// NOTE: the preview route (`validate_code`) deliberately does NOT call this — /// previewing a code must never consume it. Only the agent bind path does. pub async fn consume_for_bind( &self, code: &str, client_name: Option, client_machine: Option, ) -> bool { let mut codes = self.codes.write().await; match codes.get_mut(code) { Some(support_code) if support_code.status == CodeStatus::Pending => { support_code.status = CodeStatus::Connected; support_code.client_name = client_name; support_code.client_machine = client_machine; support_code.connected_at = Some(Utc::now()); true } _ => false, } } /// Link a support code to an actual WebSocket session pub async fn link_session(&self, code: &str, real_session_id: Uuid) { let mut codes = self.codes.write().await; if let Some(support_code) = codes.get_mut(code) { // Update session_to_code mapping with real session ID let old_session_id = support_code.session_id; support_code.session_id = real_session_id; // Update the reverse mapping let mut session_to_code = self.session_to_code.write().await; session_to_code.remove(&old_session_id); session_to_code.insert(real_session_id, code.to_string()); } } /// Get code by its code string #[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/ pub async fn get_code(&self, code: &str) -> Option { let codes = self.codes.read().await; codes.get(code).cloned() } /// Mark a code as completed pub async fn mark_completed(&self, code: &str) { let mut codes = self.codes.write().await; if let Some(support_code) = codes.get_mut(code) { support_code.status = CodeStatus::Completed; } } /// Cancel a code (works for both pending and connected) pub async fn cancel_code(&self, code: &str) -> bool { let mut codes = self.codes.write().await; if let Some(support_code) = codes.get_mut(code) { if support_code.status == CodeStatus::Pending || support_code.status == CodeStatus::Connected { support_code.status = CodeStatus::Cancelled; return true; } } false } /// Check if a code is cancelled pub async fn is_cancelled(&self, code: &str) -> bool { let codes = self.codes.read().await; codes .get(code) .map(|c| c.status == CodeStatus::Cancelled) .unwrap_or(false) } /// Check if a code is valid for connection (exists and is pending) #[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/ pub async fn is_valid_for_connection(&self, code: &str) -> bool { let codes = self.codes.read().await; codes .get(code) .map(|c| c.status == CodeStatus::Pending) .unwrap_or(false) } /// List all codes (for dashboard) #[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/ pub async fn list_codes(&self) -> Vec { let codes = self.codes.read().await; codes.values().cloned().collect() } /// List active codes only pub async fn list_active_codes(&self) -> Vec { let codes = self.codes.read().await; codes .values() .filter(|c| c.status == CodeStatus::Pending || c.status == CodeStatus::Connected) .cloned() .collect() } /// Get code by session ID #[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/ pub async fn get_by_session(&self, session_id: Uuid) -> Option { let session_to_code = self.session_to_code.read().await; let code = session_to_code.get(&session_id)?; let codes = self.codes.read().await; codes.get(code).cloned() } /// Get the status of a code as a string (for auth checks). /// /// No longer used on the agent bind path (replaced by the atomic /// `consume_for_bind` single-use gate, Task 4); retained for diagnostics. #[allow(dead_code)] pub async fn get_status(&self, code: &str) -> Option { let codes = self.codes.read().await; codes.get(code).map(|c| match c.status { CodeStatus::Pending => "pending".to_string(), CodeStatus::Connected => "connected".to_string(), CodeStatus::Completed => "completed".to_string(), CodeStatus::Cancelled => "cancelled".to_string(), }) } } impl Default for SupportCodeManager { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn generated_code_has_expected_shape() { // XXX-XXX-XXX: 9 symbols + 2 hyphens = 11 chars. let code = generate_code_string(); assert_eq!(code.len(), CODE_SYMBOLS + 2); let parts: Vec<&str> = code.split('-').collect(); assert_eq!(parts.len(), CODE_SYMBOLS / CODE_GROUP_SIZE); for p in parts { assert_eq!(p.len(), CODE_GROUP_SIZE); } } #[test] fn generated_code_uses_only_unambiguous_alphabet() { // No 0/O/1/I/L; every non-hyphen char is in CODE_ALPHABET. for _ in 0..2_000 { let code = generate_code_string(); for c in code.chars().filter(|c| *c != '-') { assert!( CODE_ALPHABET.contains(&(c as u8)), "char {:?} not in unambiguous alphabet", c ); assert!( !matches!(c, '0' | 'O' | '1' | 'I' | 'L'), "ambiguous char {:?} leaked into a code", c ); } } } #[test] fn generated_codes_are_distinct_in_practice() { // With ~44 bits of entropy, 1000 draws should be unique. use std::collections::HashSet; let mut seen = HashSet::new(); for _ in 0..1_000 { assert!(seen.insert(generate_code_string()), "unexpected collision"); } } #[tokio::test] async fn consume_for_bind_is_single_use() { let mgr = SupportCodeManager::new(); let code = mgr .create_code(CreateCodeRequest { technician_id: None, technician_name: Some("tech".to_string()), }) .await .code; // First bind consumes the code. assert!( mgr.consume_for_bind(&code, Some("agent".into()), Some("a1".into())) .await ); // Second presenter is rejected — single use. assert!( !mgr.consume_for_bind(&code, Some("agent2".into()), Some("a2".into())) .await ); } #[tokio::test] async fn consume_for_bind_rejects_unknown_code() { let mgr = SupportCodeManager::new(); assert!(!mgr.consume_for_bind("NOP-E00-000", None, None).await); } #[tokio::test] async fn consume_for_bind_rejects_cancelled_code() { let mgr = SupportCodeManager::new(); let code = mgr .create_code(CreateCodeRequest { technician_id: None, technician_name: Some("tech".to_string()), }) .await .code; assert!(mgr.cancel_code(&code).await); // A cancelled code is not Pending → cannot be consumed. assert!(!mgr.consume_for_bind(&code, None, None).await); } #[tokio::test] async fn preview_validate_does_not_consume() { let mgr = SupportCodeManager::new(); let code = mgr .create_code(CreateCodeRequest { technician_id: None, technician_name: Some("tech".to_string()), }) .await .code; // Previewing the code many times must not consume it... for _ in 0..5 { assert!(mgr.validate_code(&code).await.valid); } // ...so a first real bind still succeeds. assert!(mgr.consume_for_bind(&code, None, None).await); } }