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

@@ -62,10 +62,16 @@ reqwest = { version = "0.11", features = ["json", "socks"] }
# Nostr (node discovery)
nostr-sdk = "0.44"
# Backup encryption (DID identity export)
# Backup encryption (DID identity export) + TOTP 2FA encryption
argon2 = "0.5"
chacha20poly1305 = "0.10"
base64 = "0.21"
# TOTP 2FA
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
qrcode = "0.14"
data-encoding = "2.6"
zeroize = { version = "1.7", features = ["derive"] }
[dev-dependencies]
tokio-test = "0.4"

View File

@@ -5,6 +5,7 @@ mod lnd;
mod node;
mod package;
mod peers;
mod totp;
use crate::auth::AuthManager;
use crate::config::Config;
@@ -44,6 +45,8 @@ pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
/// Methods that do not require a valid session cookie.
const UNAUTHENTICATED_METHODS: &[&str] = &[
"auth.login",
"auth.login.totp",
"auth.login.backup",
"auth.isOnboardingComplete",
"health",
];
@@ -150,54 +153,70 @@ impl RpcHandler {
}
}
// Extract params; clone for post-routing use (login 2FA check needs password)
let params = rpc_req.params;
let login_params: Option<serde_json::Value> = if rpc_req.method == "auth.login" {
params.clone()
} else {
None
};
// Route to handler
let result = match rpc_req.method.as_str() {
"echo" => self.handle_echo(rpc_req.params).await,
"server.echo" => self.handle_echo(rpc_req.params).await,
"auth.login" => self.handle_auth_login(rpc_req.params).await,
"echo" => self.handle_echo(params).await,
"server.echo" => self.handle_echo(params).await,
"auth.login" => self.handle_auth_login(params).await,
"auth.logout" => self.handle_auth_logout().await,
"auth.changePassword" => self.handle_auth_change_password(rpc_req.params).await,
"auth.changePassword" => self.handle_auth_change_password(params).await,
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
"auth.resetOnboarding" => self.handle_auth_reset_onboarding().await,
// Container orchestration (for Archipelago-managed containers)
"container-install" => self.handle_container_install(rpc_req.params).await,
"container-start" => self.handle_container_start(rpc_req.params).await,
"container-stop" => self.handle_container_stop(rpc_req.params).await,
"container-remove" => self.handle_container_remove(rpc_req.params).await,
"container-install" => self.handle_container_install(params).await,
"container-start" => self.handle_container_start(params).await,
"container-stop" => self.handle_container_stop(params).await,
"container-remove" => self.handle_container_remove(params).await,
"container-list" => self.handle_container_list().await,
"container-status" => self.handle_container_status(rpc_req.params).await,
"container-logs" => self.handle_container_logs(rpc_req.params).await,
"container-health" => self.handle_container_health(rpc_req.params).await,
"container-status" => self.handle_container_status(params).await,
"container-logs" => self.handle_container_logs(params).await,
"container-health" => self.handle_container_health(params).await,
// Package management (for docker-compose apps)
"package.install" => self.handle_package_install(rpc_req.params).await,
"package.start" => self.handle_package_start(rpc_req.params).await,
"package.stop" => self.handle_package_stop(rpc_req.params).await,
"package.restart" => self.handle_package_restart(rpc_req.params).await,
"package.uninstall" => self.handle_package_uninstall(rpc_req.params).await,
"package.install" => self.handle_package_install(params).await,
"package.start" => self.handle_package_start(params).await,
"package.stop" => self.handle_package_stop(params).await,
"package.restart" => self.handle_package_restart(params).await,
"package.uninstall" => self.handle_package_uninstall(params).await,
// Bundled app management (for pre-loaded container images)
"bundled-app-start" => self.handle_bundled_app_start(rpc_req.params).await,
"bundled-app-stop" => self.handle_bundled_app_stop(rpc_req.params).await,
"bundled-app-start" => self.handle_bundled_app_start(params).await,
"bundled-app-stop" => self.handle_bundled_app_stop(params).await,
// Node identity and P2P peers
"node-add-peer" => self.handle_node_add_peer(rpc_req.params).await,
"node-add-peer" => self.handle_node_add_peer(params).await,
"node-list-peers" => self.handle_node_list_peers().await,
"node-remove-peer" => self.handle_node_remove_peer(rpc_req.params).await,
"node-send-message" => self.handle_node_send_message(rpc_req.params).await,
"node-check-peer" => self.handle_node_check_peer(rpc_req.params).await,
"node-remove-peer" => self.handle_node_remove_peer(params).await,
"node-send-message" => self.handle_node_send_message(params).await,
"node-check-peer" => self.handle_node_check_peer(params).await,
"node-messages-received" => self.handle_node_messages_received().await,
"node-nostr-discover" => self.handle_node_nostr_discover().await,
"node.did" => self.handle_node_did().await,
"node.signChallenge" => self.handle_node_sign_challenge(rpc_req.params).await,
"node.createBackup" => self.handle_node_create_backup(rpc_req.params).await,
"node.signChallenge" => self.handle_node_sign_challenge(params).await,
"node.createBackup" => self.handle_node_create_backup(params).await,
"node.tor-address" => self.handle_node_tor_address().await,
"node.nostr-publish" => self.handle_node_nostr_publish().await,
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await,
// TOTP 2FA
"auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await,
"auth.totp.setup.confirm" => self.handle_totp_setup_confirm(params).await,
"auth.totp.disable" => self.handle_totp_disable(params).await,
"auth.totp.status" => self.handle_totp_status().await,
"auth.login.totp" => self.handle_login_totp(params, &session_token).await,
"auth.login.backup" => self.handle_login_backup(params, &session_token).await,
// Bitcoin & Lightning deep data
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
"lnd.getinfo" => self.handle_lnd_getinfo().await,
@@ -241,17 +260,51 @@ impl RpcHandler {
self.login_rate_limiter.record_failure(client_ip).await;
}
// On successful login, create a session and set the cookie
// On successful login, check if 2FA is required
if rpc_req.method == "auth.login" && rpc_resp.error.is_none() {
let token = self.session_store.create().await;
response.headers_mut().insert(
"Set-Cookie",
format!("session={}; HttpOnly; SameSite=Strict; Path=/", token)
.parse()
.unwrap(),
);
let totp_enabled = self.auth_manager.is_totp_enabled().await.unwrap_or(false);
if totp_enabled {
// 2FA enabled: create a pending session with cached TOTP secret
// We need the password to decrypt the TOTP secret for step 2
let password = login_params
.as_ref()
.and_then(|p| p.get("password"))
.and_then(|v| v.as_str())
.unwrap_or("");
if let Ok(Some(totp_data)) = self.auth_manager.get_totp_data().await {
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
let token = self.session_store.create_pending(secret).await;
response.headers_mut().insert(
"Set-Cookie",
format!("session={}; HttpOnly; SameSite=Strict; Path=/", token)
.parse()
.unwrap(),
);
// Override the response body to indicate TOTP is required
let totp_body = serde_json::json!({
"result": { "requires_totp": true },
"error": null
});
*response.body_mut() = hyper::Body::from(
serde_json::to_vec(&totp_body).unwrap_or_default(),
);
}
}
} else {
// No 2FA: create a full session immediately
let token = self.session_store.create().await;
response.headers_mut().insert(
"Set-Cookie",
format!("session={}; HttpOnly; SameSite=Strict; Path=/", token)
.parse()
.unwrap(),
);
}
}
// On successful TOTP verification, the session is already upgraded to full
// (handled inside handle_login_totp/handle_login_backup)
// On logout, invalidate session and expire the cookie
if rpc_req.method == "auth.logout" {
if let Some(token) = &session_token {

View File

@@ -0,0 +1,257 @@
use super::RpcHandler;
use anyhow::Result;
impl RpcHandler {
/// Begin 2FA setup: generate TOTP secret, return QR code + base32 secret.
/// The secret is cached in a pending setup session (in memory only).
pub(super) async fn handle_totp_setup_begin(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
// Re-verify password before setup
if !self.auth_manager.verify_password(password).await? {
anyhow::bail!("Password Incorrect");
}
// Check 2FA isn't already enabled
if self.auth_manager.is_totp_enabled().await? {
anyhow::bail!("2FA is already enabled. Disable it first.");
}
let setup = crate::totp::setup(password)?;
// Cache the setup result in a pending session so confirm can use it
// We store the encrypted TotpData and backup codes temporarily
let setup_json = serde_json::json!({
"totp_data": setup.totp_data,
"backup_codes": setup.backup_codes,
});
let setup_bytes = serde_json::to_vec(&setup_json)?;
let pending_token = self.session_store.create_pending(setup_bytes).await;
// Return QR + secret for display (the pending token is set as a cookie by mod.rs)
Ok(serde_json::json!({
"qr_svg": setup.qr_svg,
"secret_base32": setup.secret_base32,
"pending_token": pending_token,
}))
}
/// Confirm 2FA setup: verify the user's first TOTP code, enable 2FA, return backup codes.
pub(super) async fn handle_totp_setup_confirm(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let code = params
.get("code")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing code"))?;
let pending_token = params
.get("pendingToken")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing pendingToken"))?;
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
// Re-verify password
if !self.auth_manager.verify_password(password).await? {
anyhow::bail!("Password Incorrect");
}
// Retrieve the pending setup data
let setup_bytes = self
.session_store
.get_pending_secret(pending_token)
.await
.ok_or_else(|| anyhow::anyhow!("Setup session expired or invalid. Please start again."))?;
let setup_json: serde_json::Value = serde_json::from_slice(&setup_bytes)?;
let totp_data: crate::totp::TotpData =
serde_json::from_value(setup_json["totp_data"].clone())?;
let backup_codes: Vec<String> =
serde_json::from_value(setup_json["backup_codes"].clone())?;
// Decrypt and verify the TOTP code
let secret = crate::totp::decrypt_secret(&totp_data, password)?;
let step = crate::totp::verify_code(&secret, code, &[])?;
if step.is_none() {
anyhow::bail!("Invalid code. Please check your authenticator app and try again.");
}
// Persist TOTP data
self.auth_manager.save_totp(totp_data).await?;
// Clean up the pending session
self.session_store.remove(pending_token).await;
tracing::info!("2FA enabled successfully");
Ok(serde_json::json!({
"enabled": true,
"backup_codes": backup_codes,
}))
}
/// Disable 2FA. Requires password + current TOTP code.
pub(super) async fn handle_totp_disable(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
let code = params
.get("code")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing code"))?;
// Verify password
if !self.auth_manager.verify_password(password).await? {
anyhow::bail!("Password Incorrect");
}
// Get and verify TOTP
let totp_data = self
.auth_manager
.get_totp_data()
.await?
.ok_or_else(|| anyhow::anyhow!("2FA is not enabled"))?;
let secret = crate::totp::decrypt_secret(&totp_data, password)?;
let step = crate::totp::verify_code(&secret, code, &totp_data.used_steps)?;
if step.is_none() {
anyhow::bail!("Invalid TOTP code");
}
self.auth_manager.remove_totp().await?;
tracing::info!("2FA disabled successfully");
Ok(serde_json::json!({ "disabled": true }))
}
/// Get 2FA status.
pub(super) async fn handle_totp_status(&self) -> Result<serde_json::Value> {
let enabled = self.auth_manager.is_totp_enabled().await?;
Ok(serde_json::json!({ "enabled": enabled }))
}
/// Step 2 of login: verify TOTP code using the cached secret from the pending session.
pub(super) async fn handle_login_totp(
&self,
params: Option<serde_json::Value>,
session_token: &Option<String>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let code = params
.get("code")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing code"))?;
let token = session_token
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No pending session"))?;
let secret = self
.session_store
.get_pending_secret(token)
.await
.ok_or_else(|| {
anyhow::anyhow!("Session expired or too many attempts. Please log in again.")
})?;
// Get used steps from stored data for replay protection
let totp_data = self.auth_manager.get_totp_data().await?;
let used_steps = totp_data
.as_ref()
.map(|d| d.used_steps.clone())
.unwrap_or_default();
let step = crate::totp::verify_code(&secret, code, &used_steps)?;
match step {
Some(used_step) => {
// Record the used step for replay protection
if let Some(mut data) = totp_data {
data.used_steps.push(used_step);
// Prune old steps (keep only last 5 minutes worth)
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let cutoff = (now / 30) - 10; // ~5 minutes
data.used_steps.retain(|s| *s > cutoff);
let _ = self.auth_manager.update_totp(data).await;
}
// Upgrade pending session to full
self.session_store.upgrade_to_full(token).await;
Ok(serde_json::json!({ "success": true }))
}
None => {
anyhow::bail!("Invalid code. Please try again.");
}
}
}
/// Step 2 of login (alternative): verify backup code.
pub(super) async fn handle_login_backup(
&self,
params: Option<serde_json::Value>,
session_token: &Option<String>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let code = params
.get("code")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing code"))?;
let token = session_token
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No pending session"))?;
// Verify the pending session is valid (increments attempts)
let _secret = self
.session_store
.get_pending_secret(token)
.await
.ok_or_else(|| {
anyhow::anyhow!("Session expired or too many attempts. Please log in again.")
})?;
// Verify backup code against stored hashes
let mut totp_data = self
.auth_manager
.get_totp_data()
.await?
.ok_or_else(|| anyhow::anyhow!("2FA is not enabled"))?;
match crate::totp::verify_backup_code(&totp_data.backup_codes, code)? {
Some(idx) => {
// Remove the used backup code (one-time use)
totp_data.backup_codes.remove(idx);
self.auth_manager.update_totp(totp_data).await?;
// Upgrade pending session to full
self.session_store.upgrade_to_full(token).await;
tracing::info!("Login via backup code (codes remaining: {})",
self.auth_manager.get_totp_data().await?.map(|d| d.backup_codes.len()).unwrap_or(0));
Ok(serde_json::json!({ "success": true }))
}
None => {
anyhow::bail!("Invalid backup code");
}
}
}
}

View File

@@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
use crate::totp::TotpData;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OnboardingState {
complete: bool,
@@ -17,6 +19,8 @@ pub struct User {
pub password_hash: String,
pub setup_complete: bool,
pub onboarding_complete: bool,
#[serde(default)]
pub totp: Option<TotpData>,
}
#[allow(dead_code)]
@@ -58,6 +62,7 @@ impl AuthManager {
password_hash,
setup_complete: true,
onboarding_complete,
totp: None,
};
let user_file = self.data_dir.join("user.json");
@@ -123,6 +128,39 @@ impl AuthManager {
.unwrap_or(false))
}
/// Check if 2FA is enabled for the user.
pub async fn is_totp_enabled(&self) -> Result<bool> {
Ok(self.get_user().await?.map(|u| u.totp.is_some()).unwrap_or(false))
}
/// Get the TOTP data (if 2FA is enabled).
pub async fn get_totp_data(&self) -> Result<Option<TotpData>> {
Ok(self.get_user().await?.and_then(|u| u.totp))
}
/// Save TOTP data to user.json (enable 2FA).
pub async fn save_totp(&self, totp_data: TotpData) -> Result<()> {
let mut user = self.get_user().await?.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
user.totp = Some(totp_data);
let user_file = self.data_dir.join("user.json");
fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?;
Ok(())
}
/// Remove TOTP data from user.json (disable 2FA).
pub async fn remove_totp(&self) -> Result<()> {
let mut user = self.get_user().await?.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
user.totp = None;
let user_file = self.data_dir.join("user.json");
fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?;
Ok(())
}
/// Update TOTP data in place (e.g. after consuming a backup code or recording a used step).
pub async fn update_totp(&self, totp_data: TotpData) -> Result<()> {
self.save_totp(totp_data).await
}
pub async fn verify_password(&self, password: &str) -> Result<bool> {
use bcrypt::verify;
@@ -157,6 +195,13 @@ impl AuthManager {
.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
user.password_hash = password_hash;
// Re-encrypt TOTP MEK under new password if 2FA is enabled
if let Some(ref totp_data) = user.totp {
let rekeyed = crate::totp::rekey(totp_data, current_password, new_password)?;
user.totp = Some(rekeyed);
}
let user_file = self.data_dir.join("user.json");
let content = serde_json::to_string_pretty(&user)?;
fs::write(&user_file, content).await?;

View File

@@ -20,6 +20,7 @@ mod peers;
mod server;
mod session;
mod state;
mod totp;
use auth::AuthManager;
use config::Config;

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());
}
}

View File

@@ -0,0 +1,388 @@
use anyhow::{Context, Result};
use argon2::{Argon2, Params};
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Nonce,
};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use totp_rs::{Algorithm, Secret, TOTP};
use zeroize::Zeroize;
const ARGON2_M_COST: u32 = 65536; // 64 MiB
const ARGON2_T_COST: u32 = 3;
const ARGON2_P_COST: u32 = 4;
const ARGON2_OUTPUT_LEN: usize = 32;
const BACKUP_CODE_COUNT: usize = 8;
const BACKUP_CODE_LEN: usize = 8; // 8 alphanumeric chars
/// Encrypted TOTP data stored in user.json. The secret never touches disk in plaintext.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TotpData {
/// Argon2id salt for KEK derivation (base64)
pub kek_salt: String,
/// Nonce for MEK encryption (base64)
pub mek_nonce: String,
/// MEK encrypted under KEK via ChaCha20-Poly1305 (base64)
pub encrypted_mek: String,
/// Nonce for TOTP secret encryption (base64)
pub secret_nonce: String,
/// TOTP secret encrypted under MEK via ChaCha20-Poly1305 (base64)
pub encrypted_secret: String,
/// Hashed backup codes (bcrypt), one-time use
pub backup_codes: Vec<String>,
/// Recently used TOTP time steps for replay protection
#[serde(default)]
pub used_steps: Vec<i64>,
}
/// Result of setup: encrypted data for storage + plaintext for display (shown once).
pub struct SetupResult {
pub totp_data: TotpData,
pub secret_base32: String,
pub qr_svg: String,
pub backup_codes: Vec<String>,
}
/// Generate a TOTP setup. Returns encrypted data + display values.
pub fn setup(password: &str) -> Result<SetupResult> {
// Generate the raw TOTP secret (20 bytes = 160 bits, standard for SHA1)
let mut totp_secret = vec![0u8; 20];
rand::rngs::OsRng.fill_bytes(&mut totp_secret);
// Generate MEK (Master Encryption Key)
let mut mek = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut mek);
// Derive KEK from password via Argon2id
let mut kek_salt = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut kek_salt);
let mut kek = derive_kek(password, &kek_salt)?;
// Encrypt MEK under KEK
let mut mek_nonce_bytes = [0u8; 12];
rand::rngs::OsRng.fill_bytes(&mut mek_nonce_bytes);
let encrypted_mek = encrypt_chacha(&kek, &mek_nonce_bytes, &mek)?;
// Encrypt TOTP secret under MEK
let mut secret_nonce_bytes = [0u8; 12];
rand::rngs::OsRng.fill_bytes(&mut secret_nonce_bytes);
let encrypted_secret = encrypt_chacha(&mek, &secret_nonce_bytes, &totp_secret)?;
// Generate backup codes
let (plaintext_codes, hashed_codes) = generate_backup_codes()?;
// Encode the base32 secret for the authenticator app
let secret_base32 = data_encoding::BASE32_NOPAD.encode(&totp_secret);
// Generate QR code SVG
let totp = TOTP::new(
Algorithm::SHA1,
6,
1, // skew
30,
Secret::Raw(totp_secret.clone()).to_bytes().unwrap(),
Some("Archipelago".to_string()),
"node".to_string(),
)
.context("Failed to create TOTP")?;
let otpauth_url = totp.get_url();
let qr_svg = generate_qr_svg(&otpauth_url)?;
let totp_data = TotpData {
kek_salt: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, kek_salt),
mek_nonce: base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
mek_nonce_bytes,
),
encrypted_mek: base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&encrypted_mek,
),
secret_nonce: base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
secret_nonce_bytes,
),
encrypted_secret: base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&encrypted_secret,
),
backup_codes: hashed_codes,
used_steps: Vec::new(),
};
// Zeroize sensitive material
mek.zeroize();
kek.zeroize();
totp_secret.zeroize();
Ok(SetupResult {
totp_data,
secret_base32,
qr_svg,
backup_codes: plaintext_codes,
})
}
/// Decrypt the TOTP secret from stored data using the user's password.
/// Returns the raw secret bytes.
pub fn decrypt_secret(data: &TotpData, password: &str) -> Result<Vec<u8>> {
use base64::Engine;
let b64 = &base64::engine::general_purpose::STANDARD;
let kek_salt = b64
.decode(&data.kek_salt)
.context("Invalid kek_salt base64")?;
let mek_nonce = b64
.decode(&data.mek_nonce)
.context("Invalid mek_nonce base64")?;
let encrypted_mek = b64
.decode(&data.encrypted_mek)
.context("Invalid encrypted_mek base64")?;
let secret_nonce = b64
.decode(&data.secret_nonce)
.context("Invalid secret_nonce base64")?;
let encrypted_secret = b64
.decode(&data.encrypted_secret)
.context("Invalid encrypted_secret base64")?;
// Derive KEK
let mut kek = derive_kek(password, &kek_salt)?;
// Decrypt MEK
let mut mek = decrypt_chacha(&kek, &mek_nonce, &encrypted_mek)
.context("Failed to decrypt MEK — wrong password or corrupt data")?;
// Decrypt TOTP secret
let secret = decrypt_chacha(&mek, &secret_nonce, &encrypted_secret)
.context("Failed to decrypt TOTP secret")?;
kek.zeroize();
mek.zeroize();
Ok(secret)
}
/// Verify a TOTP code against the decrypted secret. Checks ±1 time step window.
pub fn verify_code(secret: &[u8], code: &str, used_steps: &[i64]) -> Result<Option<i64>> {
let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, secret.to_vec(), None, String::new())
.context("Failed to create TOTP verifier")?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.context("System time error")?
.as_secs();
// Check current step and ±1
for offset in [-1i64, 0, 1] {
let time = (now as i64) + (offset * 30);
if time < 0 {
continue;
}
let step = time / 30;
let expected = totp.generate(time as u64);
// Constant-time comparison
if constant_time_eq(code.as_bytes(), expected.as_bytes()) {
// Replay protection
if used_steps.contains(&step) {
return Ok(None); // Code already used
}
return Ok(Some(step));
}
}
Ok(None) // No match
}
/// Verify a backup code against the stored bcrypt hashes. Returns the index if valid.
pub fn verify_backup_code(hashed_codes: &[String], code: &str) -> Result<Option<usize>> {
let normalized = code.replace('-', "").to_uppercase();
for (i, hash) in hashed_codes.iter().enumerate() {
if bcrypt::verify(&normalized, hash)? {
return Ok(Some(i));
}
}
Ok(None)
}
/// Re-encrypt the MEK under a new password (for password change).
pub fn rekey(data: &TotpData, old_password: &str, new_password: &str) -> Result<TotpData> {
use base64::Engine;
let b64 = &base64::engine::general_purpose::STANDARD;
// Decrypt MEK with old password
let old_kek_salt = b64.decode(&data.kek_salt)?;
let old_mek_nonce = b64.decode(&data.mek_nonce)?;
let old_encrypted_mek = b64.decode(&data.encrypted_mek)?;
let mut old_kek = derive_kek(old_password, &old_kek_salt)?;
let mut mek = decrypt_chacha(&old_kek, &old_mek_nonce, &old_encrypted_mek)
.context("Failed to decrypt MEK with old password")?;
// Re-encrypt MEK with new password
let mut new_kek_salt = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut new_kek_salt);
let mut new_kek = derive_kek(new_password, &new_kek_salt)?;
let mut new_mek_nonce = [0u8; 12];
rand::rngs::OsRng.fill_bytes(&mut new_mek_nonce);
let new_encrypted_mek = encrypt_chacha(&new_kek, &new_mek_nonce, &mek)?;
old_kek.zeroize();
new_kek.zeroize();
mek.zeroize();
Ok(TotpData {
kek_salt: b64.encode(new_kek_salt),
mek_nonce: b64.encode(new_mek_nonce),
encrypted_mek: b64.encode(&new_encrypted_mek),
// TOTP secret ciphertext unchanged
secret_nonce: data.secret_nonce.clone(),
encrypted_secret: data.encrypted_secret.clone(),
backup_codes: data.backup_codes.clone(),
used_steps: data.used_steps.clone(),
})
}
// --- Internal helpers ---
fn derive_kek(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(ARGON2_OUTPUT_LEN))
.map_err(|e| anyhow::anyhow!("Invalid Argon2 params: {}", e))?;
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut kek = [0u8; 32];
argon2
.hash_password_into(password.as_bytes(), salt, &mut kek)
.map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?;
Ok(kek)
}
fn encrypt_chacha(key: &[u8; 32], nonce: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = ChaCha20Poly1305::new(key.into());
let nonce = Nonce::from_slice(nonce);
cipher
.encrypt(nonce, plaintext)
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))
}
fn decrypt_chacha(key: &[u8], nonce: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| anyhow::anyhow!("Invalid key length"))?;
let cipher = ChaCha20Poly1305::new(key.into());
let nonce = Nonce::from_slice(nonce);
cipher
.decrypt(nonce, ciphertext)
.map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))
}
fn generate_backup_codes() -> Result<(Vec<String>, Vec<String>)> {
let charset: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No 0/O/1/I ambiguity
let mut plaintext = Vec::with_capacity(BACKUP_CODE_COUNT);
let mut hashed = Vec::with_capacity(BACKUP_CODE_COUNT);
for _ in 0..BACKUP_CODE_COUNT {
let mut code = String::with_capacity(BACKUP_CODE_LEN);
for _ in 0..BACKUP_CODE_LEN {
let idx = (rand::random::<u8>() as usize) % charset.len();
code.push(charset[idx] as char);
}
let formatted = format!("{}-{}", &code[..4], &code[4..]);
let hash = bcrypt::hash(&code, 10)?;
plaintext.push(formatted);
hashed.push(hash);
}
Ok((plaintext, hashed))
}
fn generate_qr_svg(data: &str) -> Result<String> {
use qrcode::QrCode;
let code = QrCode::new(data.as_bytes()).context("Failed to generate QR code")?;
let svg = code
.render::<qrcode::render::svg::Color>()
.min_dimensions(200, 200)
.quiet_zone(true)
.build();
Ok(svg)
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_setup_and_verify() {
let password = "TestPassword123!";
let result = setup(password).unwrap();
// Decrypt and verify a code
let secret = decrypt_secret(&result.totp_data, password).unwrap();
let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, secret.clone(), None, String::new()).unwrap();
let code = totp.generate_current().unwrap();
let step = verify_code(&secret, &code, &[]).unwrap();
assert!(step.is_some(), "Valid TOTP code should verify");
// Replay: same step should be rejected
let used_step = step.unwrap();
let step2 = verify_code(&secret, &code, &[used_step]).unwrap();
assert!(step2.is_none(), "Replayed code should be rejected");
}
#[test]
fn test_wrong_password_fails() {
let result = setup("CorrectPassword1!").unwrap();
let err = decrypt_secret(&result.totp_data, "WrongPassword1!");
assert!(err.is_err(), "Wrong password should fail decryption");
}
#[test]
fn test_rekey() {
let old_pw = "OldPassword123!";
let new_pw = "NewPassword456!";
let result = setup(old_pw).unwrap();
// Get original secret
let original_secret = decrypt_secret(&result.totp_data, old_pw).unwrap();
// Rekey
let rekeyed = rekey(&result.totp_data, old_pw, new_pw).unwrap();
// Old password should fail
assert!(decrypt_secret(&rekeyed, old_pw).is_err());
// New password should produce same secret
let new_secret = decrypt_secret(&rekeyed, new_pw).unwrap();
assert_eq!(original_secret, new_secret);
}
#[test]
fn test_backup_codes() {
let result = setup("TestPassword123!").unwrap();
assert_eq!(result.backup_codes.len(), BACKUP_CODE_COUNT);
// Verify a backup code works
let code = &result.backup_codes[0];
let idx = verify_backup_code(&result.totp_data.backup_codes, code).unwrap();
assert_eq!(idx, Some(0));
// Invalid code should fail
let bad = verify_backup_code(&result.totp_data.backup_codes, "ZZZZ-ZZZZ").unwrap();
assert!(bad.is_none());
}
}