feat(backup): include secrets (LND seed key) in backups + Download button
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m32s

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/<id> 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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-16 10:49:37 -04:00
parent 0aa9463b36
commit a687df9bd9
3 changed files with 130 additions and 1 deletions

View File

@ -194,6 +194,39 @@ impl ApiHandler {
))
}
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.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<Response<hyper::Body>> {
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<hyper::Body> {
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") => {

View File

@ -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();

View File

@ -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<string | null>(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 })
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" :title="t('settings.copyToUsb')">
{{ usbCopyingId === b.id ? '...' : 'USB' }}
</button>
<button @click="downloadBackup(b.id)" :disabled="downloadingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-green-400 disabled:opacity-50" title="Download backup file">
{{ downloadingId === b.id ? '...' : 'Download' }}
</button>
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" :title="t('common.restore')">
{{ t('common.restore') }}
</button>