feat: factory reset, backup restore, auto-identity creation

- system.factory-reset RPC: wipes user data, preserves images/node_key
- Factory Reset button in Settings with confirmation modal
- backup.restore-identity RPC: decrypts and restores DID key
- Restore from Backup panel in OnboardingIntro first screen
- Auto-create default identity with Nostr key on boot if none exist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-15 05:18:12 +00:00
parent b447100637
commit c545b79b65
9 changed files with 346 additions and 13 deletions

View File

@@ -291,4 +291,35 @@ impl RpcHandler {
"size_bytes": size,
}))
}
/// Restore identity from an encrypted DID backup JSON.
/// Params: { backup: { version, blob, ... }, passphrase }
pub(super) async fn handle_backup_restore_identity(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let backup = params
.get("backup")
.ok_or_else(|| anyhow::anyhow!("Missing 'backup' parameter"))?;
let passphrase = params
.get("passphrase")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
let identity_dir = self.config.data_dir.join("identity");
let (did, pubkey) = crate::backup::restore_encrypted_backup(
&identity_dir,
backup,
passphrase,
)
.await
.context("Identity restore failed")?;
info!(did = %did, "Identity restored from backup");
Ok(serde_json::json!({
"did": did,
"pubkey": pubkey,
}))
}
}

View File

@@ -109,7 +109,10 @@ const UNAUTHENTICATED_METHODS: &[&str] = &[
"auth.login.totp",
"auth.login.backup",
"auth.isOnboardingComplete",
"auth.isSetup",
"health",
// Onboarding restore (before user account exists)
"backup.restore-identity",
// Inter-node RPC: called by federated peers over Tor, no session cookies
"federation.peer-joined",
"federation.peer-address-changed",
@@ -602,6 +605,7 @@ impl RpcHandler {
"system.detect-usb-devices" => self.handle_system_detect_usb_devices().await,
"system.disk-status" => self.handle_system_disk_status().await,
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
"system.factory-reset" => self.handle_system_factory_reset(params).await,
// Opt-in anonymous analytics
"analytics.get-status" => self.handle_analytics_get_status().await,
@@ -646,6 +650,10 @@ impl RpcHandler {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_backup_restore(&p).await
}
"backup.restore-identity" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_backup_restore_identity(&p).await
}
"backup.delete" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_backup_delete(&p).await

View File

@@ -590,3 +590,78 @@ async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
Ok(temps)
}
impl RpcHandler {
/// system.factory-reset — Wipe all user data and restart.
/// Preserves container images and node_key (hardware identity).
pub(super) async fn handle_system_factory_reset(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
// Safety check: require { confirm: true }
let confirmed = params
.as_ref()
.and_then(|p| p.get("confirm"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !confirmed {
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
}
tracing::warn!("Factory reset initiated — wiping user data");
let data_dir = &self.config.data_dir;
// Stop all running containers
if let Ok(client) = archipelago_container::PodmanClient::detect().await {
if let Ok(containers) = client.list_containers().await {
for c in &containers {
let _ = client.stop_container(&c.names).await;
}
}
}
// Delete user data (preserving node_key and container images)
let files_to_remove = [
"user.json",
"onboarding.json",
"peers.json",
"server-name",
];
for f in &files_to_remove {
let path = data_dir.join(f);
if path.exists() {
let _ = tokio::fs::remove_file(&path).await;
}
}
let dirs_to_remove = [
"identities",
"credentials",
"did-cache",
"dwn",
];
for d in &dirs_to_remove {
let path = data_dir.join(d);
if path.exists() {
let _ = tokio::fs::remove_dir_all(&path).await;
}
}
// Clear all sessions
self.session_store.invalidate_all_except("").await;
tracing::warn!("Factory reset complete — restarting service");
// Restart the service via systemd
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let _ = std::process::Command::new("sudo")
.args(["systemctl", "restart", "archipelago"])
.spawn();
});
Ok(serde_json::json!({ "status": "resetting" }))
}
}

View File

@@ -66,3 +66,67 @@ pub async fn create_encrypted_backup(
"timestamp": chrono::Utc::now().to_rfc3339(),
}))
}
/// Restore a node identity key from an encrypted backup.
/// Returns the DID and pubkey of the restored identity.
pub async fn restore_encrypted_backup(
identity_dir: &Path,
backup: &serde_json::Value,
passphrase: &str,
) -> Result<(String, String)> {
let blob_b64 = backup
.get("blob")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'blob' in backup"))?;
let blob = BASE64.decode(blob_b64).context("Invalid base64 in backup blob")?;
if blob.len() < SALT_LEN + NONCE_LEN {
anyhow::bail!("Backup blob too short");
}
let salt = &blob[..SALT_LEN];
let nonce = &blob[SALT_LEN..SALT_LEN + NONCE_LEN];
let ciphertext = &blob[SALT_LEN + NONCE_LEN..];
let argon2 = Argon2::default();
let mut key = [0u8; KEY_LEN];
argon2
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
.map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?;
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(&key)
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
let plaintext = cipher
.decrypt(
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
ciphertext,
)
.map_err(|_| anyhow::anyhow!("Decryption failed — wrong passphrase"))?;
if plaintext.len() != 32 {
anyhow::bail!("Decrypted key is not 32 bytes");
}
// Write the restored key
fs::create_dir_all(identity_dir).await?;
let key_path = identity_dir.join("node_key");
fs::write(&key_path, &plaintext).await.context("Writing restored key")?;
// Set restrictive permissions
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&key_path, perms)?;
}
// Derive DID and pubkey from the restored key
let signing_key = ed25519_dalek::SigningKey::from_bytes(
plaintext.as_slice().try_into().map_err(|_| anyhow::anyhow!("Invalid key"))?,
);
let pubkey = signing_key.verifying_key();
let pubkey_hex = hex::encode(pubkey.as_bytes());
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
Ok((did, pubkey_hex))
}

View File

@@ -6,4 +6,4 @@
mod identity;
pub mod full;
pub use identity::create_encrypted_backup;
pub use identity::{create_encrypted_backup, restore_encrypted_backup};

View File

@@ -48,6 +48,24 @@ impl Server {
}
state_manager.update_data(data.clone()).await;
// Auto-create default identity if none exist (fresh boot or factory reset)
{
let im = crate::identity_manager::IdentityManager::new(&config.data_dir).await;
if let Ok(mgr) = im {
if let Ok((list, _)) = mgr.list().await {
if list.is_empty() {
match mgr.create("Default".to_string(), crate::identity_manager::IdentityPurpose::Personal).await {
Ok(record) => {
let _ = mgr.create_nostr_key(&record.id).await;
tracing::info!(did = %record.did, "Auto-created default identity with Nostr key");
}
Err(e) => tracing::debug!("Auto-identity creation (non-fatal): {}", e),
}
}
}
}
}
// Revoke any previously published Nostr data (runs before publish so revocation is not overwritten)
let identity_dir = config.data_dir.join("identity");
let tor_proxy_revoke = config.nostr_tor_proxy.clone();