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 de8dcee155
commit d1e14c4269
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();

View File

@@ -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":"<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":"<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 (`<input type="file" accept=".json">`) 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 (`<input type="file" accept=".json">`) 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.
---

View File

@@ -23,20 +23,103 @@
>
Unlock your sovereignty
</button>
<a
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
@click="showRestore = true"
>
Restore from backup
</a>
<!-- Restore Panel -->
<div v-if="showRestore" class="mt-6 glass-card px-6 py-6 text-left">
<h3 class="text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Restore Identity from Backup</h3>
<input
type="file"
accept=".json"
class="block w-full text-sm text-white/60 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white/80 hover:file:bg-white/20 mb-3"
@change="onFileSelect"
/>
<input
v-model="passphrase"
type="password"
placeholder="Backup passphrase"
class="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-sm focus:outline-none focus:border-white/40 mb-3"
/>
<p v-if="restoreError" class="text-red-400 text-xs mb-2">{{ restoreError }}</p>
<p v-if="restoreSuccess" class="text-green-400 text-xs mb-2">Identity restored successfully!</p>
<div class="flex gap-3">
<button class="glass-button text-sm px-4 py-2" @click="showRestore = false">Cancel</button>
<button
class="glass-button text-sm px-4 py-2"
:disabled="!restoreFile || !passphrase || restoreLoading"
@click="performRestore"
>
{{ restoreLoading ? 'Restoring...' : 'Restore' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { rpcClient } from '@/api/rpc-client'
const router = useRouter()
function goToOptions() {
router.push('/onboarding/path').catch(() => {})
}
// Restore from backup
const showRestore = ref(false)
const restoreFile = ref<Record<string, unknown> | null>(null)
const passphrase = ref('')
const restoreLoading = ref(false)
const restoreError = ref('')
const restoreSuccess = ref(false)
function onFileSelect(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
try {
restoreFile.value = JSON.parse(reader.result as string)
restoreError.value = ''
} catch {
restoreError.value = 'Invalid backup file format'
restoreFile.value = null
}
}
reader.readAsText(file)
}
async function performRestore() {
if (!restoreFile.value || !passphrase.value) return
restoreLoading.value = true
restoreError.value = ''
try {
await rpcClient.call({
method: 'backup.restore-identity',
params: { backup: restoreFile.value, passphrase: passphrase.value },
})
restoreSuccess.value = true
setTimeout(() => {
router.push('/onboarding/did')
}, 1500)
} catch (err) {
restoreError.value = err instanceof Error ? err.message : 'Restore failed'
} finally {
restoreLoading.value = false
}
}
</script>
<style scoped>

View File

@@ -818,6 +818,42 @@
</button>
</div>
</div>
<!-- Factory Reset Section -->
<div class="path-option-card px-6 py-6 mt-6 border-red-500/30">
<h2 class="text-xl font-semibold text-red-400/90 mb-3">Factory Reset</h2>
<p class="text-sm text-white/60 mb-4">
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.
</p>
<button
class="glass-button text-red-400 border-red-500/30 hover:border-red-500/50"
@click="showFactoryResetConfirm = true"
>
Factory Reset
</button>
</div>
<!-- Factory Reset Confirmation Modal -->
<Teleport to="body">
<div v-if="showFactoryResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="glass-card px-8 py-8 max-w-md mx-4">
<h3 class="text-lg font-semibold text-white/90 mb-3">Are you sure?</h3>
<p class="text-sm text-white/60 mb-6">
This will delete all identities, credentials, and settings. This cannot be undone.
</p>
<div class="flex gap-3 justify-end">
<button class="glass-button" @click="showFactoryResetConfirm = false">Cancel</button>
<button
class="glass-button text-red-400 border-red-500/30"
:disabled="factoryResetLoading"
@click="performFactoryReset"
>
{{ factoryResetLoading ? 'Resetting...' : 'Yes, Reset' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
@@ -837,6 +873,24 @@ import type { UIMode } from '@/types/api'
const router = useRouter()
const { t, locale } = useI18n()
const store = useAppStore()
// Factory Reset
const showFactoryResetConfirm = ref(false)
const factoryResetLoading = ref(false)
async function performFactoryReset() {
factoryResetLoading.value = true
try {
await rpcClient.call({ method: 'system.factory-reset', params: { confirm: true } })
localStorage.clear()
showFactoryResetConfirm.value = false
router.push('/onboarding/intro')
} catch (err) {
// Service likely restarted — redirect anyway
localStorage.clear()
showFactoryResetConfirm.value = false
router.push('/onboarding/intro')
}
}
const supportedLocales = SUPPORTED_LOCALES
const currentLocale = computed(() => locale.value)
async function changeLocale(code: string) {