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
+31
View File
@@ -72,7 +72,38 @@
});
}
// --- Encryption password fields shown only when asked for --------------
// The backup, export and import forms carry an optional password block. It
// is hidden until the checkbox next to it is ticked, and cleared when it is
// unticked, so a password typed and then abandoned is never submitted. With
// JavaScript blocked the block stays visible and the forms behave exactly as
// the server reads them: the checkbox alone decides whether encryption
// happens.
function syncEncryptFields(box) {
var form = box.closest("form");
var fields = form && form.querySelector("[data-encrypt-fields]");
if (!fields) {
return;
}
fields.hidden = !box.checked;
if (!box.checked) {
fields.querySelectorAll("input").forEach(function (input) {
input.value = "";
});
}
}
function initEncryptFields(root) {
root.querySelectorAll("input[data-encrypt-toggle]").forEach(function (box) {
syncEncryptFields(box);
box.addEventListener("change", function () {
syncEncryptFields(box);
});
});
}
document.addEventListener("DOMContentLoaded", function () {
initAddressFields(document);
initEncryptFields(document);
});
})();