2026-01-24 22:59:20 +00:00
|
|
|
// Authentication module for Archipelago
|
|
|
|
|
// Handles user setup, onboarding, and login
|
|
|
|
|
|
|
|
|
|
use anyhow::Result;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use tokio::fs;
|
|
|
|
|
|
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>
2026-03-06 12:23:57 +00:00
|
|
|
use crate::totp::TotpData;
|
|
|
|
|
|
2026-03-14 05:46:10 +00:00
|
|
|
/// User role for multi-user RBAC (Year 3 feature).
|
|
|
|
|
/// - Admin: full access to all operations
|
|
|
|
|
/// - Viewer: read-only dashboard, container status, monitoring
|
|
|
|
|
/// - AppUser: access specific apps, no system configuration
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
|
|
|
pub enum UserRole {
|
|
|
|
|
Admin,
|
|
|
|
|
Viewer,
|
|
|
|
|
AppUser,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for UserRole {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
UserRole::Admin
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl UserRole {
|
|
|
|
|
/// Check if this role allows a given RPC method.
|
|
|
|
|
pub fn can_access(&self, method: &str) -> bool {
|
|
|
|
|
match self {
|
|
|
|
|
UserRole::Admin => true,
|
|
|
|
|
UserRole::Viewer => {
|
2026-03-18 22:37:08 +00:00
|
|
|
// Read-only system methods (explicit allowlist — NOT prefix "system."
|
|
|
|
|
// which would grant access to system.factory-reset, system.shutdown, etc.)
|
|
|
|
|
method == "system.stats"
|
|
|
|
|
|| method == "system.processes"
|
|
|
|
|
|| method == "system.temperature"
|
|
|
|
|
|| method == "system.disk-status"
|
|
|
|
|
|| method == "system.detect-usb-devices"
|
security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation
Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)
UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet
Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:44:31 +00:00
|
|
|
|| method == "node.did"
|
|
|
|
|
|| method == "node.tor-address"
|
|
|
|
|
|| method == "node.nostr-pubkey"
|
2026-03-14 05:46:10 +00:00
|
|
|
|| method.starts_with("federation.list")
|
|
|
|
|
|| method.starts_with("dwn.status")
|
|
|
|
|
|| method.starts_with("dwn.list")
|
|
|
|
|
|| method.starts_with("dwn.query")
|
|
|
|
|
|| method.starts_with("identity.list")
|
|
|
|
|
|| method.starts_with("identity.get")
|
|
|
|
|
|| method.starts_with("backup.list")
|
|
|
|
|
|| method == "container-list"
|
|
|
|
|
|| method == "container-status"
|
|
|
|
|
|| method == "container-health"
|
|
|
|
|
|| method == "health"
|
|
|
|
|
|| method == "auth.logout"
|
|
|
|
|
}
|
|
|
|
|
UserRole::AppUser => {
|
|
|
|
|
// App access + basic read
|
|
|
|
|
method.starts_with("system.stats")
|
|
|
|
|
|| method == "node.did"
|
|
|
|
|
|| method == "container-list"
|
|
|
|
|
|| method == "container-status"
|
|
|
|
|
|| method == "health"
|
|
|
|
|
|| method == "auth.logout"
|
|
|
|
|
|| method == "auth.changePassword"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 15:03:34 +00:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
struct OnboardingState {
|
|
|
|
|
complete: bool,
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 22:59:20 +00:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct User {
|
|
|
|
|
pub password_hash: String,
|
|
|
|
|
pub setup_complete: bool,
|
|
|
|
|
pub onboarding_complete: bool,
|
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>
2026-03-06 12:23:57 +00:00
|
|
|
#[serde(default)]
|
|
|
|
|
pub totp: Option<TotpData>,
|
2026-03-14 05:46:10 +00:00
|
|
|
/// User role for RBAC (defaults to Admin for backward compatibility)
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub role: UserRole,
|
2026-01-24 22:59:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct AuthManager {
|
|
|
|
|
data_dir: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AuthManager {
|
|
|
|
|
pub fn new(data_dir: PathBuf) -> Self {
|
|
|
|
|
Self { data_dir }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn is_setup(&self) -> Result<bool> {
|
|
|
|
|
let user_file = self.data_dir.join("user.json");
|
|
|
|
|
Ok(user_file.exists())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_user(&self) -> Result<Option<User>> {
|
|
|
|
|
let user_file = self.data_dir.join("user.json");
|
|
|
|
|
if !user_file.exists() {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let content = fs::read_to_string(&user_file).await?;
|
|
|
|
|
let user: User = serde_json::from_str(&content)?;
|
|
|
|
|
Ok(Some(user))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn setup_user(&self, password: &str) -> Result<()> {
|
2026-03-18 22:41:23 +00:00
|
|
|
let password_hash = argon2id_hash(password)?;
|
2026-02-17 15:03:34 +00:00
|
|
|
|
|
|
|
|
// If onboarding was already completed (before setup), preserve that
|
|
|
|
|
let onboarding_complete = self.is_onboarding_complete().await?;
|
|
|
|
|
|
2026-01-24 22:59:20 +00:00
|
|
|
let user = User {
|
|
|
|
|
password_hash,
|
|
|
|
|
setup_complete: true,
|
2026-02-17 15:03:34 +00:00
|
|
|
onboarding_complete,
|
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>
2026-03-06 12:23:57 +00:00
|
|
|
totp: None,
|
2026-03-14 05:49:52 +00:00
|
|
|
role: UserRole::default(),
|
2026-01-24 22:59:20 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let user_file = self.data_dir.join("user.json");
|
|
|
|
|
let content = serde_json::to_string_pretty(&user)?;
|
|
|
|
|
fs::write(&user_file, content).await?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn complete_onboarding(&self) -> Result<()> {
|
2026-02-17 15:03:34 +00:00
|
|
|
// Persist to onboarding.json (works even before user/setup exists)
|
|
|
|
|
let onboarding_file = self.data_dir.join("onboarding.json");
|
|
|
|
|
let state = OnboardingState { complete: true };
|
|
|
|
|
fs::write(
|
|
|
|
|
&onboarding_file,
|
|
|
|
|
serde_json::to_string_pretty(&state)?,
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
// Also update user.json if it exists (keeps them in sync)
|
2026-01-24 22:59:20 +00:00
|
|
|
if let Some(mut user) = self.get_user().await? {
|
|
|
|
|
user.onboarding_complete = true;
|
|
|
|
|
let user_file = self.data_dir.join("user.json");
|
|
|
|
|
let content = serde_json::to_string_pretty(&user)?;
|
2026-03-02 08:34:13 +00:00
|
|
|
fs::write(&user_file, content).await?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Reset onboarding state so the user can go through onboarding again (dev/testing).
|
|
|
|
|
pub async fn reset_onboarding(&self) -> Result<()> {
|
|
|
|
|
let onboarding_file = self.data_dir.join("onboarding.json");
|
|
|
|
|
let state = OnboardingState { complete: false };
|
|
|
|
|
fs::write(
|
|
|
|
|
&onboarding_file,
|
|
|
|
|
serde_json::to_string_pretty(&state)?,
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
if let Some(mut user) = self.get_user().await? {
|
|
|
|
|
user.onboarding_complete = false;
|
|
|
|
|
let user_file = self.data_dir.join("user.json");
|
|
|
|
|
let content = serde_json::to_string_pretty(&user)?;
|
2026-01-24 22:59:20 +00:00
|
|
|
fs::write(&user_file, content).await?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 15:03:34 +00:00
|
|
|
pub async fn is_onboarding_complete(&self) -> Result<bool> {
|
|
|
|
|
// Check onboarding.json first (persisted before user setup)
|
|
|
|
|
let onboarding_file = self.data_dir.join("onboarding.json");
|
|
|
|
|
if onboarding_file.exists() {
|
|
|
|
|
let content = fs::read_to_string(&onboarding_file).await?;
|
|
|
|
|
if let Ok(state) = serde_json::from_str::<OnboardingState>(&content) {
|
|
|
|
|
if state.complete {
|
|
|
|
|
return Ok(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Fallback: user.json
|
|
|
|
|
Ok(self
|
|
|
|
|
.get_user()
|
|
|
|
|
.await?
|
|
|
|
|
.map(|u| u.onboarding_complete)
|
|
|
|
|
.unwrap_or(false))
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-03-06 12:23:57 +00:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 22:59:20 +00:00
|
|
|
pub async fn verify_password(&self, password: &str) -> Result<bool> {
|
|
|
|
|
if let Some(user) = self.get_user().await? {
|
2026-03-18 22:41:23 +00:00
|
|
|
// Detect hash format and verify accordingly
|
|
|
|
|
if user.password_hash.starts_with("$2") {
|
|
|
|
|
// Legacy bcrypt hash — verify then auto-upgrade to Argon2id
|
|
|
|
|
let valid = bcrypt::verify(password, &user.password_hash)?;
|
|
|
|
|
if valid {
|
|
|
|
|
// Transparent upgrade: re-hash with Argon2id on successful login
|
|
|
|
|
let new_hash = argon2id_hash(password)?;
|
|
|
|
|
let mut upgraded = user.clone();
|
|
|
|
|
upgraded.password_hash = new_hash;
|
|
|
|
|
let user_file = self.data_dir.join("user.json");
|
|
|
|
|
fs::write(&user_file, serde_json::to_string_pretty(&upgraded)?).await?;
|
|
|
|
|
tracing::info!("Upgraded password hash from bcrypt to Argon2id");
|
|
|
|
|
}
|
|
|
|
|
Ok(valid)
|
|
|
|
|
} else {
|
|
|
|
|
// Argon2id hash (PHC string format: $argon2id$...)
|
|
|
|
|
Ok(argon2id_verify(password, &user.password_hash))
|
|
|
|
|
}
|
2026-01-24 22:59:20 +00:00
|
|
|
} else {
|
|
|
|
|
Ok(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-17 15:03:34 +00:00
|
|
|
|
|
|
|
|
/// Change password: verify current, validate new, update user.json and optionally SSH.
|
|
|
|
|
/// New password must be 12+ chars with upper, lower, digit, and special character.
|
|
|
|
|
pub async fn change_password(
|
|
|
|
|
&self,
|
|
|
|
|
current_password: &str,
|
|
|
|
|
new_password: &str,
|
|
|
|
|
also_change_ssh: bool,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
if !self.verify_password(current_password).await? {
|
|
|
|
|
anyhow::bail!("Current password is incorrect");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
validate_password_strength(new_password)?;
|
|
|
|
|
|
2026-03-18 22:41:23 +00:00
|
|
|
let password_hash = argon2id_hash(new_password)?;
|
2026-02-17 15:03:34 +00:00
|
|
|
|
|
|
|
|
let mut user = self
|
|
|
|
|
.get_user()
|
|
|
|
|
.await?
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
|
|
|
|
|
|
|
|
|
|
user.password_hash = password_hash;
|
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>
2026-03-06 12:23:57 +00:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 15:03:34 +00:00
|
|
|
let user_file = self.data_dir.join("user.json");
|
|
|
|
|
let content = serde_json::to_string_pretty(&user)?;
|
|
|
|
|
fs::write(&user_file, content).await?;
|
|
|
|
|
|
|
|
|
|
if also_change_ssh {
|
|
|
|
|
change_ssh_password(new_password).await?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate password strength: 12+ chars, upper, lower, digit, special.
|
|
|
|
|
fn validate_password_strength(password: &str) -> Result<()> {
|
|
|
|
|
if password.len() < 12 {
|
|
|
|
|
anyhow::bail!("Password must be at least 12 characters");
|
|
|
|
|
}
|
|
|
|
|
if !password.chars().any(|c| c.is_ascii_uppercase()) {
|
|
|
|
|
anyhow::bail!("Password must contain at least one uppercase letter");
|
|
|
|
|
}
|
|
|
|
|
if !password.chars().any(|c| c.is_ascii_lowercase()) {
|
|
|
|
|
anyhow::bail!("Password must contain at least one lowercase letter");
|
|
|
|
|
}
|
|
|
|
|
if !password.chars().any(|c| c.is_ascii_digit()) {
|
|
|
|
|
anyhow::bail!("Password must contain at least one digit");
|
|
|
|
|
}
|
|
|
|
|
if !password.chars().any(|c| !c.is_ascii_alphanumeric()) {
|
|
|
|
|
anyhow::bail!("Password must contain at least one special character (!@#$%^&* etc.)");
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 23:54:14 +00:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_setup_user_and_verify_password() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let auth = AuthManager::new(dir.path().to_path_buf());
|
|
|
|
|
|
|
|
|
|
assert!(!auth.is_setup().await.unwrap());
|
|
|
|
|
|
|
|
|
|
auth.setup_user("password123").await.unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(auth.is_setup().await.unwrap());
|
|
|
|
|
assert!(auth.verify_password("password123").await.unwrap());
|
|
|
|
|
assert!(!auth.verify_password("wrong").await.unwrap());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_verify_password_no_user() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let auth = AuthManager::new(dir.path().to_path_buf());
|
|
|
|
|
|
|
|
|
|
assert!(!auth.verify_password("anything").await.unwrap());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_onboarding_lifecycle() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let auth = AuthManager::new(dir.path().to_path_buf());
|
|
|
|
|
|
|
|
|
|
assert!(!auth.is_onboarding_complete().await.unwrap());
|
|
|
|
|
|
|
|
|
|
auth.complete_onboarding().await.unwrap();
|
|
|
|
|
assert!(auth.is_onboarding_complete().await.unwrap());
|
|
|
|
|
|
|
|
|
|
auth.reset_onboarding().await.unwrap();
|
|
|
|
|
assert!(!auth.is_onboarding_complete().await.unwrap());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_onboarding_persists_to_user() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let auth = AuthManager::new(dir.path().to_path_buf());
|
|
|
|
|
|
|
|
|
|
auth.setup_user("password123").await.unwrap();
|
|
|
|
|
let user = auth.get_user().await.unwrap().unwrap();
|
|
|
|
|
assert!(!user.onboarding_complete);
|
|
|
|
|
|
|
|
|
|
auth.complete_onboarding().await.unwrap();
|
|
|
|
|
let user = auth.get_user().await.unwrap().unwrap();
|
|
|
|
|
assert!(user.onboarding_complete);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_validate_password_strength_valid() {
|
|
|
|
|
assert!(validate_password_strength("MyP@ssw0rd!123").is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_validate_password_strength_too_short() {
|
|
|
|
|
assert!(validate_password_strength("Ab1!").is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_validate_password_strength_no_uppercase() {
|
|
|
|
|
assert!(validate_password_strength("mypassword1!xx").is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_validate_password_strength_no_digit() {
|
|
|
|
|
assert!(validate_password_strength("MyPassword!!xx").is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_validate_password_strength_no_special() {
|
|
|
|
|
assert!(validate_password_strength("MyPassword1234").is_err());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 15:03:34 +00:00
|
|
|
/// Change the archipelago user's SSH/login password.
|
|
|
|
|
/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors).
|
|
|
|
|
/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH.
|
|
|
|
|
async fn change_ssh_password(new_password: &str) -> Result<()> {
|
|
|
|
|
let ssh_user = std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
|
|
|
|
|
|
|
|
|
|
// Generate crypt hash via openssl (SHA-512, compatible with /etc/shadow)
|
|
|
|
|
// Use /usr/bin/openssl - systemd services often have minimal PATH
|
|
|
|
|
let mut hash_child = tokio::process::Command::new("/usr/bin/openssl")
|
|
|
|
|
.args(["passwd", "-6", "-stdin"])
|
|
|
|
|
.stdin(std::process::Stdio::piped())
|
|
|
|
|
.stdout(std::process::Stdio::piped())
|
|
|
|
|
.stderr(std::process::Stdio::piped())
|
|
|
|
|
.spawn()
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("Failed to run openssl: {}. Is openssl installed?", e))?;
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
|
let mut stdin = hash_child
|
|
|
|
|
.stdin
|
|
|
|
|
.take()
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("Failed to open openssl stdin"))?;
|
|
|
|
|
stdin.write_all(new_password.as_bytes()).await?;
|
|
|
|
|
stdin.flush().await?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let hash_result = hash_child.wait_with_output().await?;
|
|
|
|
|
if !hash_result.status.success() {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&hash_result.stderr);
|
|
|
|
|
anyhow::bail!("openssl passwd failed: {}", stderr);
|
|
|
|
|
}
|
|
|
|
|
let hash = String::from_utf8(hash_result.stdout)?
|
|
|
|
|
.trim()
|
|
|
|
|
.to_string();
|
|
|
|
|
if hash.is_empty() {
|
|
|
|
|
anyhow::bail!("openssl passwd produced empty hash");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// usermod -p writes directly to /etc/shadow, bypassing PAM
|
|
|
|
|
// Use /usr/sbin/usermod - not always in systemd's PATH
|
|
|
|
|
let status = tokio::process::Command::new("/usr/sbin/usermod")
|
|
|
|
|
.args(["-p", &hash, &ssh_user])
|
|
|
|
|
.output()
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
if !status.status.success() {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&status.stderr);
|
|
|
|
|
anyhow::bail!("usermod failed: {}", stderr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tracing::info!("SSH password updated for user {}", ssh_user);
|
|
|
|
|
Ok(())
|
2026-01-24 22:59:20 +00:00
|
|
|
}
|
2026-03-18 22:41:23 +00:00
|
|
|
|
|
|
|
|
/// Hash a password with Argon2id (memory-hard, GPU/ASIC resistant).
|
|
|
|
|
/// Uses PHC string format ($argon2id$v=19$m=65536,t=3,p=4$...) for self-describing storage.
|
|
|
|
|
fn argon2id_hash(password: &str) -> Result<String> {
|
|
|
|
|
use argon2::{Argon2, Params, PasswordHasher};
|
|
|
|
|
use argon2::password_hash::SaltString;
|
|
|
|
|
use rand::rngs::OsRng;
|
|
|
|
|
|
|
|
|
|
let salt = SaltString::generate(&mut OsRng);
|
|
|
|
|
let params = Params::new(65536, 3, 4, Some(32))
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("Invalid Argon2 params: {}", e))?;
|
|
|
|
|
let hasher = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
|
|
|
|
let hash = hasher
|
|
|
|
|
.hash_password(password.as_bytes(), &salt)
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("Argon2id hash failed: {}", e))?;
|
|
|
|
|
Ok(hash.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Verify a password against an Argon2id PHC string hash.
|
|
|
|
|
fn argon2id_verify(password: &str, hash: &str) -> bool {
|
|
|
|
|
use argon2::{Argon2, PasswordVerifier};
|
|
|
|
|
use argon2::password_hash::PasswordHash;
|
|
|
|
|
|
|
|
|
|
let parsed = match PasswordHash::new(hash) {
|
|
|
|
|
Ok(h) => h,
|
|
|
|
|
Err(_) => return false,
|
|
|
|
|
};
|
|
|
|
|
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
|
|
|
|
|
}
|