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

34
BACKLOG.md Normal file
View File

@ -0,0 +1,34 @@
# Archipelago Backlog
## Node Discovery & Spatial Map (Alpha Demo Feature)
**Priority:** High (needed for live alpha demo)
### "Find Nodes" — Spatial Node Discovery
Add a "Find Nodes" button to the Messages tab that opens a modal with an interactive spatial node map.
**Requirements:**
- Visual spatial map showing discovered Archipelago nodes
- Each node displays its self-chosen name (pseudonym)
- Connection request flow: discover → request → peer approves → connected
- Optional locality broadcasting (toggle: share general area or stay anonymous)
- Cool, visual, presentation-worthy UI for live alpha demo
**Onboarding Addition:**
- Add "Name your node" step during setup/onboarding
- Include privacy guidance: "Use a pseudonym if you want privacy"
- Node name is broadcast on the discovery network
**Technical Notes:**
- Builds on existing Nostr-based node discovery (`node-nostr-discover` RPC)
- Existing peer system: `node-add-peer`, `node-remove-peer`, `node-list-peers`
- Need to add: connection request/approval flow (currently peers are added directly)
- Spatial visualization could use force-directed graph or map-based layout
- Locality data is optional and coarse-grained (city/region level, never precise)
---
## Settings (TBD)
*User mentioned settings changes needed — details to be clarified.*

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,8 +260,38 @@ 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 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",
@ -251,6 +300,10 @@ impl RpcHandler {
.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" {

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

View File

@ -0,0 +1,758 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Archipelago AI Quarantine Architecture</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--surface-2: #1c2333;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #fb923c;
--green: #4ade80;
--red: #ef4444;
--blue: #58a6ff;
--purple: #bc8cff;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.7;
padding: 2rem;
max-width: 1100px;
margin: 0 auto;
}
h1 {
font-size: 2.2rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, var(--accent), #f59e0b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
color: var(--text-muted);
font-size: 1.1rem;
margin-bottom: 2.5rem;
border-bottom: 1px solid var(--border);
padding-bottom: 1.5rem;
}
h2 {
font-size: 1.5rem;
margin: 2.5rem 0 1rem;
color: var(--accent);
display: flex;
align-items: center;
gap: 0.5rem;
}
h2 .num {
background: var(--accent);
color: var(--bg);
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
font-weight: 700;
flex-shrink: 0;
}
h3 {
font-size: 1.15rem;
margin: 1.5rem 0 0.5rem;
color: var(--blue);
}
p { margin-bottom: 1rem; color: var(--text); }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
margin: 1rem 0;
}
.card-green { border-left: 4px solid var(--green); }
.card-red { border-left: 4px solid var(--red); }
.card-blue { border-left: 4px solid var(--blue); }
.card-orange { border-left: 4px solid var(--accent); }
.card-purple { border-left: 4px solid var(--purple); }
.card h4 {
font-size: 1rem;
margin-bottom: 0.5rem;
}
code {
background: var(--surface-2);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.88rem;
color: var(--accent);
}
pre {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.25rem;
overflow-x: auto;
margin: 1rem 0;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
pre code { background: none; padding: 0; color: inherit; }
.label {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.label-green { background: rgba(74, 222, 128, 0.15); color: var(--green); }
.label-red { background: rgba(239, 68, 68, 0.15); color: var(--red); }
.label-blue { background: rgba(88, 166, 255, 0.15); color: var(--blue); }
.label-orange { background: rgba(251, 146, 60, 0.15); color: var(--accent); }
ul { margin: 0.5rem 0 1rem 1.5rem; }
li { margin-bottom: 0.4rem; }
li code { font-size: 0.82rem; }
.diagram {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.82rem;
line-height: 1.8;
white-space: pre;
overflow-x: auto;
color: var(--text-muted);
}
.diagram .highlight { color: var(--accent); font-weight: 600; }
.diagram .green { color: var(--green); }
.diagram .red { color: var(--red); }
.diagram .blue { color: var(--blue); }
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-size: 0.9rem;
}
th {
background: var(--surface-2);
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 2px solid var(--border);
color: var(--accent);
font-weight: 600;
}
td {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
tr:hover td { background: rgba(251, 146, 60, 0.03); }
.flow-arrow {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1rem 0;
flex-wrap: wrap;
}
.flow-box {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 1rem;
font-size: 0.85rem;
font-weight: 500;
}
.flow-box.secure {
border-color: var(--green);
color: var(--green);
}
.flow-box.blocked {
border-color: var(--red);
color: var(--red);
}
.arrow { color: var(--text-muted); font-size: 1.2rem; }
.toc {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
}
.toc h3 { margin-top: 0; color: var(--text); }
.toc ol { margin-left: 1.5rem; }
.toc li { margin-bottom: 0.3rem; }
.toc a { color: var(--blue); text-decoration: none; }
.toc a:hover { text-decoration: underline; }
.files-ref {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 0.5rem;
}
.files-ref code {
color: var(--text-muted);
font-size: 0.8rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
margin: 1.5rem 0;
}
@media (max-width: 600px) {
body { padding: 1rem; }
h1 { font-size: 1.6rem; }
pre { font-size: 0.78rem; padding: 0.75rem; }
.diagram { font-size: 0.7rem; padding: 1rem; }
}
</style>
</head>
<body>
<h1>Archipelago AI Quarantine Architecture</h1>
<p class="subtitle">How AIUI (Claude) is sandboxed from your node's sensitive data &mdash; a defense-in-depth approach across 6 layers</p>
<div class="toc">
<h3>Contents</h3>
<ol>
<li><a href="#overview">Architecture Overview &amp; Diagram</a></li>
<li><a href="#layer1">Layer 1: Container Isolation (Podman)</a></li>
<li><a href="#layer2">Layer 2: Iframe Sandbox (Browser)</a></li>
<li><a href="#layer3">Layer 3: postMessage Gate (Context Broker)</a></li>
<li><a href="#layer4">Layer 4: Per-Category Permissions (User Toggles)</a></li>
<li><a href="#layer5">Layer 5: Data Sanitization (Field Stripping)</a></li>
<li><a href="#layer6">Layer 6: Proxy &amp; Nginx Authentication</a></li>
<li><a href="#protocol">The postMessage Protocol</a></li>
<li><a href="#context">What the AI System Prompt Sees</a></li>
<li><a href="#never">What the AI Can NEVER See</a></li>
<li><a href="#actions">Permitted Actions (Limited)</a></li>
<li><a href="#bugs">Current Bugs &amp; Issues</a></li>
<li><a href="#files">Source File Reference</a></li>
</ol>
</div>
<!-- ───────────────── OVERVIEW ───────────────── -->
<h2 id="overview"><span class="num">0</span> Architecture Overview</h2>
<p>The AI is treated as <strong>untrusted code in a hostile environment</strong>. It runs inside an iframe with sandbox restrictions, inside a Podman container with no outbound network. All data it receives passes through a <strong>Context Broker</strong> that checks user permissions and strips sensitive fields before anything reaches Claude's API.</p>
<div class="diagram"><span class="highlight">User's Browser</span>
┌─────────────────────────────────────────────────────┐
<span class="blue">Archy (neode-ui)</span> — Vue.js Host Application │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ <span class="green">Context Broker</span> │ │
│ │ - Checks aiPermissions store │ │
│ │ - Validates postMessage origin │ │
│ │ - Fetches data from Pinia stores / RPC │ │
│ │ - <span class="red">Strips sensitive fields</span> (sanitize*) │ │
│ │ - Returns only permitted, sanitized data │ │
│ └──────────────┬────────────────────────────────┘ │
│ │ postMessage (origin-validated) │
│ ┌──────────────▼────────────────────────────────┐ │
│ │ <span class="highlight">AIUI iframe</span> │ │
│ │ sandbox="allow-scripts allow-same-origin │ │
│ │ allow-forms" │ │
│ │ │ │
│ │ <span class="green">archyBridge</span> ──postMessage──▶ Context Broker │ │
│ │ <span class="red">✗ Cannot</span> call /rpc/ directly │ │
│ │ <span class="red">✗ Cannot</span> access host DOM │ │
│ │ <span class="red">✗ Cannot</span> open popups │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
│ HTTPS (session cookie required)
┌──────────────────────────────────┐
<span class="blue">Nginx</span> (/aiui/api/claude/) │ ◀── cookie check gate
│ proxy_pass → 127.0.0.1:3141 │
└──────────────┬───────────────────┘
┌──────────────────────────────────┐
<span class="highlight">Claude Proxy</span> (port 3141) │
│ OAuth token from macOS keychain │
│ → Anthropic API │
└──────────────────────────────────┘
<span class="red">BLOCKED paths</span> (AI cannot reach):
✗ /rpc/ (backend API) ✗ Container exec
✗ /ws (WebSocket) ✗ File system
✗ SSH ✗ Outbound network (from container)</div>
<!-- ───────────────── LAYER 1 ───────────────── -->
<h2 id="layer1"><span class="num">1</span> Layer 1: Container Isolation (Podman)</h2>
<div class="card card-green">
<h4>AIUI runs in a locked-down Podman container</h4>
<p>Even if the AIUI web app were compromised, the container itself has no way to reach the rest of the system.</p>
</div>
<pre><code># apps/aiui/manifest.yml
security:
capabilities: [] # No Linux capabilities at all
readonly_root: true # Read-only filesystem
no_new_privileges: true # Cannot escalate privileges
network_policy: isolated # NO outbound network access
ports:
- host: 5180
container: 80
bind: 127.0.0.1 # Only reachable via nginx, not externally</code></pre>
<p><strong>What this means:</strong></p>
<ul>
<li>The AIUI container <strong>cannot make HTTP requests to the internet</strong> or to other containers</li>
<li>It serves static files only &mdash; the actual Claude API calls happen in the <em>browser</em>, not the container</li>
<li>Even with root access in the container, you can't escalate or modify the filesystem</li>
<li>The container port (5180) is bound to <code>127.0.0.1</code>, so only nginx (on the same machine) can reach it</li>
</ul>
<!-- ───────────────── LAYER 2 ───────────────── -->
<h2 id="layer2"><span class="num">2</span> Layer 2: Iframe Sandbox (Browser)</h2>
<div class="card card-blue">
<h4>AIUI loads inside a sandboxed iframe</h4>
<p>The browser enforces strict boundaries between the host Archy app and the AIUI iframe.</p>
</div>
<pre><code>&lt;!-- neode-ui/src/views/Chat.vue --&gt;
&lt;iframe
:src="aiuiUrl"
sandbox="allow-scripts allow-same-origin allow-forms"
allow="microphone"
/&gt;</code></pre>
<p><strong>The sandbox attribute restricts AIUI from:</strong></p>
<ul>
<li><strong>Navigating the parent page</strong> &mdash; cannot redirect Archy</li>
<li><strong>Opening popups/new windows</strong> &mdash; <code>allow-popups</code> is NOT granted</li>
<li><strong>Accessing parent DOM</strong> &mdash; cross-origin isolation is enforced</li>
<li><strong>Submitting forms to external URLs</strong> &mdash; forms are scoped to same origin</li>
<li><strong>Running plugins</strong> &mdash; no plugin execution</li>
</ul>
<p>The only communication channel is <code>window.postMessage()</code>, which is intercepted by the Context Broker.</p>
<!-- ───────────────── LAYER 3 ───────────────── -->
<h2 id="layer3"><span class="num">3</span> Layer 3: The Context Broker (postMessage Gate)</h2>
<div class="card card-orange">
<h4>Every data request goes through a single gatekeeper</h4>
<p>The <code>ContextBroker</code> class validates origin, checks permissions, fetches data, strips sensitive fields, then responds. AIUI never directly calls any backend API.</p>
</div>
<h3>How it works</h3>
<div class="flow-arrow">
<div class="flow-box">AIUI sends<br><code>context:request</code></div>
<span class="arrow">&rarr;</span>
<div class="flow-box secure">Origin validated<br><code>event.origin === allowedOrigin</code></div>
<span class="arrow">&rarr;</span>
<div class="flow-box secure">Permission checked<br><code>perms.isEnabled(category)</code></div>
<span class="arrow">&rarr;</span>
<div class="flow-box secure">Data fetched &amp;<br>sanitized</div>
<span class="arrow">&rarr;</span>
<div class="flow-box">Response sent<br>to iframe</div>
</div>
<pre><code>// contextBroker.ts — the critical permission check
private async handleContextRequest(id, category, query?) {
const perms = useAIPermissionsStore()
if (!perms.isEnabled(category)) {
// DENIED — send empty response, no data
this.postToIframe({
type: 'context:response', id,
data: null,
permitted: false, // ← AIUI knows it was denied
})
return
}
// ALLOWED — fetch and sanitize before sending
const data = await this.fetchAndSanitize(category, query)
this.postToIframe({
type: 'context:response', id,
data, // ← sanitized data only
permitted: true,
})
}</code></pre>
<h3>Origin Validation (both sides)</h3>
<ul>
<li><strong>Context Broker</strong> (host): Rejects any message where <code>event.origin !== this.allowedOrigin</code></li>
<li><strong>archyBridge</strong> (AIUI): Rejects any message where <code>event.origin !== allowedOrigin</code></li>
<li><strong>Responses</strong> use explicit target origin: <code>iframe.contentWindow.postMessage(msg, this.allowedOrigin)</code></li>
</ul>
<!-- ───────────────── LAYER 4 ───────────────── -->
<h2 id="layer4"><span class="num">4</span> Layer 4: Per-Category Permission Toggles</h2>
<div class="card card-purple">
<h4>All categories are OFF by default</h4>
<p>The user must explicitly enable each data category in Settings &rarr; AI Data Access. The AI sees nothing until you flip the switch.</p>
</div>
<table>
<thead>
<tr>
<th>Category</th>
<th>What AI Sees</th>
<th>What's Stripped</th>
<th>Default</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>apps</code><br><span class="label label-blue">Installed Apps</span></td>
<td>App names, versions, running state, URLs</td>
<td>Config files, env vars, credentials</td>
<td><span class="label label-red">OFF</span></td>
</tr>
<tr>
<td><code>system</code><br><span class="label label-blue">System Stats</span></td>
<td>CPU %, RAM used/total, disk used/total, uptime</td>
<td>File paths, IP addresses, hostnames, PIDs</td>
<td><span class="label label-red">OFF</span></td>
</tr>
<tr>
<td><code>network</code><br><span class="label label-blue">Network Status</span></td>
<td>Connected (bool), Tor active (bool), Tailscale active (bool)</td>
<td>IP addresses, Tor .onion addresses, peer IPs, MAC addresses</td>
<td><span class="label label-red">OFF</span></td>
</tr>
<tr>
<td><code>bitcoin</code><br><span class="label label-orange">Bitcoin Node</span></td>
<td>Block height, sync %, chain, difficulty, mempool size/count</td>
<td>Wallet keys, addresses, transaction history, RPC credentials</td>
<td><span class="label label-red">OFF</span></td>
</tr>
<tr>
<td><code>wallet</code><br><span class="label label-orange">Wallet Overview</span></td>
<td>Alias, channel count, peer count, balance (sats), sync status</td>
<td><strong>Private keys, seed phrases, macaroons, channel secrets, addresses</strong></td>
<td><span class="label label-red">OFF</span></td>
</tr>
<tr>
<td><code>media</code><br><span class="label label-blue">Media Libraries</span></td>
<td>Which media apps are installed (Plex, Jellyfin, etc.) + status</td>
<td>Library contents, file paths, metadata</td>
<td><span class="label label-red">OFF</span></td>
</tr>
<tr>
<td><code>files</code><br><span class="label label-blue">File Names</span></td>
<td>Folder names, recent file names, sizes, dates from Cloud</td>
<td>File contents (unless read-file action is used with permission)</td>
<td><span class="label label-red">OFF</span></td>
</tr>
<tr>
<td><code>notes</code><br><span class="label label-blue">Documents</span></td>
<td>Document titles (currently returns "not available")</td>
<td>Document contents</td>
<td><span class="label label-red">OFF</span></td>
</tr>
<tr>
<td><code>search</code><br><span class="label label-green">Web Search</span></td>
<td>Whether SearXNG is installed + available</td>
<td>N/A</td>
<td><span class="label label-red">OFF</span></td>
</tr>
<tr>
<td><code>ai-local</code><br><span class="label label-green">Local AI</span></td>
<td>Whether Ollama is installed + running</td>
<td>Model details</td>
<td><span class="label label-red">OFF</span></td>
</tr>
</tbody>
</table>
<p class="files-ref">Permissions stored in <code>localStorage</code> key: <code>archipelago-ai-permissions</code></p>
<p class="files-ref">Store: <code>neode-ui/src/stores/aiPermissions.ts</code></p>
<!-- ───────────────── LAYER 5 ───────────────── -->
<h2 id="layer5"><span class="num">5</span> Layer 5: Data Sanitization</h2>
<div class="card card-green">
<h4>Each category has a dedicated sanitize function that extracts only whitelisted fields</h4>
<p>The broker doesn't pass raw data through &mdash; it constructs new objects with only safe properties.</p>
</div>
<h3>Example: Bitcoin sanitization</h3>
<pre><code>// contextBroker.ts — sanitizeBitcoin()
// ONLY these fields are extracted and sent to AI:
return {
available: true,
status: 'running',
block_height: info.block_height,
sync_progress: info.sync_progress,
chain: info.chain,
difficulty: info.difficulty,
mempool_size: info.mempool_size,
mempool_tx_count: info.mempool_tx_count,
verification_progress: info.verification_progress,
}
// NOT included: wallet data, addresses, keys, RPC auth, raw responses</code></pre>
<h3>Example: Wallet sanitization</h3>
<pre><code>// contextBroker.ts — sanitizeWallet()
// ONLY these safe summary fields:
return {
available: true,
status: 'running',
alias: info.alias,
num_active_channels: info.num_active_channels,
num_peers: info.num_peers,
synced_to_chain: info.synced_to_chain,
block_height: info.block_height,
balance_sats: info.balance_sats,
channel_balance_sats: info.channel_balance_sats,
pending_open_balance: info.pending_open_balance,
}
// NEVER included: private keys, seed phrases, macaroons,
// channel points, backup data, node pubkeys</code></pre>
<h3>Example: Network sanitization</h3>
<pre><code>// contextBroker.ts — sanitizeNetwork()
// Only booleans — no addresses:
return {
connected: store.isConnected, // true/false
torConnected: hasTor, // true/false
tailscaleActive: tailscale?.state === 'running', // true/false
}
// NEVER: IP addresses, .onion addresses, peer info, MAC addresses</code></pre>
<!-- ───────────────── LAYER 6 ───────────────── -->
<h2 id="layer6"><span class="num">6</span> Layer 6: Proxy &amp; Nginx Authentication</h2>
<div class="card card-blue">
<h4>Claude API requests require a valid Archy session</h4>
<p>Nginx rejects unauthenticated API calls. The Claude Proxy on port 3141 manages OAuth tokens securely.</p>
</div>
<pre><code># nginx-archipelago.conf
location /aiui/api/claude/ {
if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}'; # No session = blocked
}
proxy_pass http://127.0.0.1:3141/; # → Claude Proxy
}</code></pre>
<p><strong>The Claude Proxy (port 3141):</strong></p>
<ul>
<li>OAuth token stored securely (macOS keychain &rarr; <code>.env.local</code>)</li>
<li>Auto-refreshes tokens 5 minutes before expiry</li>
<li>Never exposes the token to the browser &mdash; the proxy adds auth headers server-side</li>
<li>Only the browser's fetch to <code>/aiui/api/claude/</code> goes through this proxy</li>
</ul>
<p><strong>Content Security Policy (CSP):</strong></p>
<pre><code>Content-Security-Policy: default-src 'self';
connect-src 'self' ws: wss:;
frame-src 'self' http://127.0.0.1:* http://localhost:*;</code></pre>
<p>The CSP restricts the AIUI iframe to only connect to the same origin and local addresses. No external fetch calls are possible.</p>
<!-- ───────────────── PROTOCOL ───────────────── -->
<h2 id="protocol"><span class="num">7</span> The postMessage Protocol</h2>
<p>AIUI and Archy communicate via a strictly-typed protocol defined in <code>neode-ui/src/types/aiui-protocol.ts</code>.</p>
<h3>AIUI &rarr; Archy (Requests)</h3>
<table>
<thead><tr><th>Message Type</th><th>Purpose</th><th>Fields</th></tr></thead>
<tbody>
<tr><td><code>ready</code></td><td>Signals iframe is loaded</td><td>None</td></tr>
<tr><td><code>context:request</code></td><td>Request node data</td><td><code>id</code>, <code>category</code>, <code>query?</code></td></tr>
<tr><td><code>action:request</code></td><td>Request an action</td><td><code>id</code>, <code>action</code>, <code>params</code></td></tr>
<tr><td><code>theme:request</code></td><td>Request UI theme</td><td>None</td></tr>
</tbody>
</table>
<h3>Archy &rarr; AIUI (Responses)</h3>
<table>
<thead><tr><th>Message Type</th><th>Purpose</th><th>Fields</th></tr></thead>
<tbody>
<tr><td><code>context:response</code></td><td>Sanitized data or denial</td><td><code>id</code>, <code>data</code>, <code>permitted</code> (bool)</td></tr>
<tr><td><code>action:response</code></td><td>Action result</td><td><code>id</code>, <code>success</code>, <code>error?</code>, <code>data?</code></td></tr>
<tr><td><code>permissions:update</code></td><td>Push new permissions</td><td><code>categories[]</code></td></tr>
<tr><td><code>theme:response</code></td><td>Theme colors</td><td><code>theme { accent, mode }</code></td></tr>
</tbody>
</table>
<!-- ───────────────── CONTEXT ───────────────── -->
<h2 id="context"><span class="num">8</span> What the AI System Prompt Sees</h2>
<p>The <code>buildArchyContext()</code> function in AIUI constructs a context string that gets appended to Claude's system prompt. It only includes data for <strong>permitted categories</strong>:</p>
<pre><code>// Example output when apps + bitcoin + wallet are enabled:
**Archy Node Context** (this user is running AIUI on their Archipelago node):
**Installed apps on this node:**
- Bitcoin Knots (installed, running)
- LND (installed, running)
- Mempool (installed, running)
- File Browser (installed, running)
**Bitcoin:** Block 890,123, 99.99% synced, mainnet, mempool: 42,815 txs
**Lightning (LND):** MyNode | 5 channels | 3 peers | On-chain: 150,000 sats
You can help the user manage their node. Available actions: open an app
(open-app), install an app (install-app), navigate in Archy (navigate).</code></pre>
<div class="card card-red">
<h4>What's NOT in the system prompt &mdash; ever</h4>
<ul>
<li>Private keys, seed phrases, HD derivation paths</li>
<li>Macaroons, auth tokens, API keys</li>
<li>IP addresses (.onion, LAN, WAN, Tailscale)</li>
<li>File contents, log contents</li>
<li>SSH credentials, RPC passwords</li>
<li>Transaction history, UTXO set, address lists</li>
<li>Container configs, environment variables</li>
</ul>
</div>
<!-- ───────────────── NEVER ───────────────── -->
<h2 id="never"><span class="num">9</span> What the AI Can NEVER See</h2>
<div class="summary-grid">
<div class="card card-red">
<h4>Cryptographic Material</h4>
<ul>
<li>Private keys (BTC, LN)</li>
<li>Seed phrases / BIP39 mnemonics</li>
<li>LND macaroons</li>
<li>Channel backup data</li>
<li>HD derivation paths</li>
</ul>
</div>
<div class="card card-red">
<h4>Network Identity</h4>
<ul>
<li>IP addresses (LAN, WAN)</li>
<li>Tor .onion addresses</li>
<li>Tailscale IPs</li>
<li>Peer connection details</li>
<li>MAC addresses</li>
</ul>
</div>
<div class="card card-red">
<h4>Credentials</h4>
<ul>
<li>SSH passwords / keys</li>
<li>RPC usernames/passwords</li>
<li>API tokens</li>
<li>Session cookies</li>
<li>OAuth tokens</li>
</ul>
</div>
<div class="card card-red">
<h4>Sensitive Data</h4>
<ul>
<li>Transaction history</li>
<li>Bitcoin addresses (receive/change)</li>
<li>UTXO set</li>
<li>File contents (unless explicitly permitted)</li>
<li>Environment variables</li>
</ul>
</div>
</div>
<!-- ───────────────── ACTIONS ───────────────── -->
<h2 id="actions"><span class="num">10</span> Permitted Actions</h2>
<p>The AI can request a limited set of actions through the Context Broker. Each action is validated and requires the relevant permission category to be enabled.</p>
<table>
<thead><tr><th>Action</th><th>What It Does</th><th>Requires Permission</th></tr></thead>
<tbody>
<tr><td><code>open-app</code></td><td>Dispatches event to open an installed app</td><td><em>None (navigation)</em></td></tr>
<tr><td><code>navigate</code></td><td>Navigate to a path within Archy UI</td><td><em>None (navigation)</em></td></tr>
<tr><td><code>install-app</code></td><td>Installs an app from marketplace</td><td><em>None</em></td></tr>
<tr><td><code>search-web</code></td><td>Searches via local SearXNG instance</td><td><code>search</code></td></tr>
<tr><td><code>read-file</code></td><td>Reads a file from FileBrowser (Cloud)</td><td><code>files</code></td></tr>
<tr><td><code>tail-logs</code></td><td>Gets recent log lines for an app</td><td><code>apps</code></td></tr>
</tbody>
</table>
<div class="card card-red">
<h4>Actions the AI CANNOT perform</h4>
<ul>
<li>Execute shell commands</li>
<li>Call backend RPC endpoints directly</li>
<li>Modify container configs</li>
<li>Access the filesystem outside FileBrowser</li>
<li>Send Bitcoin transactions</li>
<li>Open/close Lightning channels</li>
<li>Modify system settings</li>
<li>Access other users' data</li>
</ul>
</div>
<!-- ───────────────── BUGS ───────────────── -->
<h2 id="bugs"><span class="num">11</span> Current Bugs &amp; Issues</h2>
<div class="card card-red">
<h4>"messages.6: user messages must have non-empty content" error</h4>
<p>This Anthropic API 400 error occurs when replying in the chat. The AIUI client is sending a message array where one of the user messages has empty content (likely an empty string or the reply content isn't being properly included in the messages array). This is a bug in the AIUI chat message construction, not a quarantine issue.</p>
</div>
<div class="card card-orange">
<h4>Inconsistent node awareness</h4>
<p>The AI sometimes says "I don't have access to your Bitcoin node" even though Bitcoin data may be permitted. This happens because:</p>
<ul>
<li>The <code>bitcoin.getinfo</code> RPC call may fail (e.g., Bitcoin Knots RPC not configured in the backend)</li>
<li>When the RPC fails, the broker returns a minimal fallback: <code>{ available: true, status: 'running', network: 'mainnet' }</code></li>
<li>The system prompt context then shows limited info, and Claude responds conservatively</li>
<li>The <code>tail-logs</code> action could fetch Bitcoin logs, but Claude may not know to use it</li>
</ul>
</div>
<!-- ───────────────── FILES ───────────────── -->
<h2 id="files"><span class="num">12</span> Source File Reference</h2>
<table>
<thead><tr><th>File</th><th>Role</th></tr></thead>
<tbody>
<tr><td><code>neode-ui/src/services/contextBroker.ts</code></td><td>The quarantine gate &mdash; validates, checks permissions, sanitizes all data</td></tr>
<tr><td><code>neode-ui/src/types/aiui-protocol.ts</code></td><td>Strict TypeScript protocol definition for all messages</td></tr>
<tr><td><code>neode-ui/src/stores/aiPermissions.ts</code></td><td>Pinia store for per-category permission toggles</td></tr>
<tr><td><code>neode-ui/src/views/Chat.vue</code></td><td>Iframe host with sandbox attribute</td></tr>
<tr><td><code>neode-ui/src/views/Settings.vue</code></td><td>AI Data Access toggles UI</td></tr>
<tr><td><code>apps/aiui/manifest.yml</code></td><td>Container security config (isolated network, readonly root)</td></tr>
<tr><td><code>image-recipe/configs/nginx-archipelago.conf</code></td><td>Nginx routes with session cookie auth gate</td></tr>
<tr><td><code>AIUI/packages/app/src/services/archyBridge.ts</code></td><td>AIUI-side postMessage client (the only way AIUI talks to Archy)</td></tr>
<tr><td><code>AIUI/packages/app/src/composables/useArchy.ts</code></td><td>Vue composable wrapping archyBridge + <code>buildArchyContext()</code></td></tr>
</tbody>
</table>
<div class="card card-green" style="margin-top: 2rem;">
<h4>Summary: 6 Layers of Defense</h4>
<ol>
<li><strong>Container</strong> &mdash; Podman with isolated network, read-only FS, zero capabilities</li>
<li><strong>Iframe sandbox</strong> &mdash; Browser-enforced isolation, no popups, no parent DOM access</li>
<li><strong>Context Broker</strong> &mdash; Single postMessage gate with origin validation</li>
<li><strong>Permissions</strong> &mdash; Per-category toggles, all OFF by default</li>
<li><strong>Sanitization</strong> &mdash; Dedicated functions strip sensitive fields per category</li>
<li><strong>Proxy auth</strong> &mdash; Nginx session cookie check + CSP headers</li>
</ol>
<p style="margin-top: 1rem; color: var(--text-muted);">The AI is treated as untrusted. It can only see what you explicitly permit, and even then, sensitive fields are stripped before the data ever reaches Claude's API.</p>
</div>
<p style="text-align: center; color: var(--text-muted); margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border); font-size: 0.85rem;">
Archipelago AI Quarantine Architecture &mdash; Generated 2026-03-06 &mdash; v1.0.0
</p>
</body>
</html>

View File

@ -1,49 +1,102 @@
I now have complete visibility into all affected code. Here is the remediation plan:
# Alpha Release Hardening Plan — Overnight Automation
# Security Fixes — http://192.168.1.228
- [x] **FIX-001** — fix(AUTH-001): add server-side session management to `core/archipelago/src/api/rpc/auth.rs` `handle_auth_login` — on successful password verification, generate a cryptographic random token, hash with SHA-256, store in a server-side session map (`Arc<RwLock<HashMap<HashedToken, Session>>>`), and return it via `Set-Cookie: session=<token>; HttpOnly; SameSite=Strict; Path=/`
- [x] **FIX-002** — fix(AUTH-002): add authentication middleware in `core/archipelago/src/api/handler.rs` `handle_request` and `core/archipelago/src/api/rpc/mod.rs` `handle` — extract and validate session cookie before dispatching to any handler; allowlist only `auth.login`, `auth.isOnboardingComplete`, `health`, and `echo` as unauthenticated; reject all other requests with 401
- [x] **FIX-003** — fix(AUTH-005): update frontend auth in `neode-ui/src/api/rpc-client.ts` to send credentials with requests (`credentials: 'same-origin'`) and store auth state based on server session cookie presence, not just localStorage
- [x] **FIX-004** — fix(AUTH-007): add session cookie validation to WebSocket upgrade in `core/archipelago/src/api/handler.rs` `handle_websocket` (line 42-43) — parse `Cookie` header from the upgrade request, validate the session token against the session store, reject with 401 if invalid
- [x] **FIX-005** — fix(SSRF-004): restrict `dockerImage` in `core/archipelago/src/api/rpc/package.rs` `handle_package_install` (line 28) — replace `is_valid_docker_image` blocklist with an allowlist of trusted registries (`docker.io/`, `ghcr.io/`, `localhost/`) and reject all other image sources
- [x] **FIX-006** — fix(INJ-002): validate `package_id` in `core/archipelago/src/api/rpc/package.rs` `handle_package_uninstall` (line 564-567) and `handle_package_install` (line 15-18) — add `validate_app_id()` helper that enforces `^[a-z0-9][a-z0-9-]{0,63}$` regex; call it before any filesystem or command usage; also apply in `get_data_dirs_for_app` (line 763) and `get_containers_for_app`
- [x] **FIX-007** — fix(AUTH-003): add rate limiting to `core/archipelago/src/api/rpc/auth.rs` `handle_auth_login` — track failed attempts per IP with a sliding window (max 5 failures per 60 seconds); return 429 with `Retry-After` header when exceeded; use `Arc<Mutex<HashMap<IpAddr, Vec<Instant>>>>` in `RpcHandler`
- [x] **FIX-008** — fix(AUTH-008): validate incoming P2P messages in `core/archipelago/src/api/handler.rs` `handle_node_message` (line 125-145) — verify `from_pubkey` is a valid ed25519 public key format (`^[0-9a-f]{64}$`), add an optional HMAC or ed25519 signature field for message authenticity, and rate-limit by source IP
- [x] **FIX-009** — fix(AUTH-009): replace `CORS_ANY` wildcard in `core/archipelago/src/api/handler.rs` (lines 15, 108, 118, 142, 154, 173) — remove the `const CORS_ANY: &str = "*"` constant; set `Access-Control-Allow-Origin` to the node's own origin (from `Config.host_ip` or request `Origin` header validated against an allowlist); add `Vary: Origin` header
- [x] **FIX-010** — fix(AUTH-011): ensure `/proxy/lnd/*` route in `core/archipelago/src/api/handler.rs` `handle_lnd_proxy` (line 67-68) is gated by the session validation middleware added in FIX-002; additionally forward the LND macaroon only from server-side config, never from client headers
- [x] **FIX-011** — fix(XSS-004): add security headers to `image-recipe/configs/nginx-archipelago.conf` in both `server` blocks — add `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, and a baseline `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'` (with `frame-src` exceptions for app iframes)
- [x] **FIX-012** — fix(XSS-007): remove the blanket `Access-Control-Allow-Origin $http_origin` echo pattern from nginx config if present; ensure nginx does not add its own CORS headers that override the backend's restricted CORS (from FIX-009); confirm only same-origin requests are allowed for `/rpc/` and `/ws` locations
- [x] **FIX-013** — fix(SSRF-001): add `validate_onion()` call at the top of `core/archipelago/src/node_message.rs` `check_peer_reachable` (line 115) — currently missing, unlike `send_to_peer` which already validates; this prevents arbitrary host/port injection via the `onion` parameter
- [x] **FIX-014** — fix(SSRF-002): ensure `node-send-message` RPC is behind auth middleware (FIX-002); additionally, in `core/archipelago/src/api/rpc/peers.rs` `handle_node_send_message` (line 50-67), validate that the `onion` address exists in the node's known peer list (`peers::load_peers`) before sending — prevent SSRF to arbitrary Tor destinations
- [x] **FIX-015** — fix(AUTH-006): implement functional logout in `core/archipelago/src/api/rpc/auth.rs` `handle_auth_logout` (line 34-36) — extract session token from request cookie, remove it from the server-side session store, and return a `Set-Cookie` header that expires the cookie
- [x] **FIX-016** — fix(AUTH-012): ensure `/api/container/logs` route in `core/archipelago/src/api/handler.rs` `handle_container_logs_http` (line 64-66) is gated by the session validation middleware added in FIX-002; also validate `app_id` query parameter with `validate_app_id()` from FIX-006
- [x] **FIX-017** — fix(XSS-001): sanitize P2P message content in `core/archipelago/src/node_message.rs` `store_received` (line 40-42) — strip or escape HTML entities (`<`, `>`, `&`, `"`, `'`) from `message` and `from_pubkey` before storing; also ensure the Vue frontend component rendering messages uses `{{ }}` text interpolation (not `v-html`)
- [x] **FIX-018** — fix(INJ-001): validate `manifest_path` in `core/archipelago/src/api/rpc/container.rs` `handle_container_install` (line 17-18) — canonicalize the path and verify it starts with the `apps/` directory under `config.data_dir`; reject paths containing `..` segments; reject absolute paths outside the allowed base
- [x] **FIX-019** — fix(INJ-006): add authentication to `/aiui/api/claude/` in `image-recipe/configs/nginx-archipelago.conf` (lines 17-28 and 371-382) — add `auth_request` directive pointing to an internal auth-check endpoint on the backend (e.g., `/internal/auth-check` that validates the session cookie), or restrict access to authenticated sessions via cookie check in the `location` block
- [x] **FIX-020** — fix(XSS-005): gate `echo`/`server.echo` behind authentication in `core/archipelago/src/api/rpc/mod.rs` (lines 87-88) — remove `echo` from the unauthenticated allowlist so it requires a valid session; alternatively, strip or limit `message` param to alphanumeric + basic punctuation
- [x] **FIX-021** — fix(INJ-007): sanitize log output in `core/archipelago/src/api/handler.rs` `handle_node_message` (line 136) — replace newlines (`\n`, `\r`) and ANSI escape sequences in `from` and `msg` with safe representations before passing to `tracing::info!`; use `.replace('\n', "\\n").replace('\r', "\\r")`
- [x] **FIX-022** — fix: harden `image-recipe/configs/archipelago.service` — change `User=root` to `User=archipelago` (dedicated non-root service account); set `Environment="ARCHIPELAGO_DEV_MODE=false"`; add `NoNewPrivileges=true`, `ProtectSystem=strict`, `ReadWritePaths=/var/lib/archipelago`; this reduces blast radius for all findings
- [x] **VERIFY** — test: re-run pentest curl probes from the exploitation report against all 21 finding endpoints to confirm: unauthenticated requests return 401, path traversal payloads are rejected, CORS headers are restrictive, security headers are present, WebSocket requires auth, and the service runs as non-root with dev mode disabled
**Goal**: Make Archipelago flawless for alpha testers installing on their own hardware.
**Priority**: Onboarding perfection > App install reliability > AIUI hardening > UI polish
**Rule**: No new features. No design changes (except smoothing transitions). Harden everything.
---
## Post-Fix Verification (ALWAYS run as final step)
## Phase 1: Onboarding Flow (Critical Path)
After all FIX tasks are complete and deployed, run the automated verification script:
- [ ] **OB-001** — fix(onboarding): convert "Choose Your Path" screen (`neode-ui/src/views/OnboardingPath.vue`) from a selection screen to an informative screen. Keep the exact same 6 cards (Self Sovereignty, Community Commerce, Sovereign Projects, Data Transmitter, Hoster, Sovereign AI) with their current design, but remove the toggle/selection behavior. Remove the `toggleOption()` click handler and `--selected` class binding. Change heading from "Choose Your Path" to "Your Node, Your Possibilities". Change subtitle to "Archipelago gives you the tools to build your sovereign digital life. All of these capabilities are available from your dashboard." Remove "Skip" button — only keep "Continue" which navigates to `/onboarding/did`. The cards should be read-only informational cards, not buttons. Deploy and verify.
- [ ] **OB-002** — fix(onboarding): harden the "Choose Your Setup" screen (`neode-ui/src/views/OnboardingOptions.vue`) — this is the Fresh Start / Restore / Connect screen. For alpha, only "Fresh Start" should work — gray out and disable "Restore Backup" and "Connect Existing" with a "(Coming Soon)" label. Make "Fresh Start" auto-selected on mount so users don't have to click before pressing Continue. Ensure `completeOnboarding()` is called reliably (currently it might fail silently and user gets stuck). Deploy and verify.
- [ ] **OB-003** — fix(onboarding): harden the DID retrieval step (`neode-ui/src/views/OnboardingDid.vue`). If the server is not reachable (502/503/timeout), show a clear message "Connecting to your server..." with a retry button instead of the fallback "did:key:z6Mk... (connect to server)" text. Auto-fetch the DID on mount (don't wait for button click — the "Retrieve DID" button adds friction). If fetch succeeds, auto-advance after 2 seconds with a "DID retrieved, continuing..." message. Keep Skip button.
- [ ] **OB-004** — fix(onboarding): harden the Backup step (`neode-ui/src/views/OnboardingBackup.vue`). Ensure the `rpcClient.createBackup()` call works on a fresh install. If it fails, show a helpful error message. Make the download work on mobile (some browsers block `a.click()` programmatic downloads). Test the full backup/download flow. Deploy and verify.
- [ ] **OB-005** — fix(onboarding): harden the Verify step (`neode-ui/src/views/OnboardingVerify.vue`). The `signChallenge()` call must work on a fresh server with a new identity. If it fails, allow user to retry or skip gracefully. Ensure `completeOnboarding()` is called on both proceed and skip paths (already done, but verify). Deploy and verify.
- [ ] **OB-006** — fix(onboarding): verify the complete onboarding flow end-to-end. Clear onboarding state on server (`rpcClient.resetOnboarding()` if it exists, or clear localStorage `neode_onboarding_complete`). Walk through every step: Intro → Path (informative) → DID → Backup → Verify → Done → Login. Each transition must be smooth with the 3D depth effect. No JS errors in console. Deploy and verify.
## Phase 2: First Login & Password Setup
- [ ] **LOGIN-001** — fix(login): verify the login flow on a fresh install. The first user must be able to set a password (setup mode). After setting password, redirect to login. After login, redirect to dashboard. Test: clear all state, visit http://192.168.1.228, complete onboarding, set password, login. The startup progress bar must appear only when the server is genuinely starting (not on normal page loads). Deploy and verify.
- [ ] **LOGIN-002** — fix(login): harden the RootRedirect component (`neode-ui/src/views/RootRedirect.vue`). On a fresh install, root `/` must redirect to `/onboarding/intro`. After onboarding, root must redirect to `/login`. After login, must redirect to `/dashboard`. Test all three states. No infinite redirect loops. Deploy and verify.
## Phase 3: App Installation Reliability
- [ ] **APP-001** — fix(apps): verify that the Marketplace loads all available apps from manifests in `apps/*/manifest.yml`. Each app should have: name, description, icon, version. Test the marketplace page loads without errors. Deploy and verify.
- [ ] **APP-002** — fix(apps): test installing Bitcoin Knots (the most critical app). The install flow is: click Install → pull image → create container → start → show running state. Verify each step works. If image pull fails (no internet), show a clear error. If container fails to start, show logs. After successful install, the app should appear in "My Apps" with correct status. Deploy and verify.
- [ ] **APP-003** — fix(apps): test installing at least 3 more apps from the marketplace (e.g., Mempool, LND, Electrs). Verify each installs and shows correct status. If any app fails, fix the manifest or backend logic. Ensure app dependencies are resolved (e.g., LND depends on Bitcoin). Deploy and verify.
- [ ] **APP-004** — fix(apps): verify app uninstall works cleanly. Install an app, verify it runs, uninstall it, verify it's removed from both the UI and the container runtime (`podman ps -a`). No orphaned containers or data. Deploy and verify.
- [ ] **APP-005** — fix(apps): verify app detail pages load correctly. Click into an installed app → should show: status, version, logs (if available), open button (if applicable). No JS errors, no blank pages. The iframe for web-UI apps must load with correct port. Deploy and verify.
## Phase 4: AIUI Chat Hardening
- [ ] **AIUI-001** — fix(aiui): verify the AIUI chat loads in the dashboard. Navigate to `/dashboard/chat`. The iframe must load AIUI from `/aiui/`. Check: no 404s, no CORS errors, no blank white screen. The context broker must be initialized. Deploy and verify.
- [ ] **AIUI-002** — fix(aiui): verify the Claude proxy is running and responds. Test: `curl -X POST http://192.168.1.228/aiui/api/claude/v1/messages -H 'Content-Type: application/json' -d '{"model":"haiku","messages":[{"role":"user","content":"hello"}],"stream":false}'` (with session cookie). Should get a valid response or proper auth error. If proxy is down, restart `claude-proxy` service. Deploy and verify.
- [ ] **AIUI-003** — fix(aiui): verify the API key switcher works. Open AIUI settings → Chat tab → enable "Use my own API key" → paste the test key `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` → send a message → verify response comes back. Then disable the toggle → verify it falls back to server OAuth. Deploy and verify.
- [ ] **AIUI-004** — fix(aiui): verify that the context broker surfaces node data to AIUI. Enable all AI permissions in Settings → AI Data Access. Then ask AIUI "what apps are installed?" or "what's my server status?". The response should include real data from the node (via postMessage → contextBroker → stores/RPC). If it doesn't, debug the postMessage flow. Deploy and verify.
- [ ] **AIUI-005** — fix(aiui): verify that message send, reply, and regenerate all work without the "empty content" API error. Send multiple messages in a row. Use reply on a specific message. Use regenerate. None should produce "messages.N: user messages must have non-empty content" errors. Deploy and verify.
## Phase 5: Dashboard & UI Polish
- [ ] **UI-001** — fix(ui): verify all dashboard nav items work: Home, My Apps, Marketplace, Cloud, Server, Web5, Settings, Chat. Each must load without errors. No blank pages, no console errors. The sidebar navigation must highlight the active item. Deploy and verify.
- [ ] **UI-002** — fix(ui): verify the Home dashboard shows correct data: server status (online/offline), uptime, disk usage, memory, CPU. If metrics RPC fails, show placeholder data with "Connecting..." state instead of errors. Deploy and verify.
- [ ] **UI-003** — fix(ui): verify the Server page loads and shows system info. Test all sections: system overview, services list, network info. No JS errors. Deploy and verify.
- [ ] **UI-004** — fix(ui): verify the Settings page loads all sections: General, Security (password change), AI Data Access, Tor, About. No JS errors. The AI Data Access toggles must persist between page loads. Deploy and verify.
- [ ] **UI-005** — fix(ui): test WebSocket connection stability. After login, the WebSocket at `/ws` should connect and stay connected. If it disconnects, verify auto-reconnect works. Check: no repeated "WebSocket disconnected" errors in console. Deploy and verify.
- [ ] **UI-006** — fix(ui): ensure all page transitions are smooth. Navigate between all dashboard pages. The 3D depth transitions should be fluid without flicker or layout jumps. If any transition stutters, optimize by adding `will-change` or reducing transition complexity. Deploy and verify.
## Phase 5b: AIUI Security Hardening (from research audit)
- [ ] **SEC-001** — fix(aiui): add confirmation dialog for AIUI app installs. In `neode-ui/src/services/contextBroker.ts`, the `install-app` action currently fires without user confirmation. Change it to emit a custom event (`window.dispatchEvent(new CustomEvent('aiui:install-request', { detail: { appId, version, url } }))`) that the Dashboard UI can intercept with a confirmation modal. Only proceed with `appStore.installPackage()` after user confirms. This prevents AIUI from silently installing apps.
- [ ] **SEC-002** — fix(aiui): add file path whitelist to AIUI file access. In `neode-ui/src/services/contextBroker.ts`, the `read-file` action currently allows reading any path. Add a whitelist of allowed directories (e.g., `/var/lib/archipelago/`, `/var/log/`) and reject paths containing sensitive patterns (`id_rsa`, `private`, `secret`, `password`, `seed`, `.env`, `wallet`). This prevents AIUI from exfiltrating secrets.
- [ ] **SEC-003** — fix(aiui): add log redaction for container logs. In the context broker's log handler, redact sensitive patterns from container logs before sending to AIUI: RPC passwords, private keys (hex strings > 32 chars), API tokens, and macaroon values.
- [ ] **SEC-004** — fix(auth): add `Secure` flag to session cookie in production. In `core/archipelago/src/api/rpc/mod.rs`, add `; Secure` to the `Set-Cookie` header when `dev_mode` is false. This prevents the session cookie from being transmitted over plain HTTP in production.
## Phase 6: Alpha ISO Build
- [ ] **ISO-001** — fix(iso): sync all current changes to the dev server. Run full deploy: `./scripts/deploy-to-target.sh --live`. Verify everything works on http://192.168.1.228. Then SSH to the server and run the ISO build: `cd ~/archy/image-recipe && sudo DEV_SERVER=archipelago@localhost ./build-auto-installer-iso.sh`. The ISO must build successfully and be saved to `results/`. Report the ISO path and size.
- [ ] **ISO-002** — fix(iso): verify the ISO image configs include all latest changes. Check that `image-recipe/configs/` has up-to-date: `archipelago.service`, `nginx-archipelago.conf`. If they differ from the live server, update them. The ISO must produce a bootable system identical to the current live server.
---
## Post-Completion Verification
After all tasks are done, run a full verification:
```bash
./scripts/verify-pentest-fixes.sh
# 1. Test onboarding flow (clear state first)
sshpass -p 'EwPDR8q45l0Upx@' ssh archipelago@192.168.1.228 \
'echo "EwPDR8q45l0Upx@" | sudo -S systemctl restart archipelago'
# 2. Visit http://192.168.1.228 in browser
# 3. Walk through complete onboarding
# 4. Login with password
# 5. Install Bitcoin Knots from marketplace
# 6. Open AIUI chat and send a message
# 7. Verify all dashboard pages work
# If all pass: ISO is ready for alpha testers
```
This script tests every pentest finding against the live server:
- Login returns HttpOnly/SameSite session cookie
- All sensitive RPC methods return 401 without auth
- WebSocket, container logs, LND proxy require auth
- Rate limiting triggers on 6th failed login
- Path traversal, untrusted registries, spoofed pubkeys are rejected
- CORS blocks evil origins
- Nginx security headers are present
- Logout invalidates the session
If verification fails (exit code 1), DO NOT mark VERIFY as done. Fix the failing checks and redeploy first.

View File

@ -1,74 +1,64 @@
You are integrating AIUI (AI chat interface) into Archipelago (Archy) as its Chat mode. Read these files first:
You are hardening Archipelago (Archy) for its first alpha release. People will flash this ISO to USB, install on their hardware, and use the web UI to manage their node. Everything must work flawlessly.
Read these files first:
1. `loop/plan.md` — Your task checklist (mark items `- [x]` as you complete them)
2. `CLAUDE.md` — Archy project conventions, architecture, coding standards
3. `/Users/dorian/Projects/AIUI/CLAUDE.md` — AIUI conventions and Archy integration rules
## Architecture Overview
## What You're Doing
AIUI runs in an iframe at `/dashboard/chat`. Communication happens via `window.postMessage()` through a ContextBroker (Archy side) and archyBridge (AIUI side). AIUI is quarantined — it never directly accesses Archy APIs.
**No new features. No design changes.** You are:
- Hardening the first-time onboarding flow so it works perfectly
- Ensuring app installation is bulletproof
- Making the AIUI chat work reliably
- Fixing any UI bugs or rough edges
- Building the alpha ISO when everything passes
## Architecture Quick Reference
```
AIUI (iframe) ←→ postMessage ←→ ContextBroker (Archy) ←→ Pinia stores / RPC
Server: 192.168.1.228 (ssh: archipelago@192.168.1.228, pass: EwPDR8q45l0Upx@)
Frontend: neode-ui/ → builds to web/dist/neode-ui/ → deployed to /opt/archipelago/web-ui/
Backend: core/archipelago/ → Rust binary → deployed to /usr/local/bin/archipelago
AIUI: /Users/dorian/Projects/AIUI/packages/app/ → builds to dist/ → deployed to /opt/archipelago/web-ui/aiui/
Claude Proxy: port 3141 → systemd service claude-proxy
Nginx: port 80 → proxies /rpc/, /ws/, /health, /aiui/
```
## Key Files — Archy Side
## Key Paths
- `neode-ui/src/services/contextBroker.ts` — Message handler, permission checks, data fetching/sanitization
- `neode-ui/src/types/aiui-protocol.ts` — TypeScript types for postMessage protocol
- `neode-ui/src/stores/aiPermissions.ts` — User permission toggles (what AIUI can access)
- `neode-ui/src/views/Chat.vue` — Iframe container with close button
- `neode-ui/src/views/Settings.vue` — AI permissions UI section
- `neode-ui/src/api/rpc-client.ts` — Backend RPC endpoints
- `neode-ui/src/api/container-client.ts` — Container operations
- `neode-ui/src/stores/app.ts` — Main app state (packages, server info, metrics)
## Key Files — AIUI Side (read-only reference, AIUI agent handles these)
- `/Users/dorian/Projects/AIUI/packages/app/src/services/archyBridge.ts` — AIUI's postMessage client
- `/Users/dorian/Projects/AIUI/packages/app/src/composables/useArchy.ts` — Vue composable wrapping archyBridge
- `/Users/dorian/Projects/AIUI/packages/app/src/composables/contentExtraction.ts` — Content tag extraction pipeline
- `/Users/dorian/Projects/AIUI/packages/app/src/composables/useContentPanel.ts` — Content panel state
## Coordination with AIUI Agent
A separate Claude agent is working on the AIUI repo simultaneously. Your job is the **Archy side only**:
- Expand the ContextBroker to serve real data for all categories
- Add new context categories for media, search, and local AI
- Wire up real store/RPC data instead of placeholders
- Deploy and test on the live server at 192.168.1.228
- DO NOT edit files in /Users/dorian/Projects/AIUI/ — the other agent handles that
## Content Handshake Protocol
AIUI's content pipeline uses `[[tag:data]]` syntax in AI responses to surface content. The AI needs context about what's available on the node to generate these tags. The handshake works like this:
1. AIUI sends `context:request` with category (e.g., `media`, `apps`, `files`)
2. Archy's ContextBroker checks permissions, fetches from stores/RPC, sanitizes
3. Returns data to AIUI which injects it into the AI's system prompt
4. AI generates responses with appropriate `[[film:id]]`, `[[song:id]]` tags referencing actual library content
5. AIUI's content extraction pipeline renders the tagged content in panels
- Onboarding views: `neode-ui/src/views/Onboarding*.vue`
- Router: `neode-ui/src/router/index.ts`
- App store: `neode-ui/src/stores/app.ts`
- RPC client: `neode-ui/src/api/rpc-client.ts`
- Container client: `neode-ui/src/api/container-client.ts`
- App manifests: `apps/*/manifest.yml`
- Context broker: `neode-ui/src/services/contextBroker.ts`
- AIUI composable: `/Users/dorian/Projects/AIUI/packages/app/src/composables/useAI.ts`
- Claude proxy: `/Users/dorian/Projects/AIUI/packages/app/server/claude-proxy.ts`
## For each task in loop/plan.md:
1. Find the first unchecked `- [ ]` item
2. Read the task description carefully
2. Read the task description carefully — it tells you exactly what to do
3. Read the relevant source files before making changes
4. Implement following CLAUDE.md conventions (glass styling, TypeScript strict, etc.)
5. Run `cd neode-ui && npm run type-check` — fix all errors before continuing
4. Make the change following CLAUDE.md conventions
5. Run `cd neode-ui && npm run type-check` — fix all errors
6. Run `cd neode-ui && npm run build` — must succeed
7. Deploy to live server: `./scripts/deploy-to-target.sh --live`
8. Commit: `type: description` (conventional commits)
9. Mark it done `- [x]` in `loop/plan.md`
10. Move to the next unchecked task immediately
7. Deploy: `./scripts/deploy-to-target.sh --live`
8. If AIUI files were changed: build AIUI (`cd /Users/dorian/Projects/AIUI/packages/app && node node_modules/vite/bin/vite.js build`) and deploy to server (`tar czf /tmp/aiui.tar.gz -C dist . && sshpass -p 'EwPDR8q45l0Upx@' ssh archipelago@192.168.1.228 'mkdir -p /tmp/aiui-deploy' && sshpass -p 'EwPDR8q45l0Upx@' scp /tmp/aiui.tar.gz archipelago@192.168.1.228:/tmp/aiui-deploy/ && sshpass -p 'EwPDR8q45l0Upx@' ssh archipelago@192.168.1.228 'cd /tmp/aiui-deploy && tar xzf aiui.tar.gz && echo "EwPDR8q45l0Upx@" | sudo -S rsync -a --delete /tmp/aiui-deploy/ /opt/archipelago/web-ui/aiui/'`)
9. Verify the fix works on http://192.168.1.228
10. Mark it done `- [x]` in `loop/plan.md`
11. Commit: `type: description`
12. Move to next task immediately
## Rules
- Never skip a build/typecheck gate — if it fails, fix before moving on
- If a task is proving difficult, make at least 30 genuine attempts before moving on
- Always deploy after completing a task — changes must be live at 192.168.1.228
- Do NOT edit AIUI files — only Archy files
- Build AIUI when needed: `cd /Users/dorian/Projects/AIUI && rm -rf .turbo packages/app/.turbo packages/core/.turbo packages/app/dist packages/core/dist && VITE_BASE_PATH=/aiui/ pnpm build`
- Deploy AIUI dist: `sshpass -p 'EwPDR8q45l0Upx@' scp -o StrictHostKeyChecking=no -r /Users/dorian/Projects/AIUI/packages/app/dist/* archipelago@192.168.1.228:/opt/archipelago/aiui/`
- Do not stop until all tasks are checked or you are rate limited
- Read files before editing — understand before changing
- Never skip build/typecheck — if it fails, fix before moving on
- Always deploy after completing a task — changes must be live
- If a task is proving difficult after 15+ genuine attempts, add `(BLOCKED: reason)` to the task and move on
- Test on the actual server, not just locally
- Do not stop until all tasks are checked or you hit rate limits
- AIUI files are outside the project — use Bash with python3 for edits if the Edit tool is blocked by hooks
- For ISO build: SSH to 192.168.1.228 and run the build script there

View File

@ -861,6 +861,38 @@ app.post('/rpc/v1', (req, res) => {
return res.json({ result: 'ok' })
}
case 'auth.totp.status': {
return res.json({ result: { enabled: false } })
}
case 'auth.totp.setup.begin': {
return res.json({
result: {
qr_svg: '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect width="200" height="200" fill="#fff"/><text x="100" y="100" text-anchor="middle" font-size="12" fill="#333">Mock QR Code</text></svg>',
secret_base32: 'JBSWY3DPEHPK3PXP',
pending_token: 'mock-pending-token',
},
})
}
case 'auth.totp.setup.confirm': {
return res.json({
result: {
enabled: true,
backup_codes: ['ABCD-EFGH', 'JKLM-NPQR', 'STUV-WXYZ', '2345-6789', 'ABCD-2345', 'EFGH-6789', 'JKLM-STUV', 'NPQR-WXYZ'],
},
})
}
case 'auth.totp.disable': {
return res.json({ result: { disabled: true } })
}
case 'auth.login.totp':
case 'auth.login.backup': {
return res.json({ result: { success: true } })
}
default: {
return res.json({
error: {

View File

@ -87,18 +87,47 @@ class RPCClient {
}
// Convenience methods
async login(password: string): Promise<void> {
async login(password: string): Promise<{ requires_totp?: boolean } | null> {
return this.call({
method: 'auth.login',
params: {
password,
metadata: {
// Add any metadata needed
},
},
})
}
async loginTotp(code: string): Promise<{ success: boolean }> {
return this.call({ method: 'auth.login.totp', params: { code } })
}
async loginBackup(code: string): Promise<{ success: boolean }> {
return this.call({ method: 'auth.login.backup', params: { code } })
}
async totpSetupBegin(password: string): Promise<{
qr_svg: string
secret_base32: string
pending_token: string
}> {
return this.call({ method: 'auth.totp.setup.begin', params: { password } })
}
async totpSetupConfirm(params: {
code: string
password: string
pendingToken: string
}): Promise<{ enabled: boolean; backup_codes: string[] }> {
return this.call({ method: 'auth.totp.setup.confirm', params })
}
async totpDisable(password: string, code: string): Promise<{ disabled: boolean }> {
return this.call({ method: 'auth.totp.disable', params: { password, code } })
}
async totpStatus(): Promise<{ enabled: boolean }> {
return this.call({ method: 'auth.totp.status', params: {} })
}
async changePassword(params: {
currentPassword: string
newPassword: string

View File

@ -27,12 +27,16 @@ export const useAppStore = defineStore('app', () => {
const isOffline = computed(() => !isConnected.value || isRestarting.value || isShuttingDown.value)
// Actions
async function login(password: string): Promise<void> {
async function login(password: string): Promise<{ requires_totp?: boolean }> {
isLoading.value = true
error.value = null
try {
await rpcClient.login(password)
const result = await rpcClient.login(password)
if (result && result.requires_totp) {
return { requires_totp: true }
}
isAuthenticated.value = true
sessionValidated = true
localStorage.setItem('neode-auth', 'true')
@ -44,6 +48,7 @@ export const useAppStore = defineStore('app', () => {
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket connection failed after login, will retry:', err)
})
return {}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Login failed'
throw err
@ -52,6 +57,16 @@ export const useAppStore = defineStore('app', () => {
}
}
async function completeLoginAfterTotp(): Promise<void> {
isAuthenticated.value = true
sessionValidated = true
localStorage.setItem('neode-auth', 'true')
await initializeData()
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
})
}
async function logout(): Promise<void> {
try {
await rpcClient.logout()
@ -285,6 +300,7 @@ export const useAppStore = defineStore('app', () => {
// Actions
login,
completeLoginAfterTotp,
logout,
checkSession,
needsSessionValidation,

View File

@ -19,6 +19,20 @@
<span v-else>Welcome to Archipelago</span>
</h1>
<!-- Server Startup Progress -->
<div v-if="!serverReady" class="mb-6">
<div class="flex items-center justify-center gap-2 mb-3">
<svg class="animate-spin h-4 w-4 text-orange-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-sm text-white/60">Server starting up...</span>
</div>
<div class="startup-progress-track">
<div class="startup-progress-bar" :style="{ width: startupProgress + '%' }"></div>
</div>
</div>
<!-- Error Message -->
<div v-if="error" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
{{ error }}
@ -42,7 +56,7 @@
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
placeholder="Enter a password (min 8 characters)"
@keyup.enter="handleSetupWithSound"
:disabled="loading"
:disabled="loading || formDisabled"
/>
</div>
@ -57,13 +71,13 @@
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
placeholder="Confirm your password"
@keyup.enter="handleSetupWithSound"
:disabled="loading"
:disabled="loading || formDisabled"
/>
</div>
<button
@click="handleSetupWithSound"
:disabled="loading || !password || password !== confirmPassword"
:disabled="loading || formDisabled || !password || password !== confirmPassword"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="!loading">Set Up Node</span>
@ -77,6 +91,55 @@
</button>
</template>
<!-- TOTP Verification Step -->
<template v-else-if="requiresTotp">
<div class="mb-6 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 mx-auto mb-3 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<p class="text-white/80 text-sm mb-1">Two-Factor Authentication</p>
<p class="text-white/50 text-xs">Enter the 6-digit code from your authenticator app</p>
</div>
<div class="mb-4">
<input
ref="totpInputRef"
v-model="totpCode"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="8"
autocomplete="one-time-code"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
@keyup.enter="handleTotpVerify"
:disabled="loading"
/>
</div>
<button
@click="handleTotpVerify"
:disabled="loading || !totpCode"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed mb-3"
>
<span v-if="!loading">Verify</span>
<span v-else class="flex items-center justify-center">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verifying...
</span>
</button>
<button
@click="useBackupCode = !useBackupCode; totpCode = ''"
class="w-full text-white/50 text-sm hover:text-white/70 transition-colors py-2"
>
{{ useBackupCode ? 'Use authenticator code' : 'Use a backup code instead' }}
</button>
</template>
<!-- Normal Login Mode -->
<template v-else>
<div class="mb-6">
@ -90,13 +153,13 @@
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
placeholder="Enter your password"
@keyup.enter="handleLoginWithSound"
:disabled="loading"
:disabled="loading || formDisabled"
/>
</div>
<button
@click="handleLoginWithSound"
:disabled="loading || !password"
:disabled="loading || formDisabled || !password"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="!loading">Login</span>
@ -156,12 +219,72 @@ const loading = ref(false)
const error = ref<string | null>(null)
const isSetup = ref(false)
const whooshAway = ref(false)
const requiresTotp = ref(false)
const totpCode = ref('')
const useBackupCode = ref(false)
const totpInputRef = ref<HTMLInputElement | null>(null)
// Server startup state
const serverReady = ref(false)
const serverChecking = ref(true)
const startupProgress = ref(0)
let startupPollTimer: ReturnType<typeof setTimeout> | null = null
let startupProgressInterval: ReturnType<typeof setInterval> | null = null
// Check if we're in setup mode (original StartOS node setup)
const isSetupMode = computed(() => {
return import.meta.env.VITE_DEV_MODE === 'setup'
})
// Whether the login form should be disabled (server not ready)
const formDisabled = computed(() => !serverReady.value)
async function checkServerHealth(): Promise<boolean> {
try {
const response = await fetch('/rpc/v1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: 'server.echo', params: { message: 'ping' } }),
signal: AbortSignal.timeout(5000),
})
// Any HTTP response from backend (200, 401, 403, etc.) means it's up
// Only 502/503 from nginx means backend isn't running yet
return response.status !== 502 && response.status !== 503
} catch {
return false
}
}
function pollServerStartup(): Promise<void> {
return new Promise((resolve) => {
// Animate progress slowly while waiting
startupProgressInterval = setInterval(() => {
if (startupProgress.value < 90) {
startupProgress.value += Math.random() * 8 + 2
if (startupProgress.value > 90) startupProgress.value = 90
}
}, 600)
const poll = async () => {
const healthy = await checkServerHealth()
if (healthy) {
if (startupProgressInterval) clearInterval(startupProgressInterval)
startupProgress.value = 100
// Brief pause to show 100% before revealing form
await new Promise(r => setTimeout(r, 400))
serverReady.value = true
serverChecking.value = false
resolve()
return
}
// Retry in 2s
startupPollTimer = setTimeout(poll, 2000)
}
poll()
})
}
let unlockHandler: (() => void) | null = null
function removeUnlockListeners() {
@ -173,7 +296,11 @@ function removeUnlockListeners() {
}
}
onBeforeUnmount(removeUnlockListeners)
onBeforeUnmount(() => {
removeUnlockListeners()
if (startupPollTimer) clearTimeout(startupPollTimer)
if (startupProgressInterval) clearInterval(startupProgressInterval)
})
onMounted(async () => {
const fromSplash = sessionStorage.getItem('archipelago_from_splash') === '1'
@ -188,6 +315,18 @@ onMounted(async () => {
document.addEventListener('click', unlockHandler, { once: true })
document.addEventListener('touchstart', unlockHandler, { once: true })
document.addEventListener('keydown', unlockHandler, { once: true })
// Check server health first
const healthy = await checkServerHealth()
if (healthy) {
serverReady.value = true
serverChecking.value = false
} else {
// Server not ready start polling with progress bar
await pollServerStartup()
}
// Only check setup mode after server is confirmed ready
if (isSetupMode.value) {
try {
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
@ -265,7 +404,14 @@ async function handleLogin() {
error.value = null
try {
await store.login(password.value)
const result = await store.login(password.value)
if (result?.requires_totp) {
requiresTotp.value = true
loading.value = false
// Focus the TOTP input after DOM update
setTimeout(() => totpInputRef.value?.focus(), 100)
return
}
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()
@ -288,6 +434,43 @@ async function handleLogin() {
}
}
async function handleTotpVerify() {
if (!totpCode.value) return
loading.value = true
error.value = null
try {
if (useBackupCode.value) {
await rpcClient.loginBackup(totpCode.value)
} else {
await rpcClient.loginTotp(totpCode.value)
}
await store.completeLoginAfterTotp()
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
})
} catch (err) {
const msg = err instanceof Error ? err.message : ''
if (/expired|too many/i.test(msg)) {
// Session expired, go back to password step
requiresTotp.value = false
totpCode.value = ''
error.value = msg
} else {
error.value = msg || 'Invalid code. Please try again.'
}
totpCode.value = ''
} finally {
loading.value = false
}
}
function replayIntro() {
// Clear the intro seen flag
localStorage.removeItem('neode_intro_seen')
@ -318,6 +501,22 @@ async function restartOnboarding() {
</script>
<style scoped>
/* Server startup progress bar */
.startup-progress-track {
height: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
overflow: hidden;
}
.startup-progress-bar {
height: 100%;
background: linear-gradient(90deg, #fb923c, #f59e0b);
border-radius: 2px;
transition: width 0.5s ease-out;
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
}
/* Perspective for 3D fly effect */
.login-fly-perspective {
perspective: 1200px;

View File

@ -174,6 +174,204 @@
</div>
</Teleport>
<!-- Two-Factor Authentication -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div>
<p class="text-sm font-medium text-white/90">Two-Factor Authentication</p>
<p class="text-xs text-white/50">Protect your account with an authenticator app</p>
</div>
</div>
<span
class="text-xs font-semibold px-2 py-1 rounded-full"
:class="totpEnabled ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-white/50'"
>
{{ totpEnabled ? 'Enabled' : 'Disabled' }}
</span>
</div>
<button
v-if="!totpEnabled"
@click="showTotpSetupModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Enable 2FA</span>
</button>
<button
v-else
@click="showTotpDisableModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-red-500/50 text-red-400 font-medium hover:bg-red-500/10 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
<span>Disable 2FA</span>
</button>
</div>
<!-- TOTP Setup Modal -->
<Teleport to="body">
<div
v-if="showTotpSetupModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click.self="closeTotpSetup"
>
<div class="glass-card p-6 max-w-md w-full">
<!-- Step 1: Enter password -->
<template v-if="totpSetupStep === 1">
<h3 class="text-lg font-semibold text-white mb-2">Enable Two-Factor Authentication</h3>
<p class="text-white/60 text-sm mb-4">Enter your password to begin setup.</p>
<form @submit.prevent="beginTotpSetup" class="space-y-4">
<input
v-model="totpSetupPassword"
type="password"
required
autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="Enter your password"
/>
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
<div class="flex gap-3">
<button
type="submit"
:disabled="totpSetupLoading"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ totpSetupLoading ? 'Loading...' : 'Continue' }}
</button>
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
</div>
</form>
</template>
<!-- Step 2: Scan QR + verify code -->
<template v-else-if="totpSetupStep === 2">
<h3 class="text-lg font-semibold text-white mb-2">Scan QR Code</h3>
<p class="text-white/60 text-sm mb-4">Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code.</p>
<div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="totpQrSvg" />
<div class="bg-black/30 rounded-lg px-3 py-2 mb-4">
<p class="text-xs text-white/50 mb-1">Manual entry key:</p>
<p class="text-sm font-mono text-orange-400 break-all select-all">{{ totpSecretBase32 }}</p>
</div>
<form @submit.prevent="confirmTotpSetup" class="space-y-4">
<input
v-model="totpSetupCode"
type="text"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
required
autocomplete="one-time-code"
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
placeholder="000000"
/>
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
<div class="flex gap-3">
<button
type="submit"
:disabled="totpSetupLoading || totpSetupCode.length !== 6"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ totpSetupLoading ? 'Verifying...' : 'Verify & Enable' }}
</button>
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
</div>
</form>
</template>
<!-- Step 3: Show backup codes -->
<template v-else-if="totpSetupStep === 3">
<h3 class="text-lg font-semibold text-white mb-2">Save Your Backup Codes</h3>
<p class="text-white/60 text-sm mb-4">Store these codes safely. Each can be used once if you lose access to your authenticator app.</p>
<div class="bg-black/30 rounded-xl p-4 mb-4">
<div class="grid grid-cols-2 gap-2">
<div
v-for="(code, i) in totpBackupCodes"
:key="i"
class="text-sm font-mono text-white/90 bg-white/5 rounded px-3 py-2 text-center"
>
{{ code }}
</div>
</div>
</div>
<button
@click="copyBackupCodes"
class="w-full mb-3 flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-white/20 text-white/80 font-medium hover:bg-white/5 transition-colors"
>
<svg v-if="!backupCodesCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>{{ backupCodesCopied ? 'Copied!' : 'Copy All Codes' }}</span>
</button>
<button
@click="closeTotpSetup"
class="w-full px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 transition-colors"
>
Done
</button>
</template>
</div>
</div>
</Teleport>
<!-- TOTP Disable Modal -->
<Teleport to="body">
<div
v-if="showTotpDisableModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click.self="closeTotpDisable"
>
<div class="glass-card p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-white mb-2">Disable Two-Factor Authentication</h3>
<p class="text-white/60 text-sm mb-4">Enter your password and a current TOTP code to disable 2FA.</p>
<form @submit.prevent="disableTotp" class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">Password</label>
<input
v-model="totpDisablePassword"
type="password"
required
autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="Enter your password"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">Authenticator Code</label>
<input
v-model="totpDisableCode"
type="text"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
required
autocomplete="one-time-code"
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
placeholder="000000"
/>
</div>
<p v-if="totpDisableError" class="text-sm text-red-400">{{ totpDisableError }}</p>
<div class="flex gap-3">
<button
type="submit"
:disabled="totpDisableLoading"
class="flex-1 px-4 py-2 rounded-lg bg-red-500 text-white font-medium hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ totpDisableLoading ? 'Disabling...' : 'Disable 2FA' }}
</button>
<button type="button" @click="closeTotpDisable" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
</div>
</form>
</div>
</div>
</Teleport>
<!-- Logout Button -->
<button
@click="handleLogout"
@ -447,6 +645,121 @@ function handleClaudeLoginMessage(e: MessageEvent) {
}
}
// --- 2FA State ---
const totpEnabled = ref(false)
const showTotpSetupModal = ref(false)
const showTotpDisableModal = ref(false)
const totpSetupStep = ref(1)
const totpSetupPassword = ref('')
const totpSetupCode = ref('')
const totpSetupError = ref('')
const totpSetupLoading = ref(false)
const totpQrSvg = ref('')
const totpSecretBase32 = ref('')
const totpPendingToken = ref('')
const totpBackupCodes = ref<string[]>([])
const backupCodesCopied = ref(false)
const totpDisablePassword = ref('')
const totpDisableCode = ref('')
const totpDisableError = ref('')
const totpDisableLoading = ref(false)
async function loadTotpStatus() {
try {
const res = await rpcClient.totpStatus()
totpEnabled.value = res.enabled
} catch {
// Ignore - may not be available
}
}
async function beginTotpSetup() {
totpSetupError.value = ''
totpSetupLoading.value = true
try {
const res = await rpcClient.totpSetupBegin(totpSetupPassword.value)
totpQrSvg.value = res.qr_svg
totpSecretBase32.value = res.secret_base32
totpPendingToken.value = res.pending_token
totpSetupStep.value = 2
} catch (e) {
totpSetupError.value = e instanceof Error ? e.message : 'Setup failed'
} finally {
totpSetupLoading.value = false
}
}
async function confirmTotpSetup() {
totpSetupError.value = ''
totpSetupLoading.value = true
try {
const res = await rpcClient.totpSetupConfirm({
code: totpSetupCode.value,
password: totpSetupPassword.value,
pendingToken: totpPendingToken.value,
})
totpBackupCodes.value = res.backup_codes
totpEnabled.value = true
totpSetupStep.value = 3
} catch (e) {
totpSetupError.value = e instanceof Error ? e.message : 'Verification failed'
} finally {
totpSetupLoading.value = false
}
}
function closeTotpSetup() {
showTotpSetupModal.value = false
totpSetupStep.value = 1
totpSetupPassword.value = ''
totpSetupCode.value = ''
totpSetupError.value = ''
totpQrSvg.value = ''
totpSecretBase32.value = ''
totpPendingToken.value = ''
totpBackupCodes.value = []
backupCodesCopied.value = false
}
async function disableTotp() {
totpDisableError.value = ''
totpDisableLoading.value = true
try {
await rpcClient.totpDisable(totpDisablePassword.value, totpDisableCode.value)
totpEnabled.value = false
closeTotpDisable()
} catch (e) {
totpDisableError.value = e instanceof Error ? e.message : 'Failed to disable 2FA'
} finally {
totpDisableLoading.value = false
}
}
function closeTotpDisable() {
showTotpDisableModal.value = false
totpDisablePassword.value = ''
totpDisableCode.value = ''
totpDisableError.value = ''
}
async function copyBackupCodes() {
const text = totpBackupCodes.value.join('\n')
try {
await navigator.clipboard.writeText(text)
} catch {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
backupCodesCopied.value = true
setTimeout(() => { backupCodesCopied.value = false }, 2000)
}
const copiedOnion = ref(false)
const showChangePasswordModal = ref(false)
const changePasswordModalRef = ref<HTMLElement | null>(null)
@ -539,6 +852,7 @@ function closeChangePasswordModal() {
onMounted(async () => {
checkClaudeStatus()
loadTotpStatus()
if (!serverTorAddressFromStore.value) {
try {
const res = await rpcClient.getTorAddress()