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:
@@ -32,6 +32,8 @@ type detailView struct {
|
||||
// RateLimitErr surfaces a validation error from a domain- or application-level
|
||||
// rate-limit form (spec 7.4) as a page banner.
|
||||
RateLimitErr string
|
||||
// ExportErr surfaces a rejected encryption password from the export card.
|
||||
ExportErr string
|
||||
}
|
||||
|
||||
// appRateLimitView pairs an application with its differentiated rate-limit
|
||||
@@ -131,6 +133,8 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
||||
"Wildcard": store.AddressModeWildcard,
|
||||
"List": store.AddressModeList,
|
||||
"RateLimitErr": view.RateLimitErr,
|
||||
"ExportErr": view.ExportErr,
|
||||
"MinPwLen": minSecretFilePasswordLen,
|
||||
"DomainHasRL": domainRLok && domainRL.Active(),
|
||||
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
|
||||
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
|
||||
|
||||
+177
-11
@@ -1,20 +1,25 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/backup"
|
||||
"codeberg.org/mix/selfpost/internal/domain"
|
||||
"codeberg.org/mix/selfpost/internal/secretfile"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
|
||||
// document (a DKIM key and a handful of credentials); this leaves generous head
|
||||
// room while refusing anything large enough to be an abuse attempt.
|
||||
// room while refusing anything large enough to be an abuse attempt. An
|
||||
// encrypted export adds only a header and per-chunk tags, so the same ceiling
|
||||
// covers both forms.
|
||||
const maxImportBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// handleBackupPage renders the backup/migration screen: the full-server backup
|
||||
@@ -27,11 +32,21 @@ func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) {
|
||||
// renderBackupPage draws the page; importErr surfaces a failed domain import
|
||||
// (spec 7.5.B) next to the form that produced it.
|
||||
func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
|
||||
s.renderBackupPageWith(w, r, status, importErr, "")
|
||||
}
|
||||
|
||||
// renderBackupPageWith is renderBackupPage with the second of the page's two
|
||||
// error slots: backupErr belongs to the full-backup card (a rejected encryption
|
||||
// password), importErr to the import card, so neither message appears under the
|
||||
// wrong form.
|
||||
func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
|
||||
s.render(w, status, "backup", map[string]any{
|
||||
"Title": "SelfPost — backup",
|
||||
"User": currentUser(r),
|
||||
"Active": "backup",
|
||||
"ImportErr": importErr,
|
||||
"BackupErr": backupErr,
|
||||
"MinPwLen": minSecretFilePasswordLen,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,36 +54,84 @@ func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status
|
||||
// authenticated admin action (this handler sits behind the auth middleware). The
|
||||
// archive carries DKIM private keys, the admin password hash and SASL
|
||||
// credentials, so it is served with no-store and as an attachment to discourage
|
||||
// caching of secret material.
|
||||
// caching of secret material. When the operator ticks "encrypt with a
|
||||
// password", the archive is wrapped in a .spbk envelope on the way out, so the
|
||||
// file that lands on their disk — wherever it is copied afterwards — is useless
|
||||
// without the password.
|
||||
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
|
||||
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
|
||||
password, pwErr := secretFilePassword(r)
|
||||
if pwErr != "" {
|
||||
s.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
stamp := time.Now().UTC().Format("20060102-150405")
|
||||
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", stamp)
|
||||
contentType := "application/gzip"
|
||||
if password != "" {
|
||||
filename = fmt.Sprintf("selfpost-backup-%s%s", stamp, secretfile.ExtBackup)
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
if err := backup.Create(w, backup.Params{
|
||||
// Everything below streams: past this point headers (and possibly some
|
||||
// bytes) are already on the wire, so a failure cannot switch to a clean
|
||||
// error page. Log it and let the truncated download fail loudly on the
|
||||
// client side — for an encrypted archive that is a missing end-of-stream
|
||||
// chunk, which decryption refuses outright.
|
||||
sink := io.Writer(w)
|
||||
var env *secretfile.Writer
|
||||
if password != "" {
|
||||
var err error
|
||||
// The only failures here are key derivation (which happens before
|
||||
// anything is written) and writing the envelope header, which fails only
|
||||
// if the client is already gone.
|
||||
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
|
||||
if err != nil {
|
||||
logf("panel: full backup: encrypt: %v", err)
|
||||
http.Error(w, "backup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sink = env
|
||||
}
|
||||
|
||||
if err := backup.Create(sink, backup.Params{
|
||||
DataDir: s.cfg.DataDir,
|
||||
DBPath: s.cfg.DBPath,
|
||||
Version: s.cfg.Version,
|
||||
}); err != nil {
|
||||
// Headers (and possibly some bytes) may already be on the wire, so we
|
||||
// cannot switch to a clean error page; log it and let the truncated
|
||||
// download fail loudly on the client side.
|
||||
logf("panel: full backup failed: %v", err)
|
||||
return
|
||||
}
|
||||
if env != nil {
|
||||
if err := env.Close(); err != nil {
|
||||
logf("panel: full backup failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportDomain streams a single-domain export as a secret download (spec
|
||||
// 7.5.B). Like the full backup it is POST-only (state is not changed, but the
|
||||
// response contains the domain's DKIM private key and application passwords, so
|
||||
// it must not be prefetchable or cached).
|
||||
// it must not be prefetchable or cached). Like the full backup it can be
|
||||
// encrypted with a password, in which case the download is a .spde envelope
|
||||
// instead of plain JSON.
|
||||
func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
password, pwErr := secretFilePassword(r)
|
||||
if pwErr != "" {
|
||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
ExportErr: pwErr,
|
||||
})
|
||||
return
|
||||
}
|
||||
exp, err := s.domains.Export(d.ID)
|
||||
if err != nil {
|
||||
logf("panel: export domain %d: %v", d.ID, err)
|
||||
@@ -83,7 +146,29 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("selfpost-domain-%s.json", d.Name)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
contentType := "application/json"
|
||||
if password != "" {
|
||||
// An export is small, so it is sealed in memory: the response is only
|
||||
// started once the ciphertext is complete and nothing can half-fail.
|
||||
var buf bytes.Buffer
|
||||
env, err := secretfile.NewWriter(&buf, secretfile.TypeDomainExport, password)
|
||||
if err == nil {
|
||||
_, err = env.Write(body)
|
||||
}
|
||||
if err == nil {
|
||||
err = env.Close()
|
||||
}
|
||||
if err != nil {
|
||||
logf("panel: export domain %d: encrypt: %v", d.ID, err)
|
||||
http.Error(w, "export failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body = buf.Bytes()
|
||||
filename = fmt.Sprintf("selfpost-domain-%s%s", d.Name, secretfile.ExtDomainExport)
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
@@ -108,8 +193,49 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// An encrypted export announces itself with the envelope magic, so the file
|
||||
// decides which path it takes; the password field is only consulted when the
|
||||
// file actually needs it, and a password typed for a plain file is a plain
|
||||
// mistake worth reporting.
|
||||
head := make([]byte, secretfile.MagicLen)
|
||||
n, err := io.ReadFull(file, head)
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.")
|
||||
return
|
||||
}
|
||||
source := io.MultiReader(bytes.NewReader(head[:n]), file)
|
||||
password := r.PostFormValue("import_password")
|
||||
|
||||
if secretfile.HasMagic(head[:n]) {
|
||||
if password == "" {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.")
|
||||
return
|
||||
}
|
||||
env, err := secretfile.NewReader(source, password)
|
||||
if err != nil {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
||||
return
|
||||
}
|
||||
if env.Type() != secretfile.TypeDomainExport {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.")
|
||||
return
|
||||
}
|
||||
// Read the whole plaintext first: authentication of the last chunk is
|
||||
// what proves the file is intact, and a streaming JSON decoder could
|
||||
// accept a truncated document before ever reaching it.
|
||||
plain, err := io.ReadAll(env)
|
||||
if err != nil {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
||||
return
|
||||
}
|
||||
source = bytes.NewReader(plain)
|
||||
} else if password != "" {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
|
||||
return
|
||||
}
|
||||
|
||||
var exp domain.DomainExport
|
||||
dec := json.NewDecoder(file)
|
||||
dec := json.NewDecoder(source)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&exp); err != nil {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
|
||||
@@ -134,6 +260,46 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// secretFilePassword reads the "encrypt this download with a password" controls
|
||||
// shared by the full-backup and domain-export forms. It returns the password to
|
||||
// encrypt with — empty when the box is not ticked, which keeps the plain
|
||||
// .tar.gz/.json behaviour of earlier versions — or a message to show above the
|
||||
// form. The confirmation field is checked here rather than in the browser
|
||||
// because a typo in an encryption password is unrecoverable: the archive would
|
||||
// be sealed with a secret the operator does not know.
|
||||
func secretFilePassword(r *http.Request) (password, errMsg string) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return "", "Invalid form submission."
|
||||
}
|
||||
if r.PostFormValue("encrypt") == "" {
|
||||
return "", ""
|
||||
}
|
||||
password = r.PostFormValue("password")
|
||||
if len([]rune(password)) < minSecretFilePasswordLen {
|
||||
return "", fmt.Sprintf("The encryption password must be at least %d characters.", minSecretFilePasswordLen)
|
||||
}
|
||||
if password != r.PostFormValue("password_confirm") {
|
||||
return "", "The two passwords do not match."
|
||||
}
|
||||
return password, ""
|
||||
}
|
||||
|
||||
// decryptErrorMessage phrases an envelope failure for the operator. A wrong
|
||||
// password and a damaged file are deliberately indistinguishable to the code,
|
||||
// so the message names both possibilities.
|
||||
func decryptErrorMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, secretfile.ErrWrongPassword):
|
||||
return "Wrong password, or the file has been altered since it was exported."
|
||||
case errors.Is(err, secretfile.ErrCorrupt):
|
||||
return "That file is damaged or incomplete."
|
||||
case errors.Is(err, secretfile.ErrNotEncrypted):
|
||||
return "That file is not a SelfPost export."
|
||||
default:
|
||||
return "Could not decrypt the file."
|
||||
}
|
||||
}
|
||||
|
||||
// importErrorMessage maps a domain-import failure (already logged by the caller)
|
||||
// to an HTTP status and a user-facing message. Duplicate domain/login are called
|
||||
// out specifically; other failures — validation errors describing what is wrong
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/secretfile"
|
||||
)
|
||||
|
||||
// postForm builds the kind of request the backup and export forms submit.
|
||||
func postForm(values url.Values) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/backup", strings.NewReader(values.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return r
|
||||
}
|
||||
|
||||
// The encryption password is only ever typed once into a file nobody can
|
||||
// recover without it, so every way of getting it wrong has to be caught before
|
||||
// the archive is sealed — and leaving the box unticked has to keep producing
|
||||
// the plain archive earlier versions produced.
|
||||
func TestSecretFilePassword(t *testing.T) {
|
||||
long := strings.Repeat("x", minSecretFilePasswordLen)
|
||||
short := strings.Repeat("x", minSecretFilePasswordLen-1)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
form url.Values
|
||||
wantPass string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "unticked box means no encryption",
|
||||
form: url.Values{"password": {long}, "password_confirm": {long}},
|
||||
wantPass: "",
|
||||
},
|
||||
{
|
||||
name: "ticked with a matching password",
|
||||
form: url.Values{"encrypt": {"1"}, "password": {long}, "password_confirm": {long}},
|
||||
wantPass: long,
|
||||
},
|
||||
{
|
||||
name: "mistyped confirmation",
|
||||
form: url.Values{"encrypt": {"1"}, "password": {long}, "password_confirm": {long + "!"}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "too short",
|
||||
form: url.Values{"encrypt": {"1"}, "password": {short}, "password_confirm": {short}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ticked but empty",
|
||||
form: url.Values{"encrypt": {"1"}},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pass, errMsg := secretFilePassword(postForm(tt.form))
|
||||
if tt.wantErr {
|
||||
if errMsg == "" {
|
||||
t.Fatalf("password %q accepted, want a rejection", pass)
|
||||
}
|
||||
if pass != "" {
|
||||
t.Errorf("a rejected form still yielded password %q", pass)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errMsg != "" {
|
||||
t.Fatalf("unexpected rejection: %s", errMsg)
|
||||
}
|
||||
if pass != tt.wantPass {
|
||||
t.Errorf("password = %q, want %q", pass, tt.wantPass)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The messages the import form shows must distinguish the operator's likely
|
||||
// mistakes; a wrong password and a tampered file stay deliberately merged.
|
||||
func TestDecryptErrorMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{secretfile.ErrWrongPassword, "Wrong password"},
|
||||
{fmt.Errorf("read: %w", secretfile.ErrCorrupt), "damaged"},
|
||||
{secretfile.ErrNotEncrypted, "not a SelfPost export"},
|
||||
{errors.New("something else"), "Could not decrypt"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := decryptErrorMessage(tt.err); !strings.Contains(got, tt.want) {
|
||||
t.Errorf("decryptErrorMessage(%v) = %q, want it to mention %q", tt.err, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The encryption controls are shared markup pulled into two pages; a page that
|
||||
// forgets to include the partial (or the data it needs) loses the option
|
||||
// silently, since the plain download still works.
|
||||
func TestBackupPageOffersEncryption(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
s := &Server{tmpl: tmpl, cfg: Config{Version: "test"}}
|
||||
rec := httptest.NewRecorder()
|
||||
s.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
|
||||
http.StatusOK, "", "The two passwords do not match.")
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
|
||||
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
|
||||
fmt.Sprintf("at least %d characters", minSecretFilePasswordLen),
|
||||
"The two passwords do not match.",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("backup page is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,3 +249,22 @@ button.copy { flex: none; margin-top: 0.3rem; }
|
||||
}
|
||||
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: #3d1a18 !important; }
|
||||
}
|
||||
|
||||
/* The optional "encrypt this download" block on the backup, export and import
|
||||
forms. Its label is the one checkbox in the panel, so it opts out of the
|
||||
block-level label rule above and sits on one line with its box; the fields it
|
||||
reveals are indented under it to read as its consequence rather than as three
|
||||
more fields of the form. panel.js hides the inner block until the box is
|
||||
ticked (and empties it when unticked); without JavaScript everything stays
|
||||
visible, which the server handles identically. */
|
||||
.encrypt { margin-top: 1.2rem; }
|
||||
.encrypt label.check {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600;
|
||||
}
|
||||
.encrypt label.check input { width: auto; margin: 0; }
|
||||
.encrypt-fields {
|
||||
margin-left: 1.6rem; padding-left: 0.9rem; border-left: 2px solid #e2e5e9;
|
||||
}
|
||||
.encrypt-fields label { margin-top: 0.7rem; }
|
||||
.encrypt-fields .muted { margin: 0.5rem 0 0; font-size: 0.85rem; }
|
||||
@media (prefers-color-scheme: dark) { .encrypt-fields { border-color: #2b3138 !important; } }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -15,6 +15,12 @@ const (
|
||||
maxUsernameLen = 64
|
||||
)
|
||||
|
||||
// minSecretFilePasswordLen is the floor for the password protecting an
|
||||
// encrypted backup or domain export. Such a file is offline and can be attacked
|
||||
// at leisure, so the floor matches the administrator password's rather than the
|
||||
// weaker "any password is better than none".
|
||||
const minSecretFilePasswordLen = minAdminPasswordLen
|
||||
|
||||
// validateUsername enforces a strict server-side whitelist (spec 7.6.2):
|
||||
// letters, digits, dot, dash, underscore. Client validation is never trusted.
|
||||
func validateUsername(u string) error {
|
||||
|
||||
Reference in New Issue
Block a user