From c545b79b659eb87c1ee722f7535a74d89acd48e2 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 15 Mar 2026 05:18:12 +0000 Subject: [PATCH] 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) --- core/archipelago/src/api/rpc/backup_rpc.rs | 31 ++++++++ core/archipelago/src/api/rpc/mod.rs | 8 +++ core/archipelago/src/api/rpc/system.rs | 75 +++++++++++++++++++ core/archipelago/src/backup/identity.rs | 64 +++++++++++++++++ core/archipelago/src/backup/mod.rs | 2 +- core/archipelago/src/server.rs | 18 +++++ loop/plan.md | 24 +++---- neode-ui/src/views/OnboardingIntro.vue | 83 ++++++++++++++++++++++ neode-ui/src/views/Settings.vue | 54 ++++++++++++++ 9 files changed, 346 insertions(+), 13 deletions(-) diff --git a/core/archipelago/src/api/rpc/backup_rpc.rs b/core/archipelago/src/api/rpc/backup_rpc.rs index 5e2ff45b..10b918cc 100644 --- a/core/archipelago/src/api/rpc/backup_rpc.rs +++ b/core/archipelago/src/api/rpc/backup_rpc.rs @@ -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 { + 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, + })) + } } diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index b1afecae..a77adff7 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -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 diff --git a/core/archipelago/src/api/rpc/system.rs b/core/archipelago/src/api/rpc/system.rs index 9b6398b1..a5f8e240 100644 --- a/core/archipelago/src/api/rpc/system.rs +++ b/core/archipelago/src/api/rpc/system.rs @@ -590,3 +590,78 @@ async fn read_temperatures() -> Result> { 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, + ) -> Result { + // 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" })) + } +} diff --git a/core/archipelago/src/backup/identity.rs b/core/archipelago/src/backup/identity.rs index d3f28ec8..0316d8fa 100644 --- a/core/archipelago/src/backup/identity.rs +++ b/core/archipelago/src/backup/identity.rs @@ -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)) +} diff --git a/core/archipelago/src/backup/mod.rs b/core/archipelago/src/backup/mod.rs index 8a736182..bb9476f8 100644 --- a/core/archipelago/src/backup/mod.rs +++ b/core/archipelago/src/backup/mod.rs @@ -6,4 +6,4 @@ mod identity; pub mod full; -pub use identity::create_encrypted_backup; +pub use identity::{create_encrypted_backup, restore_encrypted_backup}; diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index ef43910b..af90251c 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -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(); diff --git a/loop/plan.md b/loop/plan.md index ae1e5ed6..4af05b9e 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -120,15 +120,15 @@ - [x] **Write ADR for DWN deprioritization**: Create `docs/adr/011-dwn-deprioritization.md`. Document: (1) TBD/Block shut down Nov 2024, donated code to DIF, (2) no maintained Rust DWN SDK exists, (3) DWN spec losing momentum without TBD's backing, (4) Archy's federation over Tor + Nostr relays already serve the peer data sync use case, (5) DWN store code stays in codebase but is not actively developed, (6) re-evaluate if DIF produces a viable Rust SDK. Follow existing ADR format in `docs/adr/`. This is documentation only — no code changes. -- [ ] **Deploy to both nodes and test Web5 features**: Deploy with `./scripts/deploy-to-target.sh --both`. Test at `http://192.168.1.228`: (1) navigate to Web5 page — DID displays correctly, (2) click "Publish to DHT" if available — should publish and show status, (3) go to Credentials page — issue a test credential to self, verify it shows in list. Repeat on `http://192.168.1.198`. Check logs on both: `ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago --since "5 min ago" | grep -iE "(did|credential|dwn|identity)"'` and same for .198. +- [x] **Deploy to both nodes and test Web5 features**: Deploy with `./scripts/deploy-to-target.sh --both`. Test at `http://192.168.1.228`: (1) navigate to Web5 page — DID displays correctly, (2) click "Publish to DHT" if available — should publish and show status, (3) go to Credentials page — issue a test credential to self, verify it shows in list. Repeat on `http://192.168.1.198`. Check logs on both: `ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago --since "5 min ago" | grep -iE "(did|credential|dwn|identity)"'` and same for .198. -- [ ] **Test cross-node DID resolution between .228 and .198**: From .228's Web5 page, get its DID (did:key). SSH to .198 and test resolving .228's DID: `curl -s http://localhost:5678/rpc/v1 -H 'Content-Type: application/json' -d '{"method":"identity.resolve-remote-did","params":{"did":"<.228-did>","onion_address":"<.228-onion>"}}'`. The response should return .228's full DID Document. Test the reverse direction (resolve .198's DID from .228). If resolution fails, check: (1) Tor is running on both nodes (`sudo podman ps | grep tor`), (2) onion addresses are valid (`cat /var/lib/archipelago/tor/*/hostname`), (3) RPC is accessible over Tor. Fix any issues found. +- [x] **Test cross-node DID resolution between .228 and .198**: From .228's Web5 page, get its DID (did:key). SSH to .198 and test resolving .228's DID: `curl -s http://localhost:5678/rpc/v1 -H 'Content-Type: application/json' -d '{"method":"identity.resolve-remote-did","params":{"did":"<.228-did>","onion_address":"<.228-onion>"}}'`. The response should return .228's full DID Document. Test the reverse direction (resolve .198's DID from .228). If resolution fails, check: (1) Tor is running on both nodes (`sudo podman ps | grep tor`), (2) onion addresses are valid (`cat /var/lib/archipelago/tor/*/hostname`), (3) RPC is accessible over Tor. Fix any issues found. -- [ ] **Test cross-node credential issuance and verification**: From .228, issue a Verifiable Credential where .228 is the issuer and .198's DID is the subject. Use the Credentials UI or RPC: `curl -s http://localhost:5678/rpc/v1 -H 'Content-Type: application/json' -d '{"method":"identity.issue-credential","params":{"subject_did":"<.198-did>","credential_type":"FederationMember","claims":{"role":"peer","joined":"2026-03-15"}}}'`. Copy the credential ID. From .198, verify the credential: `curl -s http://localhost:5678/rpc/v1 -H 'Content-Type: application/json' -d '{"method":"identity.verify-credential","params":{"credential_id":""}}'`. If .198 can't verify (it needs .228's public key), test the resolution chain: .198 resolves .228's DID → extracts public key → verifies signature. Fix any issues in the verification flow. +- [x] **Test cross-node credential issuance and verification**: From .228, issue a Verifiable Credential where .228 is the issuer and .198's DID is the subject. Use the Credentials UI or RPC: `curl -s http://localhost:5678/rpc/v1 -H 'Content-Type: application/json' -d '{"method":"identity.issue-credential","params":{"subject_did":"<.198-did>","credential_type":"FederationMember","claims":{"role":"peer","joined":"2026-03-15"}}}'`. Copy the credential ID. From .198, verify the credential: `curl -s http://localhost:5678/rpc/v1 -H 'Content-Type: application/json' -d '{"method":"identity.verify-credential","params":{"credential_id":""}}'`. If .198 can't verify (it needs .228's public key), test the resolution chain: .198 resolves .228's DID → extracts public key → verifies signature. Fix any issues in the verification flow. -- [ ] **Test federation trust via DIDs between .228 and .198**: Verify the federation between the two nodes uses DID-based identity. SSH to .228: `curl -s http://localhost:5678/rpc/v1 -H 'Content-Type: application/json' -d '{"method":"federation.list-nodes"}'`. Check that .198 appears as a peer with its DID. SSH to .198 and verify .228 appears similarly. If federation is not set up between them, establish it: use `federation.invite` on .228 to generate an invite, then `federation.join` on .198. After joining, verify: (1) both nodes see each other in their peer lists, (2) both nodes have each other's DIDs, (3) peer health checks pass between them. Check logs for federation errors: `sudo journalctl -u archipelago --since "10 min ago" | grep -i federation`. +- [x] **Test federation trust via DIDs between .228 and .198**: Verify the federation between the two nodes uses DID-based identity. SSH to .228: `curl -s http://localhost:5678/rpc/v1 -H 'Content-Type: application/json' -d '{"method":"federation.list-nodes"}'`. Check that .198 appears as a peer with its DID. SSH to .198 and verify .228 appears similarly. If federation is not set up between them, establish it: use `federation.invite` on .228 to generate an invite, then `federation.join` on .198. After joining, verify: (1) both nodes see each other in their peer lists, (2) both nodes have each other's DIDs, (3) peer health checks pass between them. Check logs for federation errors: `sudo journalctl -u archipelago --since "10 min ago" | grep -i federation`. -- [ ] **Test DWN sync between .228 and .198**: Even though DWN is deprioritized, test the existing sync functionality. On .228, write a test DWN message: `curl -s http://localhost:5678/rpc/v1 -H 'Content-Type: application/json' -d '{"method":"dwn.write","params":{"protocol":"https://archipelago.dev/protocols/file-catalog/v1","data":{"filename":"test.txt","size":1024}}}'`. Check DWN status on both nodes: `curl -s http://localhost:5678/rpc/v1 -d '{"method":"dwn.status"}'`. If sync is working, the message should appear on .198 after a sync cycle. If sync is not working, document what fails and where — this informs whether to invest more or formally pause DWN development. Don't spend more than 15 minutes debugging — document findings either way. +- [x] **Test DWN sync between .228 and .198**: Even though DWN is deprioritized, test the existing sync functionality. On .228, write a test DWN message: `curl -s http://localhost:5678/rpc/v1 -H 'Content-Type: application/json' -d '{"method":"dwn.write","params":{"protocol":"https://archipelago.dev/protocols/file-catalog/v1","data":{"filename":"test.txt","size":1024}}}'`. Check DWN status on both nodes: `curl -s http://localhost:5678/rpc/v1 -d '{"method":"dwn.status"}'`. If sync is working, the message should appear on .198 after a sync cycle. If sync is not working, document what fails and where — this informs whether to invest more or formally pause DWN development. Don't spend more than 15 minutes debugging — document findings either way. --- @@ -136,19 +136,19 @@ > **Goal**: Be able to factory reset the node, go through onboarding (DID + Nostr key created together), keys loaded into identity management, sign into IndeedHub with native Nostr signer, content loads. Also: restore from backup on the very first screen. -- [ ] **Implement system.factory-reset RPC endpoint**: Create a new RPC handler in `core/archipelago/src/api/rpc/system.rs` (or add to an existing system module). The `system.factory-reset` method should: (1) require authentication (admin only), (2) accept `{ confirm: true }` param as a safety check, (3) stop all running containers via `PodmanClient` (iterate `podman ps -q` and stop each), (4) delete user data: remove `{data_dir}/user.json`, `{data_dir}/onboarding.json`, `{data_dir}/identities/` directory, `{data_dir}/credentials/` directory, `{data_dir}/peers.json`, `{data_dir}/did-cache/` directory, `{data_dir}/dwn/` directory, (5) keep container images (don't re-download), keep the `identity/node_key` (node identity persists — it's the hardware identity), keep nginx and systemd configs, (6) clear all sessions from the session store, (7) restart the Archipelago service: `sd_notify::notify(false, &[sd_notify::NotifyState::Reloading])` then exit the process (systemd will restart it), or alternatively use `std::process::Command::new("sudo").args(["systemctl", "restart", "archipelago"]).spawn()`. Register the handler in `core/archipelago/src/api/rpc/mod.rs`. Run `cargo clippy --all-targets --all-features && cargo test --all-features` on the dev server. +- [x] **Implement system.factory-reset RPC endpoint**: Create a new RPC handler in `core/archipelago/src/api/rpc/system.rs` (or add to an existing system module). The `system.factory-reset` method should: (1) require authentication (admin only), (2) accept `{ confirm: true }` param as a safety check, (3) stop all running containers via `PodmanClient` (iterate `podman ps -q` and stop each), (4) delete user data: remove `{data_dir}/user.json`, `{data_dir}/onboarding.json`, `{data_dir}/identities/` directory, `{data_dir}/credentials/` directory, `{data_dir}/peers.json`, `{data_dir}/did-cache/` directory, `{data_dir}/dwn/` directory, (5) keep container images (don't re-download), keep the `identity/node_key` (node identity persists — it's the hardware identity), keep nginx and systemd configs, (6) clear all sessions from the session store, (7) restart the Archipelago service: `sd_notify::notify(false, &[sd_notify::NotifyState::Reloading])` then exit the process (systemd will restart it), or alternatively use `std::process::Command::new("sudo").args(["systemctl", "restart", "archipelago"]).spawn()`. Register the handler in `core/archipelago/src/api/rpc/mod.rs`. Run `cargo clippy --all-targets --all-features && cargo test --all-features` on the dev server. -- [ ] **Add factory reset button to Settings.vue**: In `neode-ui/src/views/Settings.vue`, add a "Factory Reset" section at the very bottom of the page (after all other settings). Use a `.path-option-card` container with a red-tinted warning. Include: (1) heading "Factory Reset", (2) description "Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.", (3) a `.glass-button` styled with red text/border that says "Factory Reset", (4) on click, show a confirmation dialog (use a simple `v-if` modal with `.glass-card` styling) asking "Are you sure? This will delete all identities, credentials, and settings. This cannot be undone." with Cancel and "Yes, Reset" buttons, (5) on confirm, call `rpcClient.call({ method: 'system.factory-reset', params: { confirm: true } })`, (6) on success, clear all localStorage (`localStorage.clear()`), redirect to `/onboarding/intro`. Use existing glass styles only — no new CSS classes. Run `cd neode-ui && npm run type-check`. +- [x] **Add factory reset button to Settings.vue**: In `neode-ui/src/views/Settings.vue`, add a "Factory Reset" section at the very bottom of the page (after all other settings). Use a `.path-option-card` container with a red-tinted warning. Include: (1) heading "Factory Reset", (2) description "Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.", (3) a `.glass-button` styled with red text/border that says "Factory Reset", (4) on click, show a confirmation dialog (use a simple `v-if` modal with `.glass-card` styling) asking "Are you sure? This will delete all identities, credentials, and settings. This cannot be undone." with Cancel and "Yes, Reset" buttons, (5) on confirm, call `rpcClient.call({ method: 'system.factory-reset', params: { confirm: true } })`, (6) on success, clear all localStorage (`localStorage.clear()`), redirect to `/onboarding/intro`. Use existing glass styles only — no new CSS classes. Run `cd neode-ui && npm run type-check`. -- [ ] **Add "Restore from Backup" button to OnboardingIntro.vue (first screen)**: In `neode-ui/src/views/OnboardingIntro.vue`, this is the very first screen a user sees after a fresh install or factory reset. Currently it just has a "Unlock your sovereignty →" button. Add a "Restore from Backup" link below it. Implementation: (1) add `showRestore` and `restoreFile` and `passphrase` refs, (2) below the main CTA button, add a subtle text link "Restore from backup" (style: `text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center`), (3) clicking it toggles a restore panel (use `.glass-card`) with: a file input (``) for the `archipelago-did-backup.json` file, a password input for the backup passphrase, and a "Restore" `.glass-button`, (4) on file select, read the JSON with `FileReader`, (5) on Restore click, call `rpcClient.call({ method: 'backup.restore-identity', params: { backup: parsedJson, passphrase: password } })`, (6) on success, show "Identity restored successfully" message, then navigate to `/onboarding/did` — the DID step will now show the restored DID instead of generating a new one. Run `cd neode-ui && npm run type-check`. +- [x] **Add "Restore from Backup" button to OnboardingIntro.vue (first screen)**: In `neode-ui/src/views/OnboardingIntro.vue`, this is the very first screen a user sees after a fresh install or factory reset. Currently it just has a "Unlock your sovereignty →" button. Add a "Restore from Backup" link below it. Implementation: (1) add `showRestore` and `restoreFile` and `passphrase` refs, (2) below the main CTA button, add a subtle text link "Restore from backup" (style: `text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center`), (3) clicking it toggles a restore panel (use `.glass-card`) with: a file input (``) for the `archipelago-did-backup.json` file, a password input for the backup passphrase, and a "Restore" `.glass-button`, (4) on file select, read the JSON with `FileReader`, (5) on Restore click, call `rpcClient.call({ method: 'backup.restore-identity', params: { backup: parsedJson, passphrase: password } })`, (6) on success, show "Identity restored successfully" message, then navigate to `/onboarding/did` — the DID step will now show the restored DID instead of generating a new one. Run `cd neode-ui && npm run type-check`. -- [ ] **Implement backup.restore-identity RPC for DID restore**: Check if `core/archipelago/src/api/rpc/backup_rpc.rs` has an identity-specific restore handler. The existing `backup.restore` is for full system backups (tar archives from USB). We need a lighter `backup.restore-identity` that: (1) accepts the JSON blob from `node.createBackup` (the `archipelago-did-backup.json` file), (2) extracts: version, encrypted blob, (3) decrypts with Argon2 + ChaCha20-Poly1305 using the provided passphrase (reverse of `backup::create_encrypted_backup()` in `core/archipelago/src/backup/identity.rs`), (4) writes the decrypted 32-byte Ed25519 private key to `{data_dir}/identity/node_key` with 0o600 permissions, (5) returns `{ did, pubkey }` of the restored identity. If the `backup/identity.rs` module already has a `restore_encrypted_backup()` function, use it. If not, create one following the inverse of `create_encrypted_backup()`. Register the handler in `rpc/mod.rs`. Run `cargo clippy --all-targets --all-features && cargo test --all-features`. +- [x] **Implement backup.restore-identity RPC for DID restore**: Check if `core/archipelago/src/api/rpc/backup_rpc.rs` has an identity-specific restore handler. The existing `backup.restore` is for full system backups (tar archives from USB). We need a lighter `backup.restore-identity` that: (1) accepts the JSON blob from `node.createBackup` (the `archipelago-did-backup.json` file), (2) extracts: version, encrypted blob, (3) decrypts with Argon2 + ChaCha20-Poly1305 using the provided passphrase (reverse of `backup::create_encrypted_backup()` in `core/archipelago/src/backup/identity.rs`), (4) writes the decrypted 32-byte Ed25519 private key to `{data_dir}/identity/node_key` with 0o600 permissions, (5) returns `{ did, pubkey }` of the restored identity. If the `backup/identity.rs` module already has a `restore_encrypted_backup()` function, use it. If not, create one following the inverse of `create_encrypted_backup()`. Register the handler in `rpc/mod.rs`. Run `cargo clippy --all-targets --all-features && cargo test --all-features`. -- [ ] **Ensure DID + Nostr keypair exist immediately from boot / factory reset**: The node's Ed25519 key is auto-generated at first boot (stored in `identity/node_key`), and `node.did` / `node.nostr-pubkey` RPCs derive from it. But user identities with Nostr keys are only created when the user reaches the Identity step in onboarding. Fix this so keys are available from the very start: (1) In `core/archipelago/src/main.rs` or `server.rs`, during startup (after loading node identity but before starting the HTTP server), check if any identities exist via `IdentityManager::list()`. If the list is empty (fresh boot or factory reset), auto-create a default identity: call `identity_manager.create("Default", IdentityPurpose::Personal)` — this generates Ed25519 + Nostr keypair automatically. (2) Verify `identity_manager.rs` `create()` method calls `create_nostr_key()` automatically — if not, add it after keypair generation. (3) This means when `OnboardingDid.vue` loads, both `node.did` AND `identity.list` already return data with Nostr npub populated. The identity step in onboarding can then let the user rename or create additional identities, but the default is already there. (4) After factory reset (which deletes `{data_dir}/identities/`), the next boot auto-creates the default identity again. Run `cargo test --all-features` on the dev server. +- [x] **Ensure DID + Nostr keypair exist immediately from boot / factory reset**: The node's Ed25519 key is auto-generated at first boot (stored in `identity/node_key`), and `node.did` / `node.nostr-pubkey` RPCs derive from it. But user identities with Nostr keys are only created when the user reaches the Identity step in onboarding. Fix this so keys are available from the very start: (1) In `core/archipelago/src/main.rs` or `server.rs`, during startup (after loading node identity but before starting the HTTP server), check if any identities exist via `IdentityManager::list()`. If the list is empty (fresh boot or factory reset), auto-create a default identity: call `identity_manager.create("Default", IdentityPurpose::Personal)` — this generates Ed25519 + Nostr keypair automatically. (2) Verify `identity_manager.rs` `create()` method calls `create_nostr_key()` automatically — if not, add it after keypair generation. (3) This means when `OnboardingDid.vue` loads, both `node.did` AND `identity.list` already return data with Nostr npub populated. The identity step in onboarding can then let the user rename or create additional identities, but the default is already there. (4) After factory reset (which deletes `{data_dir}/identities/`), the next boot auto-creates the default identity again. Run `cargo test --all-features` on the dev server. -- [ ] **Deploy factory reset + restore and test the full cycle**: Deploy with `./scripts/deploy-to-target.sh --live`. Then run the end-to-end test on .228: (1) Login at `http://192.168.1.228`, go to Settings, scroll to bottom, click "Factory Reset", confirm, (2) node restarts — wait 10-15 seconds, refresh browser, (3) should see the onboarding intro screen, (4) go through: Intro → Path → DID (should show new or existing DID + Nostr npub) → Identity (create "Personal" identity) → Backup (download backup file) → Verify (signature verified) → Done → Login, (5) set password, login, (6) navigate to Web5/Identity page — DID and Nostr npub should display, (7) go to Apps → click IndeedHub, (8) NostrIdentityPicker should appear — select the identity just created, (9) IndeedHub should load in iframe, (10) IndeedHub should request `window.nostr.getPublicKey()` — Archy returns the identity's Nostr pubkey, (11) if IndeedHub requires signing, NostrSignConsent appears, approve it, (12) IndeedHub content should load from their API (videos, pages). Check logs: `ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago --since "5 min ago" | grep -iE "(factory|reset|onboard|identity|nostr|indeedhub)"'`. +- [x] **Deploy factory reset + restore and test the full cycle**: Deploy with `./scripts/deploy-to-target.sh --live`. Then run the end-to-end test on .228: (1) Login at `http://192.168.1.228`, go to Settings, scroll to bottom, click "Factory Reset", confirm, (2) node restarts — wait 10-15 seconds, refresh browser, (3) should see the onboarding intro screen, (4) go through: Intro → Path → DID (should show new or existing DID + Nostr npub) → Identity (create "Personal" identity) → Backup (download backup file) → Verify (signature verified) → Done → Login, (5) set password, login, (6) navigate to Web5/Identity page — DID and Nostr npub should display, (7) go to Apps → click IndeedHub, (8) NostrIdentityPicker should appear — select the identity just created, (9) IndeedHub should load in iframe, (10) IndeedHub should request `window.nostr.getPublicKey()` — Archy returns the identity's Nostr pubkey, (11) if IndeedHub requires signing, NostrSignConsent appears, approve it, (12) IndeedHub content should load from their API (videos, pages). Check logs: `ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago --since "5 min ago" | grep -iE "(factory|reset|onboard|identity|nostr|indeedhub)"'`. -- [ ] **Test restore from backup on fresh state**: After the previous test, do another factory reset on .228. This time: (1) when the first screen appears (Login.vue in setup mode), click "Restore from Backup", (2) select the `archipelago-did-backup.json` file downloaded in the previous test, (3) enter the backup passphrase, (4) click Restore, (5) should see success message, (6) continue onboarding — the DID step should show the SAME DID as before (restored from backup), (7) create identity, complete onboarding, (8) login and verify: same DID, identity management has the restored keys, (9) go to IndeedHub — Nostr signing should work with the restored identity. If any step fails, check: backend logs for restore errors, frontend console for RPC failures, verify the backup file format matches what `backup.restore-identity` expects. +- [x] **Test restore from backup on fresh state**: After the previous test, do another factory reset on .228. This time: (1) when the first screen appears (Login.vue in setup mode), click "Restore from Backup", (2) select the `archipelago-did-backup.json` file downloaded in the previous test, (3) enter the backup passphrase, (4) click Restore, (5) should see success message, (6) continue onboarding — the DID step should show the SAME DID as before (restored from backup), (7) create identity, complete onboarding, (8) login and verify: same DID, identity management has the restored keys, (9) go to IndeedHub — Nostr signing should work with the restored identity. If any step fails, check: backend logs for restore errors, frontend console for RPC failures, verify the backup file format matches what `backup.restore-identity` expects. --- diff --git a/neode-ui/src/views/OnboardingIntro.vue b/neode-ui/src/views/OnboardingIntro.vue index ae4b02ee..679dbacd 100644 --- a/neode-ui/src/views/OnboardingIntro.vue +++ b/neode-ui/src/views/OnboardingIntro.vue @@ -23,20 +23,103 @@ > Unlock your sovereignty → + + + Restore from backup + + + +
+

Restore Identity from Backup

+ + +

{{ restoreError }}

+

Identity restored successfully!

+
+ + +
+