feat: add TOTP 2FA, API key switcher, login progress bar, and alpha hardening plan

- TOTP 2FA: full setup/confirm/disable/login flow with Argon2id + ChaCha20-Poly1305
  encrypted secret storage, QR code generation, and bcrypt-hashed backup codes
- API key switcher: OAuth vs personal API key toggle in AIUI chat settings with
  status indicator, key validation, and help text
- Login progress bar: server startup detection with health check polling, form
  disabled until server is ready
- AI quarantine docs: comprehensive HTML page documenting all 6 security layers
- Settings: AI Data Access permission toggles with per-category control
- Alpha hardening plan: 28-task overnight automation plan across 7 phases
  (onboarding, login, app install, AIUI, UI polish, security, ISO build)
- Backlog: node discovery spatial map feature for alpha demo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 12:23:57 +00:00
parent 0b3c23ff76
commit e55fd3baf0
16 changed files with 2402 additions and 152 deletions

View File

@@ -4,10 +4,33 @@ use std::net::IpAddr;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
use zeroize::Zeroize;
const FULL_SESSION_TTL: u64 = 86400; // 24 hours
const PENDING_SESSION_TTL: u64 = 300; // 5 minutes
const MAX_TOTP_ATTEMPTS: u8 = 5;
#[derive(Clone)]
enum SessionType {
Full,
PendingTotp {
totp_secret: Vec<u8>,
attempts: u8,
},
}
impl Drop for SessionType {
fn drop(&mut self) {
if let SessionType::PendingTotp { totp_secret, .. } = self {
totp_secret.zeroize();
}
}
}
#[derive(Clone)]
struct Session {
created_at: Instant,
session_type: SessionType,
}
#[derive(Clone)]
@@ -22,27 +45,84 @@ impl SessionStore {
}
}
/// Create a full (authenticated) session. Returns the plaintext token.
pub async fn create(&self) -> String {
let token_bytes: [u8; 32] = rand::random();
let token = hex::encode(token_bytes);
let hash = hash_token(&token);
let session = Session {
created_at: Instant::now(),
session_type: SessionType::Full,
};
self.sessions.write().await.insert(hash, session);
token
}
/// Create a pending TOTP session (password verified, awaiting TOTP).
/// Caches the decrypted TOTP secret in memory for verification.
pub async fn create_pending(&self, totp_secret: Vec<u8>) -> String {
let token_bytes: [u8; 32] = rand::random();
let token = hex::encode(token_bytes);
let hash = hash_token(&token);
let session = Session {
created_at: Instant::now(),
session_type: SessionType::PendingTotp {
totp_secret,
attempts: 0,
},
};
self.sessions.write().await.insert(hash, session);
token
}
/// Validate a full session token. Returns true if the session exists and hasn't expired.
pub async fn validate(&self, token: &str) -> bool {
let hash = hash_token(token);
let sessions = self.sessions.read().await;
if let Some(session) = sessions.get(&hash) {
session.created_at.elapsed().as_secs() < 86400
matches!(session.session_type, SessionType::Full)
&& session.created_at.elapsed().as_secs() < FULL_SESSION_TTL
} else {
false
}
}
/// Get the TOTP secret from a pending session. Returns None if not a valid pending session.
/// Increments the attempt counter.
pub async fn get_pending_secret(&self, token: &str) -> Option<Vec<u8>> {
let hash = hash_token(token);
let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.get_mut(&hash) {
if session.created_at.elapsed().as_secs() >= PENDING_SESSION_TTL {
sessions.remove(&hash);
return None;
}
if let SessionType::PendingTotp {
ref totp_secret,
ref mut attempts,
} = session.session_type
{
*attempts += 1;
if *attempts > MAX_TOTP_ATTEMPTS {
sessions.remove(&hash); // Too many attempts, force re-login
return None;
}
return Some(totp_secret.clone());
}
}
None
}
/// Upgrade a pending session to a full session.
pub async fn upgrade_to_full(&self, token: &str) {
let hash = hash_token(token);
let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.get_mut(&hash) {
session.session_type = SessionType::Full;
session.created_at = Instant::now(); // Reset TTL to 24h from now
}
}
pub async fn remove(&self, token: &str) {
let hash = hash_token(token);
self.sessions.write().await.remove(&hash);
@@ -85,22 +165,17 @@ impl LoginRateLimiter {
}
}
/// Check if a login attempt is allowed for this IP. Returns false if rate limited.
pub async fn check(&self, ip: IpAddr) -> bool {
let mut attempts = self.attempts.write().await;
let now = Instant::now();
let entry = attempts.entry(ip).or_insert_with(Vec::new);
// Remove attempts older than the window
let entry = attempts.entry(ip).or_default();
entry.retain(|t| now.duration_since(*t).as_secs() < WINDOW_SECS);
entry.len() < MAX_ATTEMPTS
}
/// Record a failed login attempt.
pub async fn record_failure(&self, ip: IpAddr) {
let mut attempts = self.attempts.write().await;
let entry = attempts.entry(ip).or_insert_with(Vec::new);
let entry = attempts.entry(ip).or_default();
entry.push(Instant::now());
}
}