feat: optional password encryption for backup and domain export (code-review.md § Phase 1.5)

Both secret-bearing downloads can now be sealed with a password. Unticked, the
forms produce exactly the files they did before.

- internal/secretfile: envelope format — magic/type/scrypt params/salt/nonce
  prefix header, then 64 KiB AES-256-GCM chunks each authenticated with the
  header, its counter and an end-of-stream flag, so truncation, reordering and
  tampering fail to open instead of restoring a plausible prefix. Streams both
  ways, so a full backup never sits in memory.
- Panel: "Encrypt with a password" checkbox on the full-backup and
  domain-export forms (shared partial, toggled from panel.js — no inline
  script); domain import detects an encrypted export by magic bytes, not by
  extension, and asks for the password.
- selfpost-backup: writes .spbk when given a password and converts one back
  with -decrypt, which a restore needs. The password comes from
  SELFPOST_BACKUP_PASSWORD or -password-file, never argv.
- Docs: README, security.md (+ accepted risk: encryption stays opt-in),
  architecture.md, progress.md, CHANGELOG.

Verified locally: panel-encrypted archive decrypts through the CLI and unpacks;
wrong password and password mismatch are refused; UI checked in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 16:43:28 +03:00
parent 670982fb3e
commit 6a7d010868
20 changed files with 1402 additions and 36 deletions
+5 -3
View File
@@ -21,14 +21,16 @@ type templates struct {
// pageFiles maps a logical page name to its template files. Every page
// composes with layout.html; pages that embed a polling fragment (spec 7.1)
// list that fragment's file too, so the same {{define}} block renders both
// the initial page and the fragment's own refresh responses identically.
// the initial page and the fragment's own refresh responses identically. Pages
// sharing a block of markup (the encryption fields on the two secret downloads)
// list that partial the same way.
var pageFiles = map[string][]string{
"setup": {"templates/setup.html"},
"login": {"templates/login.html"},
"dashboard": {"templates/dashboard.html"},
"account": {"templates/account.html"},
"backup": {"templates/backup.html"},
"domain_detail": {"templates/domain_detail.html"},
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
"domain_delete": {"templates/domain_delete.html"},
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},