use super::RpcHandler; #[cfg(debug_assertions)] use super::DEV_DEFAULT_PASSWORD; use anyhow::Result; impl RpcHandler { pub(super) async fn handle_auth_login( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; // Companion device-token login: minted via auth.createDeviceToken and // carried by the pairing QR. Verified here so it shares the login rate // limiter with password attempts. if let Some(token) = params.get("token").and_then(|v| v.as_str()) { return match crate::device_tokens::verify(&self.config.data_dir, token).await { Some(device) => { tracing::info!("[onboarding] device-token login ({device})"); Ok(serde_json::Value::Null) } None => { tracing::warn!("[onboarding] device-token login failed"); Err(anyhow::anyhow!("Invalid device token")) } }; } let password = params .get("password") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing password"))?; let is_setup = self.auth_manager.is_setup().await?; if !is_setup { // Dev BUILDS only: allow the default password so the UI can log // in without running setup. cfg-gated so no release binary can // carry the bypass, whatever its runtime config says. #[cfg(debug_assertions)] if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD { tracing::info!("[onboarding] login via dev default password"); return Ok(serde_json::Value::Null); } tracing::warn!("[onboarding] login attempt before setup complete"); return Err(anyhow::anyhow!( "User not set up. Please complete setup first." )); } let valid = self.auth_manager.verify_password(password).await?; if !valid { // The companion app sends its device token through the password // field (it reuses the whole password auto-login path, including // the WebView form). Accept a valid token here so that path works. if let Some(device) = crate::device_tokens::verify(&self.config.data_dir, password).await { tracing::info!("[onboarding] device-token login via password field ({device})"); return Ok(serde_json::Value::Null); } tracing::warn!("[onboarding] login failed — wrong password"); return Err(anyhow::anyhow!("Password Incorrect")); } tracing::info!("[onboarding] login successful"); // Best-effort: heal a LOCKED LND wallet created with an unknown/legacy // password by rotating it onto the per-node secret, using the password // the user just authenticated with as a candidate. Non-blocking so login // is never slowed or broken when LND isn't installed / already unlocked. let candidate = password.to_string(); tokio::spawn(async move { match crate::container::lnd::migrate_locked_wallet(&[candidate]).await { Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"), Ok(false) => {} // not locked, or seed-recovery required Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"), } }); // Ensure NostrVPN config exists — covers the case where onboardingComplete // was never called (e.g., user took the "already set up" shortcut). let data_dir = self.config.data_dir.clone(); tokio::spawn(async move { // Quick check: if config.toml already exists, skip let config_path = data_dir.join("nostr-vpn/.config/nvpn/config.toml"); if config_path.exists() { return; } // Identity must exist for VPN config if !data_dir.join("identity/nostr_pubkey").exists() { return; } match crate::vpn::configure_nostr_vpn(&data_dir).await { Ok(()) => tracing::info!("[login] NostrVPN auto-configured on first login"), Err(e) => tracing::debug!("[login] NostrVPN auto-config skipped: {}", e), } }); Ok(serde_json::Value::Null) } /// Mint a device token for the companion pairing QR. Session-gated by the /// dispatcher (not in UNAUTHENTICATED_METHODS), so only a logged-in web UI /// can mint one. The plaintext token is returned exactly once. pub(super) async fn handle_auth_create_device_token( &self, params: Option, ) -> Result { let mut name = params .as_ref() .and_then(|p| p.get("name")) .and_then(|v| v.as_str()) .unwrap_or("companion") .trim() .to_string(); if name.is_empty() || name.len() > 64 { return Err(anyhow::anyhow!("Device name must be 1-64 characters")); } // The default name was a single shared slot: every pairing popup // replaced the previous phone's token, silently logging out the // first phone the moment a second one paired. Default-named mints // get a unique suffix so each device keeps its own credential; // explicitly named devices keep replace-in-place semantics. if name == "companion" { name = format!("companion-{}", hex::encode(rand::random::<[u8; 2]>())); } let token = crate::device_tokens::create(&self.config.data_dir, &name).await?; Ok(serde_json::json!({ "name": name, "token": token })) } pub(super) async fn handle_auth_list_device_tokens(&self) -> Result { let tokens = crate::device_tokens::list(&self.config.data_dir).await; Ok(serde_json::json!(tokens .iter() .map(|t| serde_json::json!({ "name": t.name, "created": t.created })) .collect::>())) } pub(super) async fn handle_auth_revoke_device_token( &self, params: Option, ) -> Result { let name = params .as_ref() .and_then(|p| p.get("name")) .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing name"))?; let removed = crate::device_tokens::remove(&self.config.data_dir, name).await?; Ok(serde_json::json!({ "removed": removed })) } pub(super) async fn handle_auth_logout(&self) -> Result { tracing::info!("[onboarding] logout"); Ok(serde_json::Value::Null) } pub(super) async fn handle_auth_change_password( &self, params: Option, session_token: &Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let current_password = params .get("currentPassword") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing currentPassword"))?; let new_password = params .get("newPassword") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing newPassword"))?; let also_change_ssh = params .get("alsoChangeSsh") .and_then(|v| v.as_bool()) .unwrap_or(true); let outcome = self .auth_manager .change_password(current_password, new_password, also_change_ssh) .await?; // Session rotation: invalidate all other sessions, rotate the caller's session if let Some(token) = session_token { self.session_store.invalidate_all_except(token).await; } Ok(serde_json::json!({ "success": true, "session_rotated": true, "ssh_updated": outcome.ssh_updated, "ssh_error": outcome.ssh_error, })) } pub(super) async fn handle_auth_is_setup(&self) -> Result { let is_setup = self.auth_manager.is_setup().await?; Ok(serde_json::json!(is_setup)) } pub(super) async fn handle_auth_setup( &self, params: Option, ) -> Result { // Prevent re-setup if already set up let is_setup = self.auth_manager.is_setup().await?; if is_setup { tracing::warn!("[onboarding] setup rejected — already set up"); return Err(anyhow::anyhow!( "Already set up. Use auth.changePassword to change." )); } 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"))?; if password.len() < 8 { tracing::warn!("[onboarding] setup rejected — password too short"); return Err(anyhow::anyhow!("Password must be at least 8 characters")); } self.auth_manager.setup_user(password).await?; tracing::info!("[onboarding] user setup complete"); // The install-time password must also become the OS login for the // archipelago user — otherwise the console/SSH keeps the image default // ("archipelago") after the user has picked a real password (#97). // Best-effort: a failure here must not break onboarding. match crate::auth::change_ssh_password(password).await { Ok(()) => tracing::info!("[onboarding] system login password synced"), Err(e) => tracing::warn!("[onboarding] system login password sync failed: {e}"), } // Persist the pending onboarding seed as the encrypted backup now that // a passphrase (the login password) finally exists — otherwise "Reveal // recovery phrase" has nothing to decrypt on this node, ever. // Best-effort: a failure here must not break password setup. match super::seed_rpc::save_pending_seed_encrypted(&self.config.data_dir, password).await { Ok(true) => tracing::info!("[onboarding] encrypted seed backup saved"), Ok(false) => tracing::info!( "[onboarding] no pending mnemonic to back up (restored earlier or legacy node)" ), Err(e) => tracing::warn!("[onboarding] encrypted seed backup failed: {e:#}"), } Ok(serde_json::json!(true)) } pub(super) async fn handle_auth_onboarding_complete(&self) -> Result { self.auth_manager.complete_onboarding().await?; tracing::info!("[onboarding] onboarding marked complete"); // Auto-configure NostrVPN with the node's Nostr identity let data_dir = self.config.data_dir.clone(); tokio::spawn(async move { match crate::vpn::configure_nostr_vpn(&data_dir).await { Ok(()) => tracing::info!("[onboarding] NostrVPN configured and started"), Err(e) => tracing::warn!("[onboarding] NostrVPN setup (non-fatal): {}", e), } }); Ok(serde_json::json!(true)) } pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result { let complete = self.auth_manager.is_onboarding_complete().await?; tracing::debug!("[onboarding] isOnboardingComplete={}", complete); Ok(serde_json::json!(complete)) } pub(super) async fn handle_auth_reset_onboarding( &self, params: Option, ) -> Result { 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-authentication required"))?; let valid = self.auth_manager.verify_password(password).await?; if !valid { tracing::warn!("[onboarding] reset rejected — wrong password"); return Err(anyhow::anyhow!("Password Incorrect")); } self.auth_manager.reset_onboarding().await?; tracing::info!("[onboarding] onboarding reset"); Ok(serde_json::json!(true)) } }