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 68f83139ee
commit 6d2d49257d
20 changed files with 1402 additions and 36 deletions
+13 -4
View File
@@ -219,7 +219,7 @@ E2E покрывает: bootstrap, SMTP AUTH, DKIM, send-log lifecycle, negative
| CSRF without tokens | POST без Origin/Sec-Fetch-Site пропускается | [security.md](security.md) |
| Fail-open L2 rate limit | DB error → mail проходит | [`internal/milter/ratelimit.go`](../internal/milter/ratelimit.go) |
| Shallow SPF check | Не следует `include:`/`redirect=` | README, `internal/dnscheck/spf.go` |
| Plaintext backup/export at rest | DKIM-ключи, SASL, пароли приложений в cleartext `.tar.gz`/`.json` | **Mitigation:** R13 (optional encryption) |
| Plaintext backup/export at rest | DKIM-ключи, SASL, пароли приложений в cleartext `.tar.gz`/`.json` | **Закрыто:** R13 — опциональное шифрование (`.spbk`/`.spde`); открытый вариант остаётся умолчанием, риск переформулирован в [security.md](security.md) |
**Не риск (решение оператора):** «Session resurrection from backup» — снято из [security.md](security.md).
@@ -234,7 +234,7 @@ E2E покрывает: bootstrap, SMTP AUTH, DKIM, send-log lifecycle, negative
| # | Действие | Приоритет | Модель |
|---|----------|-----------|--------|
| L1 | **Предрелизный security review** (§ D) — обязательный гейт | **P0** | **Fable** |
| L2 | **Шифрование бэкапа и экспорта домена** (R13) — optional, checkbox + password | P1 | **Opus** + Sonnet |
| L2 | **Шифрование бэкапа и экспорта домена** (R13) — optional, checkbox + password**выполнено** | P1 | **Opus** + Sonnet |
| L3 | Send-log gap mitigation — опционально | P2 | Opus |
| L4 | Transaction wrap для rate limit count+insert — опционально | P3 | Opus |
@@ -352,7 +352,7 @@ Go `html/template` + HTMX polling + [`panel.css`](../internal/web/static/panel.c
| R6 | GUI: visibility-aware HTMX polling | Sonnet |
| R7 | CONTRIBUTING.md | Sonnet |
| R8 | ADR для CSRF policy | Sonnet |
| R13 | Шифрование бэкапа и экспорта домена (checkbox + password) | **Opus** + Sonnet |
| R13 | Шифрование бэкапа и экспорта домена (checkbox + password)**выполнено** | **Opus** + Sonnet |
### v2.x (roadmap, не начинать без согласования)
@@ -384,7 +384,16 @@ Go `html/template` + HTMX polling + [`panel.css`](../internal/web/static/panel.c
5. Sonnet: bump compose image tag + Codeberg URLs (в том же release commit)
6. Git tag vX.Y.Z
### Фаза 1.5 — Шифрование резервных копий (P1, v1.x)
### Фаза 1.5 — Шифрование резервных копий (P1, v1.x) — **выполнено 2026-08-06**
Реализовано как спланировано: `internal/secretfile` (E1) → `selfpost-backup`
+ панель (E2) → экспорт/импорт домена (E3) → UI-чекбокс (E4) → docs (E5).
Отличия от плана: конверт потоковый (64 KiB чанки AES-256-GCM с AAD
`header+counter+last`), а не одноблочный, иначе полный бэкап пришлось бы
держать в памяти целиком; манифест остался внутри tar, то есть внутри
шифротекста, как и планировалось; в CLI добавлен режим `-decrypt` — без него
зашифрованный бэкап нечем распаковать при restore. Детали —
[progress.md](progress.md), CHANGELOG `[Unreleased]`.
**Проблема:** полный бэкап и экспорт домена содержат DKIM-ключи, SASL-креды и plaintext-пароли приложений; сейчас `.tar.gz` / `.json` без шифрования.