diff --git a/backend/internal/backupcrypto/backupcrypto.go b/backend/internal/backupcrypto/backupcrypto.go new file mode 100644 index 0000000..d770edb --- /dev/null +++ b/backend/internal/backupcrypto/backupcrypto.go @@ -0,0 +1,132 @@ +// Package backupcrypto encrypts panel backups under an admin-chosen password so a +// backup file is fully self-contained: it can safely embed the deployment's +// ACCOUNT_KEY_ENCRYPTION_KEY and API_HMAC_MASTER_KEY (without which the accounts +// and api_keys tables are ciphertext) and still be handed to a fresh server whose +// deploy/.env no longer exists - the disaster-recovery case where the original +// server is simply gone. The password is the only thing the admin must retain. +// +// Construction: argon2id (same cost posture as authcrypto's password hashing) +// derives a 32-byte key from the password; AES-256-GCM seals the whole backup +// JSON. KDF parameters travel inside the envelope so they can be tuned later +// without breaking old files. +package backupcrypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + + "golang.org/x/crypto/argon2" +) + +// Format identifies the envelope; bump the suffix on any incompatible change. +const Format = "wgpanel-backup-enc/1" + +const ( + kdfTimeCost = 1 + kdfMemoryKB = 64 * 1024 + kdfThreads = 4 + keyLenByte = 32 + saltLenByte = 16 +) + +// ErrWrongPassword covers both a wrong password and a corrupted/tampered file - +// AES-GCM authentication cannot distinguish the two, and callers shouldn't try. +var ErrWrongPassword = errors.New("wrong password or corrupted backup file") + +type KDFParams struct { + Salt string `json:"salt"` // base64 (raw, unpadded) + TimeCost uint32 `json:"t"` + MemoryKB uint32 `json:"m"` + Threads uint8 `json:"p"` +} + +// Envelope is the outer, plaintext-JSON shape of an encrypted backup file. Only +// format/created_at are readable without the password. +type Envelope struct { + Format string `json:"format"` + CreatedAt string `json:"created_at"` + KDF KDFParams `json:"kdf"` + Nonce string `json:"nonce"` // base64 (raw, unpadded) + Data string `json:"data"` // base64 (raw, unpadded) AES-256-GCM ciphertext +} + +// Seal encrypts plaintext under password into a ready-to-serialize Envelope. +func Seal(password string, plaintext []byte, createdAt string) (Envelope, error) { + salt := make([]byte, saltLenByte) + if _, err := rand.Read(salt); err != nil { + return Envelope{}, fmt.Errorf("generate salt: %w", err) + } + key := argon2.IDKey([]byte(password), salt, kdfTimeCost, kdfMemoryKB, kdfThreads, keyLenByte) + + block, err := aes.NewCipher(key) + if err != nil { + return Envelope{}, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return Envelope{}, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return Envelope{}, fmt.Errorf("generate nonce: %w", err) + } + + enc := base64.RawStdEncoding + return Envelope{ + Format: Format, + CreatedAt: createdAt, + KDF: KDFParams{ + Salt: enc.EncodeToString(salt), + TimeCost: kdfTimeCost, + MemoryKB: kdfMemoryKB, + Threads: kdfThreads, + }, + Nonce: enc.EncodeToString(nonce), + Data: enc.EncodeToString(gcm.Seal(nil, nonce, plaintext, nil)), + }, nil +} + +// Open decrypts an Envelope with password, using the KDF parameters the file was +// sealed with. Returns ErrWrongPassword on authentication failure. +func Open(password string, env Envelope) ([]byte, error) { + if env.Format != Format { + return nil, fmt.Errorf("unrecognized backup format %q (expected %q)", env.Format, Format) + } + dec := base64.RawStdEncoding + salt, err := dec.DecodeString(env.KDF.Salt) + if err != nil { + return nil, fmt.Errorf("decode salt: %w", err) + } + nonce, err := dec.DecodeString(env.Nonce) + if err != nil { + return nil, fmt.Errorf("decode nonce: %w", err) + } + data, err := dec.DecodeString(env.Data) + if err != nil { + return nil, fmt.Errorf("decode data: %w", err) + } + // Guard the KDF cost against a hostile file that would have us allocate + // unbounded memory before authentication can reject it. + if env.KDF.MemoryKB > 1024*1024 || env.KDF.TimeCost > 16 || env.KDF.Threads == 0 { + return nil, fmt.Errorf("unreasonable KDF parameters in backup file") + } + + key := argon2.IDKey([]byte(password), salt, env.KDF.TimeCost, env.KDF.MemoryKB, env.KDF.Threads, keyLenByte) + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + plaintext, err := gcm.Open(nil, nonce, data, nil) + if err != nil { + return nil, ErrWrongPassword + } + return plaintext, nil +} diff --git a/backend/internal/backupcrypto/backupcrypto_test.go b/backend/internal/backupcrypto/backupcrypto_test.go new file mode 100644 index 0000000..1536591 --- /dev/null +++ b/backend/internal/backupcrypto/backupcrypto_test.go @@ -0,0 +1,73 @@ +package backupcrypto + +import ( + "bytes" + "encoding/json" + "errors" + "testing" +) + +func TestSealOpenRoundTrip(t *testing.T) { + plaintext := []byte(`{"tables":{"accounts":[]},"secret":"hunter2"}`) + + env, err := Seal("correct horse battery staple", plaintext, "2026-07-18T00:00:00Z") + if err != nil { + t.Fatal(err) + } + if env.Format != Format { + t.Errorf("format = %q, want %q", env.Format, Format) + } + + // The envelope must survive JSON serialization - that's the on-disk shape. + raw, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(raw, []byte("hunter2")) { + t.Fatal("plaintext leaked into the serialized envelope") + } + var decoded Envelope + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + + got, err := Open("correct horse battery staple", decoded) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, plaintext) { + t.Errorf("round trip mismatch: %q", got) + } +} + +func TestOpenWrongPassword(t *testing.T) { + env, err := Seal("right-password", []byte("data"), "2026-07-18T00:00:00Z") + if err != nil { + t.Fatal(err) + } + if _, err := Open("wrong-password", env); !errors.Is(err, ErrWrongPassword) { + t.Fatalf("expected ErrWrongPassword, got %v", err) + } +} + +func TestOpenTamperedData(t *testing.T) { + env, err := Seal("pw", []byte("data"), "2026-07-18T00:00:00Z") + if err != nil { + t.Fatal(err) + } + env.Data = env.Data[:len(env.Data)-2] + "AA" + if _, err := Open("pw", env); !errors.Is(err, ErrWrongPassword) { + t.Fatalf("expected ErrWrongPassword for tampered data, got %v", err) + } +} + +func TestOpenRejectsHostileKDFParams(t *testing.T) { + env, err := Seal("pw", []byte("data"), "2026-07-18T00:00:00Z") + if err != nil { + t.Fatal(err) + } + env.KDF.MemoryKB = 64 * 1024 * 1024 // 64GB - a memory-exhaustion attempt + if _, err := Open("pw", env); err == nil || errors.Is(err, ErrWrongPassword) { + t.Fatalf("expected a KDF-parameter rejection, got %v", err) + } +} diff --git a/backend/internal/httpapi/backup.go b/backend/internal/httpapi/backup.go index f11d05d..a85c0a3 100644 --- a/backend/internal/httpapi/backup.go +++ b/backend/internal/httpapi/backup.go @@ -1,13 +1,16 @@ -// Backup & restore via the panel (Settings -> Backup & restore): a single JSON -// file containing every config table plus the node-mTLS CA keypair - everything -// needed to rebuild the panel on a fresh server, EXCEPT deploy/.env. The backup is -// deliberately useless without that .env: account private keys inside it are -// still encrypted with ACCOUNT_KEY_ENCRYPTION_KEY (a key-canary field lets restore -// detect a mismatch up front instead of every config download failing later). +// Backup & restore via the panel (Settings -> Backup & restore): one +// password-encrypted file (see internal/backupcrypto) containing every config +// table, the node-mTLS CA keypair, AND the two .env keys the data is useless +// without (ACCOUNT_KEY_ENCRYPTION_KEY, API_HMAC_MASTER_KEY). Embedding the keys +// is what makes the backup survive total server loss - the original deploy/.env +// no longer needs to exist. Restore onto a server with different keys transparently +// re-encrypts every account private key and API-key secret to the new keys, so the +// restored panel works with the new .env as-is. // -// The file is as sensitive as the database itself (admin password hashes, the CA -// private key, subscription tokens) - it is only ever produced for, and accepted -// from, a super_admin, and both directions are audit-logged. +// The decrypted contents are as sensitive as the database itself (admin password +// hashes, the CA private key, subscription tokens, the encryption keys) - the file +// is only ever produced for, and accepted from, a super_admin, is never readable +// without the admin-chosen password, and both directions are audit-logged. package httpapi import ( @@ -21,35 +24,53 @@ import ( "slices" "time" + "wgpanel-api/internal/backupcrypto" "wgpanel-api/internal/wgkeys" ) -const backupFormat = "wgpanel-backup/1" - -// backupCanaryPlaintext is a fixed string encrypted with ACCOUNT_KEY_ENCRYPTION_KEY -// into every backup; restore decrypts it to prove the current deployment holds the -// same key the backed-up account private keys were encrypted with. -const backupCanaryPlaintext = "wgpanel-key-canary" +// backupPasswordMinLen guards against trivially brute-forceable backups; the +// argon2id KDF does the heavy lifting beyond that. +const backupPasswordMinLen = 8 type backupCA struct { CertPEM string `json:"cert_pem"` KeyPEM string `json:"key_pem"` } -type backupFile struct { - Format string `json:"format"` - CreatedAt string `json:"created_at"` +type backupSecrets struct { + AccountKeyEncryptionKey string `json:"account_key_encryption_key"` + APIHMACMasterKey string `json:"api_hmac_master_key"` +} + +// backupPayload is the plaintext sealed inside the backupcrypto envelope. +type backupPayload struct { Migrations []string `json:"migrations"` - KeyCanary string `json:"key_canary"` + Secrets backupSecrets `json:"secrets"` CA *backupCA `json:"ca,omitempty"` Tables map[string]json.RawMessage `json:"tables"` } -// handleDownloadBackup streams the full panel backup as an attachment. -// super_admin-only (wired via requireRole in server.go). +type downloadBackupRequest struct { + Password string `json:"password"` +} + +// handleDownloadBackup streams the encrypted panel backup as an attachment. POST, +// not GET, because the encryption password rides in the body. super_admin-only +// (wired via requireRole in server.go). func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + var req downloadBackupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid_request", "malformed JSON body") + return + } + if len(req.Password) < backupPasswordMinLen { + writeJSONError(w, http.StatusBadRequest, "invalid_request", + fmt.Sprintf("password must be at least %d characters - it is the ONLY way to open the backup", backupPasswordMinLen)) + return + } + migrations, err := s.Store.AppliedMigrations(ctx) if err != nil { s.Logger.Error("backup_migrations_failed", "error", err) @@ -62,20 +83,28 @@ func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusInternalServerError, "internal_error", "could not dump tables") return } - canary, err := wgkeys.Encrypt(s.AccountKeyEncryptionKey, backupCanaryPlaintext) + + payload, err := json.Marshal(backupPayload{ + Migrations: migrations, + Secrets: backupSecrets{ + AccountKeyEncryptionKey: s.AccountKeyEncryptionKey, + APIHMACMasterKey: s.APIHMACMasterKey, + }, + CA: s.readCAForBackup(), + Tables: tables, + }) if err != nil { - s.Logger.Error("backup_canary_failed", "error", err) + s.Logger.Error("backup_marshal_failed", "error", err) writeJSONError(w, http.StatusInternalServerError, "internal_error", "could not build backup") return } - backup := backupFile{ - Format: backupFormat, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - Migrations: migrations, - KeyCanary: canary, - CA: s.readCAForBackup(), - Tables: tables, + createdAt := time.Now().UTC() + envelope, err := backupcrypto.Seal(req.Password, payload, createdAt.Format(time.RFC3339)) + if err != nil { + s.Logger.Error("backup_seal_failed", "error", err) + writeJSONError(w, http.StatusInternalServerError, "internal_error", "could not encrypt backup") + return } if identity, ok := callerIdentityFromContext(ctx); ok { @@ -84,11 +113,11 @@ func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) { } } - filename := "wgpanel-backup-" + time.Now().UTC().Format("20060102T150405Z") + ".json" + filename := "wgpanel-backup-" + createdAt.Format("20060102T150405Z") + ".json" w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(backup); err != nil { + if err := json.NewEncoder(w).Encode(envelope); err != nil { s.Logger.Error("backup_write_failed", "error", err) } } @@ -113,6 +142,11 @@ func (s *Server) readCAForBackup() *backupCA { return &backupCA{CertPEM: string(cert), KeyPEM: string(key)} } +type restoreBackupRequest struct { + Password string `json:"password"` + Backup backupcrypto.Envelope `json:"backup"` +} + type restoreBackupResponse struct { Restored map[string]int `json:"restored"` // CARestored is true when the backup carried a CA keypair and it was written @@ -124,8 +158,8 @@ type restoreBackupResponse struct { } // handleRestoreBackup replaces ALL panel state with an uploaded backup file. -// super_admin-only. The three validations happen before anything is touched, in -// increasing order of specificity: file shape, schema version, encryption key. +// super_admin-only. Everything is validated - password, schema version, key +// re-encryption - before any state is touched. func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -133,19 +167,33 @@ func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { // realistic backup while still bounding a hostile upload. r.Body = http.MaxBytesReader(w, r.Body, 256<<20) - var backup backupFile - if err := json.NewDecoder(r.Body).Decode(&backup); err != nil { + var req restoreBackupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { writeJSONError(w, http.StatusRequestEntityTooLarge, "invalid_request", "backup file exceeds the 256MB limit") return } - writeJSONError(w, http.StatusBadRequest, "invalid_request", "not a valid JSON backup file") + writeJSONError(w, http.StatusBadRequest, "invalid_request", "not a valid backup upload") return } - if backup.Format != backupFormat { - writeJSONError(w, http.StatusBadRequest, "invalid_request", - fmt.Sprintf("unrecognized backup format %q (expected %q)", backup.Format, backupFormat)) + + plaintext, err := backupcrypto.Open(req.Password, req.Backup) + if errors.Is(err, backupcrypto.ErrWrongPassword) { + writeJSONError(w, http.StatusBadRequest, "wrong_password", "wrong password or corrupted backup file") + return + } + if err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + var backup backupPayload + if err := json.Unmarshal(plaintext, &backup); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid_request", "backup contents are not valid JSON") + return + } + if backup.Secrets.AccountKeyEncryptionKey == "" || backup.Secrets.APIHMACMasterKey == "" { + writeJSONError(w, http.StatusBadRequest, "invalid_request", "backup is missing its embedded encryption keys") return } @@ -164,10 +212,30 @@ func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { return } - if plaintext, err := wgkeys.Decrypt(s.AccountKeyEncryptionKey, backup.KeyCanary); err != nil || plaintext != backupCanaryPlaintext { - writeJSONError(w, http.StatusConflict, "encryption_key_mismatch", - "this backup's account keys were encrypted with a different ACCOUNT_KEY_ENCRYPTION_KEY - set the original key in deploy/.env before restoring") - return + // The backup carries the keys its ciphertext columns were encrypted with; if + // this deployment's keys differ (fresh .env after losing the old server), + // re-encrypt those columns to the current keys BEFORE restoring, so the + // restored panel works with the new .env as-is. Failures here abort before + // anything is touched. + if backup.Secrets.AccountKeyEncryptionKey != s.AccountKeyEncryptionKey { + reencrypted, err := reencryptRows(backup.Tables["accounts"], []string{"private_key_encrypted"}, + backup.Secrets.AccountKeyEncryptionKey, s.AccountKeyEncryptionKey) + if err != nil { + s.Logger.Error("restore_reencrypt_accounts_failed", "error", err) + writeJSONError(w, http.StatusBadRequest, "invalid_request", "could not re-encrypt account keys from this backup") + return + } + backup.Tables["accounts"] = reencrypted + } + if backup.Secrets.APIHMACMasterKey != s.APIHMACMasterKey { + reencrypted, err := reencryptRows(backup.Tables["api_keys"], []string{"secret_encrypted", "previous_secret_encrypted"}, + backup.Secrets.APIHMACMasterKey, s.APIHMACMasterKey) + if err != nil { + s.Logger.Error("restore_reencrypt_api_keys_failed", "error", err) + writeJSONError(w, http.StatusBadRequest, "invalid_request", "could not re-encrypt API key secrets from this backup") + return + } + backup.Tables["api_keys"] = reencrypted } counts, err := s.Store.RestoreTables(ctx, backup.Tables) @@ -201,6 +269,38 @@ func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +// reencryptRows rewrites the named wgkeys-encrypted columns in a json_agg rows +// blob from oldKey to newKey. Null/absent/empty values pass through untouched +// (previous_secret_encrypted is nullable). Missing/empty rows blobs (nothing to +// re-encrypt) pass through as-is. +func reencryptRows(rows json.RawMessage, columns []string, oldKey, newKey string) (json.RawMessage, error) { + if len(rows) == 0 { + return rows, nil + } + var list []map[string]any + if err := json.Unmarshal(rows, &list); err != nil { + return nil, err + } + for i, row := range list { + for _, col := range columns { + v, ok := row[col].(string) + if !ok || v == "" { + continue + } + plain, err := wgkeys.Decrypt(oldKey, v) + if err != nil { + return nil, fmt.Errorf("row %d, column %s: %w", i, col, err) + } + enc, err := wgkeys.Encrypt(newKey, plain) + if err != nil { + return nil, fmt.Errorf("row %d, column %s: %w", i, col, err) + } + row[col] = enc + } + } + return json.Marshal(list) +} + func (s *Server) writeRestoredCA(ca backupCA) error { if err := os.MkdirAll(s.CADataDir, 0o700); err != nil { return err diff --git a/backend/internal/httpapi/backup_test.go b/backend/internal/httpapi/backup_test.go new file mode 100644 index 0000000..b2df910 --- /dev/null +++ b/backend/internal/httpapi/backup_test.go @@ -0,0 +1,71 @@ +package httpapi + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "testing" + + "wgpanel-api/internal/wgkeys" +) + +func testHexKey(t *testing.T) string { + t.Helper() + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + t.Fatal(err) + } + return hex.EncodeToString(buf) +} + +func TestReencryptRows(t *testing.T) { + oldKey, newKey := testHexKey(t), testHexKey(t) + + encrypted, err := wgkeys.Encrypt(oldKey, "wg-private-key-1") + if err != nil { + t.Fatal(err) + } + rows, err := json.Marshal([]map[string]any{ + {"id": "a", "label": "one", "private_key_encrypted": encrypted}, + // Nullable/absent encrypted columns must pass through untouched + // (api_keys.previous_secret_encrypted is the real-world case). + {"id": "b", "label": "two", "private_key_encrypted": nil}, + }) + if err != nil { + t.Fatal(err) + } + + out, err := reencryptRows(rows, []string{"private_key_encrypted"}, oldKey, newKey) + if err != nil { + t.Fatal(err) + } + var decoded []map[string]any + if err := json.Unmarshal(out, &decoded); err != nil { + t.Fatal(err) + } + + plain, err := wgkeys.Decrypt(newKey, decoded[0]["private_key_encrypted"].(string)) + if err != nil { + t.Fatalf("decrypt with new key: %v", err) + } + if plain != "wg-private-key-1" { + t.Errorf("round trip = %q, want wg-private-key-1", plain) + } + if decoded[1]["private_key_encrypted"] != nil { + t.Errorf("null column should stay null, got %v", decoded[1]["private_key_encrypted"]) + } + if decoded[0]["label"] != "one" { + t.Errorf("unrelated column mangled: %v", decoded[0]["label"]) + } + + // A blob encrypted under a different key than claimed must fail loudly - it + // would otherwise restore undecryptable account keys. + if _, err := reencryptRows(rows, []string{"private_key_encrypted"}, newKey, oldKey); err == nil { + t.Error("expected an error re-encrypting with the wrong source key") + } + + // Empty/missing table blobs (nothing dumped) pass through. + if out, err := reencryptRows(nil, []string{"x"}, oldKey, newKey); err != nil || out != nil { + t.Errorf("nil rows: out=%v err=%v", out, err) + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 2c942a9..2cace2e 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -126,9 +126,11 @@ func (s *Server) Routes() http.Handler { mux.Handle("PATCH /api/v1/settings", s.requireAdmin(s.requireRole("super_admin", http.HandlerFunc(s.handleUpdateSettings)))) // Backup & restore (see backup.go's doc comment) - both directions are - // super_admin-only: the file contains admin password hashes, the CA private - // key and every subscription token, and restore replaces ALL panel state. - mux.Handle("GET /api/v1/backup", s.requireAdmin(s.requireRole("super_admin", http.HandlerFunc(s.handleDownloadBackup)))) + // super_admin-only: the decrypted file contains admin password hashes, the CA + // private key, the .env encryption keys and every subscription token, and + // restore replaces ALL panel state. Download is a POST because the encryption + // password rides in the request body. + mux.Handle("POST /api/v1/backup", s.requireAdmin(s.requireRole("super_admin", http.HandlerFunc(s.handleDownloadBackup)))) mux.Handle("POST /api/v1/backup/restore", s.requireAdmin(s.requireRole("super_admin", http.HandlerFunc(s.handleRestoreBackup)))) return s.loggingMiddleware(mux) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 0ec0681..4b0e58c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1365,25 +1365,38 @@ paths: "500": { $ref: "#/components/responses/InternalError" } /api/v1/backup: - get: + post: tags: [Backup] - summary: Download a full panel backup + summary: Download a full, password-encrypted panel backup description: >- - Requires role = `super_admin`. Streams a JSON attachment containing every - config table (admins, nodes, accounts, account_peers, account_devices, - api_keys, panel_settings, audit_log) plus the node-mTLS CA keypair. - Metrics history (TimescaleDB hypertables) is excluded - account usage - totals live on the accounts rows and are included. Account private keys - remain encrypted with the deployment's ACCOUNT_KEY_ENCRYPTION_KEY, so a - backup is only restorable alongside the original deploy/.env. + Requires role = `super_admin`. POST (not GET) because the encryption + password rides in the body. Streams a JSON attachment: an argon2id + + AES-256-GCM envelope sealing every config table (admins, nodes, accounts, + account_peers, account_devices, api_keys, panel_settings, audit_log), the + node-mTLS CA keypair, and the deployment's ACCOUNT_KEY_ENCRYPTION_KEY / + API_HMAC_MASTER_KEY - so the file plus its password is sufficient to + rebuild the panel on a brand-new server with a fresh deploy/.env. Metrics + history (TimescaleDB hypertables) is excluded - account usage totals live + on the accounts rows and are included. The password is the only way to + open the file and cannot be recovered. security: - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: { type: string, minLength: 8 } responses: "200": - description: The backup file (Content-Disposition attachment). + description: The encrypted backup file (Content-Disposition attachment). content: application/json: - schema: { $ref: "#/components/schemas/BackupFile" } + schema: { $ref: "#/components/schemas/BackupEnvelope" } + "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/InternalError" } @@ -1397,29 +1410,41 @@ paths: backed-up table with the file's rows (a table absent from the file is emptied, not skipped) and writes the CA keypair to disk - if that keypair differs from the running one, node-agent mTLS needs an api restart to pick - it up (`restart_required` on the response). Validated before anything is - touched: the file's schema version must exactly match this panel's - (`schema_mismatch`) and its key canary must decrypt with the current - ACCOUNT_KEY_ENCRYPTION_KEY (`encryption_key_mismatch`). Restoring replaces - admin users too - the caller's own login may change. + it up (`restart_required` on the response). If this deployment's + ACCOUNT_KEY_ENCRYPTION_KEY / API_HMAC_MASTER_KEY differ from the ones + embedded in the backup (disaster recovery onto a fresh .env), every + account private key and API-key secret is transparently re-encrypted to + the current keys. Validated before anything is touched: the password must + open the envelope (`wrong_password`) and the file's schema version must + exactly match this panel's (`schema_mismatch`). Restoring replaces admin + users too - the caller's own login may change. security: - BearerAuth: [] requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/BackupFile" } + schema: + type: object + required: [password, backup] + properties: + password: { type: string } + backup: { $ref: "#/components/schemas/BackupEnvelope" } responses: "200": description: Restore succeeded. content: application/json: schema: { $ref: "#/components/schemas/RestoreResult" } - "400": { $ref: "#/components/responses/BadRequest" } + "400": + description: "`invalid_request` or `wrong_password` - nothing was changed." + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorEnvelope" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": - description: "`schema_mismatch` or `encryption_key_mismatch` - nothing was changed." + description: "`schema_mismatch` - nothing was changed." content: application/json: schema: { $ref: "#/components/schemas/ErrorEnvelope" } @@ -1778,35 +1803,29 @@ components: the default. required: [default_node_capacity, client_dns] - BackupFile: + BackupEnvelope: type: object description: >- - The wgpanel-backup/1 format. `tables` maps each table name to its rows as - plain JSON objects (the exact json_agg of the table); `key_canary` is a - fixed string encrypted with ACCOUNT_KEY_ENCRYPTION_KEY so restore can - verify key compatibility up front; `ca` is the node-mTLS CA keypair (PEM), - omitted if the deployment has none. - required: [format, created_at, migrations, key_canary, tables] + The wgpanel-backup-enc/1 format: an argon2id + AES-256-GCM envelope. Only + format and created_at are readable without the password. `kdf` carries the + argon2id parameters the file was sealed with (salt base64, t/m/p costs); + `data` is the base64 ciphertext of the backup payload - config tables as + json_agg rows, the CA keypair (PEM), the applied-migrations list (restore + requires an exact match), and the deployment's encryption keys. + required: [format, created_at, kdf, nonce, data] properties: - format: { type: string, enum: [wgpanel-backup/1] } + format: { type: string, enum: [wgpanel-backup-enc/1] } created_at: { type: string, format: date-time } - migrations: - type: array - items: { type: string } - description: Applied migration filenames at backup time - restore requires an exact match. - key_canary: { type: string } - ca: + kdf: type: object - nullable: true + required: [salt, t, m, p] properties: - cert_pem: { type: string } - key_pem: { type: string } - required: [cert_pem, key_pem] - tables: - type: object - additionalProperties: - type: array - items: { type: object, additionalProperties: true } + salt: { type: string, description: base64 (raw, unpadded) } + t: { type: integer } + m: { type: integer, description: memory cost in KB } + p: { type: integer } + nonce: { type: string, description: base64 (raw, unpadded) } + data: { type: string, description: base64 (raw, unpadded) AES-256-GCM ciphertext } RestoreResult: type: object diff --git a/frontend/public/openapi.yaml b/frontend/public/openapi.yaml index 0ec0681..4b0e58c 100644 --- a/frontend/public/openapi.yaml +++ b/frontend/public/openapi.yaml @@ -1365,25 +1365,38 @@ paths: "500": { $ref: "#/components/responses/InternalError" } /api/v1/backup: - get: + post: tags: [Backup] - summary: Download a full panel backup + summary: Download a full, password-encrypted panel backup description: >- - Requires role = `super_admin`. Streams a JSON attachment containing every - config table (admins, nodes, accounts, account_peers, account_devices, - api_keys, panel_settings, audit_log) plus the node-mTLS CA keypair. - Metrics history (TimescaleDB hypertables) is excluded - account usage - totals live on the accounts rows and are included. Account private keys - remain encrypted with the deployment's ACCOUNT_KEY_ENCRYPTION_KEY, so a - backup is only restorable alongside the original deploy/.env. + Requires role = `super_admin`. POST (not GET) because the encryption + password rides in the body. Streams a JSON attachment: an argon2id + + AES-256-GCM envelope sealing every config table (admins, nodes, accounts, + account_peers, account_devices, api_keys, panel_settings, audit_log), the + node-mTLS CA keypair, and the deployment's ACCOUNT_KEY_ENCRYPTION_KEY / + API_HMAC_MASTER_KEY - so the file plus its password is sufficient to + rebuild the panel on a brand-new server with a fresh deploy/.env. Metrics + history (TimescaleDB hypertables) is excluded - account usage totals live + on the accounts rows and are included. The password is the only way to + open the file and cannot be recovered. security: - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: { type: string, minLength: 8 } responses: "200": - description: The backup file (Content-Disposition attachment). + description: The encrypted backup file (Content-Disposition attachment). content: application/json: - schema: { $ref: "#/components/schemas/BackupFile" } + schema: { $ref: "#/components/schemas/BackupEnvelope" } + "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/InternalError" } @@ -1397,29 +1410,41 @@ paths: backed-up table with the file's rows (a table absent from the file is emptied, not skipped) and writes the CA keypair to disk - if that keypair differs from the running one, node-agent mTLS needs an api restart to pick - it up (`restart_required` on the response). Validated before anything is - touched: the file's schema version must exactly match this panel's - (`schema_mismatch`) and its key canary must decrypt with the current - ACCOUNT_KEY_ENCRYPTION_KEY (`encryption_key_mismatch`). Restoring replaces - admin users too - the caller's own login may change. + it up (`restart_required` on the response). If this deployment's + ACCOUNT_KEY_ENCRYPTION_KEY / API_HMAC_MASTER_KEY differ from the ones + embedded in the backup (disaster recovery onto a fresh .env), every + account private key and API-key secret is transparently re-encrypted to + the current keys. Validated before anything is touched: the password must + open the envelope (`wrong_password`) and the file's schema version must + exactly match this panel's (`schema_mismatch`). Restoring replaces admin + users too - the caller's own login may change. security: - BearerAuth: [] requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/BackupFile" } + schema: + type: object + required: [password, backup] + properties: + password: { type: string } + backup: { $ref: "#/components/schemas/BackupEnvelope" } responses: "200": description: Restore succeeded. content: application/json: schema: { $ref: "#/components/schemas/RestoreResult" } - "400": { $ref: "#/components/responses/BadRequest" } + "400": + description: "`invalid_request` or `wrong_password` - nothing was changed." + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorEnvelope" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": - description: "`schema_mismatch` or `encryption_key_mismatch` - nothing was changed." + description: "`schema_mismatch` - nothing was changed." content: application/json: schema: { $ref: "#/components/schemas/ErrorEnvelope" } @@ -1778,35 +1803,29 @@ components: the default. required: [default_node_capacity, client_dns] - BackupFile: + BackupEnvelope: type: object description: >- - The wgpanel-backup/1 format. `tables` maps each table name to its rows as - plain JSON objects (the exact json_agg of the table); `key_canary` is a - fixed string encrypted with ACCOUNT_KEY_ENCRYPTION_KEY so restore can - verify key compatibility up front; `ca` is the node-mTLS CA keypair (PEM), - omitted if the deployment has none. - required: [format, created_at, migrations, key_canary, tables] + The wgpanel-backup-enc/1 format: an argon2id + AES-256-GCM envelope. Only + format and created_at are readable without the password. `kdf` carries the + argon2id parameters the file was sealed with (salt base64, t/m/p costs); + `data` is the base64 ciphertext of the backup payload - config tables as + json_agg rows, the CA keypair (PEM), the applied-migrations list (restore + requires an exact match), and the deployment's encryption keys. + required: [format, created_at, kdf, nonce, data] properties: - format: { type: string, enum: [wgpanel-backup/1] } + format: { type: string, enum: [wgpanel-backup-enc/1] } created_at: { type: string, format: date-time } - migrations: - type: array - items: { type: string } - description: Applied migration filenames at backup time - restore requires an exact match. - key_canary: { type: string } - ca: + kdf: type: object - nullable: true + required: [salt, t, m, p] properties: - cert_pem: { type: string } - key_pem: { type: string } - required: [cert_pem, key_pem] - tables: - type: object - additionalProperties: - type: array - items: { type: object, additionalProperties: true } + salt: { type: string, description: base64 (raw, unpadded) } + t: { type: integer } + m: { type: integer, description: memory cost in KB } + p: { type: integer } + nonce: { type: string, description: base64 (raw, unpadded) } + data: { type: string, description: base64 (raw, unpadded) AES-256-GCM ciphertext } RestoreResult: type: object diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 5759361..76009a1 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -12,7 +12,7 @@ import { Button } from '../components/ui/Button' import { Input } from '../components/ui/Input' import { Field } from '../components/ui/Field' import { Skeleton } from '../components/ui/Skeleton' -import { ConfirmDialog } from '../components/ui/ConfirmDialog' +import { Dialog } from '../components/ui/Dialog' interface Settings { public_base_url: string | null @@ -43,16 +43,38 @@ function BackupCard() { const queryClient = useQueryClient() const fileInputRef = useRef(null) + const [downloadOpen, setDownloadOpen] = useState(false) + const [downloadPassword, setDownloadPassword] = useState('') + const [downloadPasswordConfirm, setDownloadPasswordConfirm] = useState('') const [downloading, setDownloading] = useState(false) + const [restoreFile, setRestoreFile] = useState(null) + const [restorePassword, setRestorePassword] = useState('') const [restoring, setRestoring] = useState(false) const [restoreResult, setRestoreResult] = useState(null) + function closeDownload() { + setDownloadOpen(false) + setDownloadPassword('') + setDownloadPasswordConfirm('') + } + + function closeRestore() { + setRestoreFile(null) + setRestorePassword('') + if (fileInputRef.current) fileInputRef.current.value = '' + } + async function downloadBackup() { setDownloading(true) try { const res = await fetch('/api/v1/backup', { - headers: { Authorization: `Bearer ${getAccessToken() ?? ''}` }, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getAccessToken() ?? ''}`, + }, + body: JSON.stringify({ password: downloadPassword }), }) if (!res.ok) { let message = 'Failed to create backup' @@ -72,7 +94,8 @@ function BackupCard() { a.download = filename a.click() URL.revokeObjectURL(url) - push('success', 'Backup downloaded - store it somewhere safe, it contains every panel secret') + closeDownload() + push('success', 'Backup downloaded - keep the file AND its password safe; the password cannot be recovered') } catch (err) { push('error', err instanceof Error ? err.message : 'Failed to create backup') } finally { @@ -84,18 +107,24 @@ function BackupCard() { if (!restoreFile) return setRestoring(true) try { - const body = await restoreFile.text() - const result = await apiFetch('/api/v1/backup/restore', { method: 'POST', body }) + const backup = JSON.parse(await restoreFile.text()) as unknown + const result = await apiFetch('/api/v1/backup/restore', { + method: 'POST', + body: JSON.stringify({ password: restorePassword, backup }), + }) setRestoreResult(result) // Everything cached client-side describes the pre-restore panel. queryClient.clear() + closeRestore() push('success', 'Backup restored') } catch (err) { - push('error', err instanceof ApiError ? err.message : 'Restore failed') + if (err instanceof SyntaxError) { + push('error', 'That file is not a WGPanel backup') + } else { + push('error', err instanceof ApiError ? err.message : 'Restore failed') + } } finally { setRestoring(false) - setRestoreFile(null) - if (fileInputRef.current) fileInputRef.current.value = '' } } @@ -104,25 +133,25 @@ function BackupCard() {

- Metrics history (usage charts) is not included; account usage totals are. Restoring on a new server also - needs the original deploy/.env - account keys are encrypted with its{' '} - ACCOUNT_KEY_ENCRYPTION_KEY. + The file is useless without the password you choose - and the password cannot be recovered, so store both + safely. Metrics history (usage charts) is not included; account usage totals are.

-

Restore replaces all current panel data with the file's - contents - including admin users, so your own login may change. + contents - including admin users, so your own login may change. Works on a fresh install with new{' '} + .env keys: account keys are re-encrypted automatically.

@@ -153,19 +182,88 @@ function BackupCard() { )}
- { - setRestoreFile(null) - if (fileInputRef.current) fileInputRef.current.value = '' - }} - onConfirm={restoreBackup} - title="Replace all panel data?" - description={`Everything currently in this panel - accounts, nodes, admins, API keys, settings, audit log - will be replaced by "${restoreFile?.name ?? ''}". This cannot be undone. Download a backup of the current state first if you might need it.`} - confirmLabel="Replace everything" - danger - submitting={restoring} - /> + +
{ + e.preventDefault() + downloadBackup() + }} + className="space-y-4" + > +

+ Choose a password for this backup. Restoring it - here or on a new server - requires exactly this + password; it is not stored anywhere and cannot be recovered. +

+ + setDownloadPassword(e.target.value)} + minLength={8} + placeholder="At least 8 characters" + autoFocus + required + /> + + + setDownloadPasswordConfirm(e.target.value)} + required + /> + + {downloadPasswordConfirm.length > 0 && downloadPassword !== downloadPasswordConfirm && ( +

Passwords do not match.

+ )} +
+ + +
+
+
+ + +
{ + e.preventDefault() + restoreBackup() + }} + className="space-y-4" + > +

+ Everything currently in this panel - accounts, nodes, admins, API keys, settings, audit log - will be + replaced by {restoreFile?.name}. This cannot be undone. + Download a backup of the current state first if you might need it. +

+ + setRestorePassword(e.target.value)} + placeholder="The password this backup was encrypted with" + autoFocus + required + /> + +
+ + +
+
+
) }