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
+16 -3
View File
@@ -10,8 +10,11 @@
start. TLS certificates and the mail queue are not included.</p>
<p class="muted"><strong>The backup file is a secret</strong> (it contains
private keys and credentials). Store and transfer it securely and delete it
once the restore succeeds.</p>
<form class="inline" method="post" action="/backup">
once the restore succeeds. Encrypting it below is the simplest way to do that:
the download is then a <code>.spbk</code> file that only the password opens.</p>
{{if .BackupErr}}<p class="error">{{.BackupErr}}</p>{{end}}
<form method="post" action="/backup">
{{template "encryptfields" .}}
<button type="submit">Download full backup</button>
</form>
</div>
@@ -25,7 +28,17 @@
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
<form method="post" action="/domains/import" enctype="multipart/form-data">
<label for="importfile">Domain export file</label>
<input id="importfile" name="file" type="file" accept=".json,application/json" required>
<input id="importfile" name="file" type="file" accept=".json,.spde,application/json" required>
<div class="encrypt">
<label class="check">
<input type="checkbox" data-encrypt-toggle>
<span>The file is encrypted (<code>.spde</code>)</span>
</label>
<div class="encrypt-fields" data-encrypt-fields>
<label for="importpw">Password</label>
<input id="importpw" name="import_password" type="password" autocomplete="off">
</div>
</div>
<button type="submit">Import domain</button>
</form>
</div>
+5 -2
View File
@@ -283,8 +283,11 @@
import the DNS record stays the same, so no DNS change is needed.</p>
<p class="muted"><strong>The export file is a secret</strong> — it contains the
private DKIM key and application passwords. Transfer it securely and delete it
after the import.</p>
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/export">
after the import, or encrypt it below and move a <code>.spde</code> file
instead; the import form asks for the password.</p>
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
<form method="post" action="/domains/{{.Domain.ID}}/export">
{{template "encryptfields" .}}
<button type="submit">Export domain</button>
</form>
</div>
@@ -0,0 +1,23 @@
{{/* Password fields shared by the full-backup and domain-export forms. The
checkbox is the switch: unticked, the download keeps its historic plain
form (.tar.gz / .json); ticked, the file is sealed in a password-encrypted
envelope (.spbk / .spde). The fields start hidden and are revealed by
panel.js — with JavaScript blocked they are simply always visible, and the
server still decides from the checkbox alone. */}}
{{define "encryptfields"}}
<div class="encrypt">
<label class="check">
<input type="checkbox" name="encrypt" value="1" data-encrypt-toggle>
<span>Encrypt with a password</span>
</label>
<div class="encrypt-fields" data-encrypt-fields>
<label for="encpw">Password</label>
<input id="encpw" name="password" type="password" autocomplete="new-password"
minlength="{{.MinPwLen}}" placeholder="at least {{.MinPwLen}} characters">
<label for="encpw2">Repeat password</label>
<input id="encpw2" name="password_confirm" type="password" autocomplete="new-password">
<p class="muted">Keep this password: without it the file cannot be opened,
and SelfPost does not store it anywhere.</p>
</div>
</div>
{{end}}