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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user