fix: overhaul backup/restore — full secrets coverage, ordered reapply, optional passphrase encryption
Unit Tests / test (push) Successful in 12m25s

P0 — backups previously omitted peers/keys/vault(CA+fernet)/auth/cell-links/ddns/connectivity
configs (a restore lost everything incl admin login + CA) and included logs/trash; restore did
file-copies only with no reapply.

Changes:
- api/config_manager.py: backup_config now includes auth_users.json, .flask_secret_key,
  peers.json, peer_service_credentials.json, WireGuard keys + wg_confs + api/wireguard/keys,
  vault/** (incl fernet.key), api/services + service configs, cell_links.json, ddns_token,
  caddy/**; new _is_excluded() drops logs/config_backups/.test_admin_pass/.gitkeep/*.tmp/
  *.partial/__pycache__; restore_config reordered (vault/fernet → config → wg keys/peers →
  cell_links → caddy/dns → service configs → auth/ddns → volumes) + new _reapply_runtime_state()
  (regenerate Caddyfile/Corefile, reapply services, connectivity apply_routes, replay cell pushes)
- api/backup_crypto.py (new): optional passphrase encryption via scrypt-derived key + Fernet;
  encrypted archives written 0600
- api/routes/config.py: backup/restore accept optional {passphrase}; wrong/missing passphrase
  returns 400; backup response warns it contains secrets
- Makefile: backup target applies same excludes + chmod 0600 + secrets warning
- webui/src/services/api.js + webui/src/pages/Settings.jsx: passphrase field on create backup,
  restore prompt, "contains secrets" banner
- tests/test_config_backup_overhaul.py (new, 18 tests) + tests/test_config_backup_restore_http.py
  (2 assertions updated)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:41:10 -04:00
parent c3ba82251a
commit 82a0c0e9bd
8 changed files with 743 additions and 71 deletions
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Passphrase-based encryption for PIC backup archives.
A backup archive contains key material (WireGuard keys, the vault Fernet key,
the internal CA, admin credentials). When the operator supplies a passphrase we
encrypt the archive at rest.
The repo's only available crypto primitive is `cryptography` (Fernet, scrypt) —
PyNaCl / the age binary are not installed in the API image. We therefore derive
a Fernet key from the passphrase with scrypt and wrap the archive bytes. The
encrypted file keeps the `.age` extension expected by the UI/restore detection;
the embedded MAGIC distinguishes our format from a real age file.
"""
import os
import struct
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
import base64
# File layout: MAGIC | salt(16) | n(4) | r(4) | p(4) | fernet_token
MAGIC = b'PICBKP1\n'
_SALT_LEN = 16
# scrypt cost parameters (interactive-strong; ~tens of ms)
_N = 2 ** 15
_R = 8
_P = 1
class BackupDecryptError(Exception):
"""Raised when an encrypted backup cannot be decrypted (wrong passphrase)."""
def _derive_key(passphrase: str, salt: bytes, n: int, r: int, p: int) -> bytes:
kdf = Scrypt(salt=salt, length=32, n=n, r=r, p=p)
raw = kdf.derive(passphrase.encode('utf-8'))
return base64.urlsafe_b64encode(raw)
def encrypt_bytes(plaintext: bytes, passphrase: str) -> bytes:
"""Encrypt archive bytes with a passphrase. Returns the on-disk blob."""
if not passphrase:
raise ValueError('passphrase required for encryption')
salt = os.urandom(_SALT_LEN)
key = _derive_key(passphrase, salt, _N, _R, _P)
token = Fernet(key).encrypt(plaintext)
header = MAGIC + salt + struct.pack('>III', _N, _R, _P)
return header + token
def is_encrypted(blob: bytes) -> bool:
return blob[:len(MAGIC)] == MAGIC
def decrypt_bytes(blob: bytes, passphrase: str) -> bytes:
"""Decrypt a blob produced by encrypt_bytes. Raises BackupDecryptError."""
if not is_encrypted(blob):
raise BackupDecryptError('not a PIC encrypted backup')
if not passphrase:
raise BackupDecryptError('passphrase required')
off = len(MAGIC)
salt = blob[off:off + _SALT_LEN]
off += _SALT_LEN
n, r, p = struct.unpack('>III', blob[off:off + 12])
off += 12
token = blob[off:]
key = _derive_key(passphrase, salt, n, r, p)
try:
return Fernet(key).decrypt(token)
except (InvalidToken, ValueError) as e:
raise BackupDecryptError('invalid passphrase or corrupt archive') from e