From a687df9bd9e0b637763e541af1d86e5b1291953a Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 16 Jul 2026 10:49:37 -0400 Subject: [PATCH] feat(backup): include secrets (LND seed key) in backups + Download button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full backup carried identity/lnd_aezeed.enc but NOT the per-node wallet secret that encrypts it (secrets/lnd-wallet-password), so a restored node could never decrypt its Lightning seed. The secrets dir now rides the backup (archive v3; the .bak itself is passphrase-encrypted). Restore gains a staging-presence guard so restoring an older archive without a secrets dir can no longer displace and delete the node's live secrets — covered by two new tests. Backups can now also be downloaded through the browser: a session-gated GET /api/blob/backup/ endpoint (under the existing nginx /api/blob prefix, so no fleet nginx changes) plus a Download button next to Verify / USB / Restore / Delete in Settings → Backup & Restore. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/handler/mod.rs | 43 +++++++++++++ core/archipelago/src/backup/full.rs | 60 ++++++++++++++++++- neode-ui/src/views/settings/BackupSection.vue | 28 +++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 20d8207f..5aa9d523 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -194,6 +194,39 @@ impl ApiHandler { )) } + /// Serve an encrypted backup archive (`/backups/.bak`) as a + /// browser download. The archive is passphrase-encrypted at rest; the + /// session gate at the route controls who can fetch it. + async fn handle_backup_download(&self, path: &str) -> Result> { + let id = path.strip_prefix("/api/blob/backup/").unwrap_or(""); + // Backup ids are UUIDs — reject anything that could traverse paths. + if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from(r#"{"error":"invalid backup id"}"#), + )); + } + let file = self.config.data_dir.join("backups").join(format!("{id}.bak")); + match tokio::fs::read(&file).await { + Ok(bytes) => Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/octet-stream") + .header( + "Content-Disposition", + format!("attachment; filename=\"archipelago-backup-{id}.bak\""), + ) + .header("Content-Length", bytes.len()) + .body(hyper::Body::from(bytes)) + .unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))), + Err(_) => Ok(build_response( + StatusCode::NOT_FOUND, + "application/json", + hyper::Body::from(r#"{"error":"backup not found"}"#), + )), + } + } + /// Build a 401 Unauthorized JSON response. fn unauthorized() -> Response { let body = serde_json::json!({ "error": "Unauthorized" }); @@ -434,6 +467,16 @@ impl ApiHandler { Self::handle_mesh_typed_relay(self.rpc_handler.clone(), body_bytes).await } + // Backup archive download — session-gated. Lives under /api/blob/ + // so the existing nginx `location /api/blob` prefix proxies it on + // every fleet node without a config change. + (Method::GET, p) if p.starts_with("/api/blob/backup/") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + self.handle_backup_download(p).await + } + // Blob upload — local/session use only. Session-authenticated so // only the node owner can push attachments into the blob store. (Method::POST, "/api/blob") => { diff --git a/core/archipelago/src/backup/full.rs b/core/archipelago/src/backup/full.rs index 6164ef50..2eceef7b 100644 --- a/core/archipelago/src/backup/full.rs +++ b/core/archipelago/src/backup/full.rs @@ -29,6 +29,11 @@ const BACKUP_DIRS: &[&str] = &[ "credentials", "tor-config", "content", + // Per-node service secrets. Without these a restored node can't + // decrypt identity/lnd_aezeed.enc (the Lightning seed backup is + // encrypted with secrets/lnd-wallet-password) or reuse its service + // credentials. The archive itself is passphrase-encrypted. + "secrets", ]; /// Files within data_dir to include in a full backup. @@ -101,7 +106,9 @@ pub async fn create_full_backup( // Step 4: Write metadata let metadata = BackupMetadata { id: backup_id, - version: 2, + // v3: archive additionally carries the `secrets` dir (needed to + // decrypt identity/lnd_aezeed.enc on a restored node). + version: 3, created_at: timestamp, encrypted: true, size_bytes: encrypted.len() as u64, @@ -193,6 +200,14 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: & .context("Failed to create rollback directory")?; for dir_name in BACKUP_DIRS { + // Only displace live data the backup will actually replace — + // older archives don't contain every current BACKUP_DIRS entry + // (e.g. `secrets` was added later), and moving a live dir to + // rollback with nothing staged to take its place would DELETE it + // at cleanup time. + if !staging_dir.join(dir_name).exists() { + continue; + } let src = data_dir.join(dir_name); if src.exists() { let dst = rollback_dir.join(dir_name); @@ -207,6 +222,9 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: & } } for file_name in BACKUP_FILES { + if !staging_dir.join(file_name).exists() { + continue; + } let src = data_dir.join(file_name); if src.exists() { let dst = rollback_dir.join(file_name); @@ -732,6 +750,46 @@ mod tests { assert!(!bad_result.valid); } + #[tokio::test] + async fn secrets_dir_rides_backup_and_restore() { + // The Lightning seed backup (identity/lnd_aezeed.enc) is encrypted + // with secrets/lnd-wallet-password — a backup that omits it can't + // recover the Lightning wallet on restore. + let dir = TempDir::new().unwrap(); + setup_data_dir(dir.path()); + std::fs::create_dir_all(dir.path().join("secrets")).unwrap(); + std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "s3cret").unwrap(); + + let meta = create_full_backup(dir.path(), "pass", None).await.unwrap(); + + std::fs::remove_dir_all(dir.path().join("secrets")).unwrap(); + restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap(); + + let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap(); + assert_eq!(pw, "s3cret"); + } + + #[tokio::test] + async fn restoring_old_backup_without_secrets_keeps_live_secrets() { + // Archives created before `secrets` joined BACKUP_DIRS don't stage + // one — the restore must NOT displace (and then delete) the node's + // live secrets in that case. + let dir = TempDir::new().unwrap(); + setup_data_dir(dir.path()); + + // Backup taken while no secrets dir existed (mimics an old archive). + let meta = create_full_backup(dir.path(), "pass", None).await.unwrap(); + + // Live secrets appear afterwards. + std::fs::create_dir_all(dir.path().join("secrets")).unwrap(); + std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "keep-me").unwrap(); + + restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap(); + + let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap(); + assert_eq!(pw, "keep-me"); + } + #[tokio::test] async fn backup_and_restore() { let dir = TempDir::new().unwrap(); diff --git a/neode-ui/src/views/settings/BackupSection.vue b/neode-ui/src/views/settings/BackupSection.vue index a153fcc0..a4580277 100644 --- a/neode-ui/src/views/settings/BackupSection.vue +++ b/neode-ui/src/views/settings/BackupSection.vue @@ -126,6 +126,31 @@ async function deleteBackup(id: string) { } } +// Download the encrypted backup archive through the browser — the only +// path off the node for remote/companion users with no USB access. +const downloadingId = ref(null) +async function downloadBackup(id: string) { + downloadingId.value = id + try { + const res = await fetch(`/api/blob/backup/${encodeURIComponent(id)}`, { credentials: 'include' }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `archipelago-backup-${id}.bak` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + showBackupStatus('Backup download started', 'success') + } catch { + showBackupStatus('Backup download failed', 'error') + } finally { + downloadingId.value = null + } +} + // Recovery phrase reveal — re-auth gated (password + 2FA when enabled). const showRevealModal = ref(false) const revealPassword = ref('') @@ -364,6 +389,9 @@ defineExpose({ loadBackups }) +