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,162 @@
|
||||
package secretfile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// encrypt is the round-trip helper: seal data and return the envelope bytes.
|
||||
func encrypt(t *testing.T, typ PayloadType, password string, data []byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w, err := NewWriter(&buf, typ, password)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWriter: %v", err)
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func decrypt(t *testing.T, enc []byte, password string) ([]byte, PayloadType, error) {
|
||||
t.Helper()
|
||||
r, err := NewReader(bytes.NewReader(enc), password)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := io.ReadAll(r)
|
||||
return out, r.Type(), err
|
||||
}
|
||||
|
||||
func TestRoundTripSizes(t *testing.T) {
|
||||
// Empty, sub-chunk, exactly one chunk, and several chunks with a partial
|
||||
// tail: the boundaries where chunk framing tends to break.
|
||||
sizes := []int{0, 1, 1000, chunkSize - 1, chunkSize, chunkSize + 1, 3*chunkSize + 77}
|
||||
for _, n := range sizes {
|
||||
data := make([]byte, n)
|
||||
for i := range data {
|
||||
data[i] = byte(i * 7)
|
||||
}
|
||||
enc := encrypt(t, TypeFullBackup, "correct horse battery staple", data)
|
||||
got, typ, err := decrypt(t, enc, "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatalf("size %d: decrypt: %v", n, err)
|
||||
}
|
||||
if typ != TypeFullBackup {
|
||||
t.Errorf("size %d: type = %v, want full backup", n, typ)
|
||||
}
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Errorf("size %d: plaintext mismatch (%d bytes back)", n, len(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCiphertextDoesNotLeakPlaintext(t *testing.T) {
|
||||
secret := []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n")
|
||||
enc := encrypt(t, TypeDomainExport, "a very long password", secret)
|
||||
if bytes.Contains(enc, secret) {
|
||||
t.Fatal("plaintext found verbatim in the envelope")
|
||||
}
|
||||
if !HasMagic(enc) {
|
||||
t.Fatal("envelope does not start with the magic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongPassword(t *testing.T) {
|
||||
enc := encrypt(t, TypeDomainExport, "the right password", []byte("secret payload"))
|
||||
_, _, err := decrypt(t, enc, "the wrong password")
|
||||
if !errors.Is(err, ErrWrongPassword) {
|
||||
t.Fatalf("err = %v, want ErrWrongPassword", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotEncrypted(t *testing.T) {
|
||||
for _, plain := range [][]byte{[]byte(`{"format":"selfpost-domain-export"}`), {}, []byte("SELF")} {
|
||||
if _, err := NewReader(bytes.NewReader(plain), "pw"); !errors.Is(err, ErrNotEncrypted) {
|
||||
t.Errorf("NewReader(%q) = %v, want ErrNotEncrypted", plain, err)
|
||||
}
|
||||
if HasMagic(plain) {
|
||||
t.Errorf("HasMagic(%q) = true", plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncationDetected(t *testing.T) {
|
||||
// A backup cut short mid-transfer must fail loudly rather than restore a
|
||||
// plausible-looking prefix.
|
||||
data := bytes.Repeat([]byte("payload"), 20000) // spans several chunks
|
||||
enc := encrypt(t, TypeFullBackup, "password password", data)
|
||||
|
||||
// Drop the final chunk entirely: what remains is a sequence of valid,
|
||||
// correctly authenticated chunks, none of which is marked last.
|
||||
var chunks []int
|
||||
for off := headerLen; off < len(enc); {
|
||||
n := int(enc[off])<<24 | int(enc[off+1])<<16 | int(enc[off+2])<<8 | int(enc[off+3])
|
||||
chunks = append(chunks, off)
|
||||
off += 4 + n
|
||||
}
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("expected several chunks, got %d", len(chunks))
|
||||
}
|
||||
truncated := enc[:chunks[len(chunks)-1]]
|
||||
r, err := NewReader(bytes.NewReader(truncated), "password password")
|
||||
if err != nil {
|
||||
t.Fatalf("NewReader (truncated): %v", err)
|
||||
}
|
||||
if _, err := io.ReadAll(r); !errors.Is(err, ErrCorrupt) {
|
||||
t.Fatalf("read truncated = %v, want ErrCorrupt", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperDetected(t *testing.T) {
|
||||
enc := encrypt(t, TypeFullBackup, "password password", []byte("some archive bytes"))
|
||||
for _, off := range []int{MagicLen /* type byte */, headerLen + 6 /* ciphertext */} {
|
||||
bad := bytes.Clone(enc)
|
||||
bad[off] ^= 0xff
|
||||
if _, _, err := decrypt(t, bad, "password password"); err == nil {
|
||||
t.Errorf("flipping byte %d was accepted", off)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkReorderRejected(t *testing.T) {
|
||||
// Two full chunks plus a tail, so swapping the first two is possible without
|
||||
// changing any length.
|
||||
data := make([]byte, 2*chunkSize+10)
|
||||
for i := range data {
|
||||
data[i] = byte(i)
|
||||
}
|
||||
enc := encrypt(t, TypeFullBackup, "password password", data)
|
||||
|
||||
const framed = 4 + chunkSize + tagLen
|
||||
first := headerLen
|
||||
second := headerLen + framed
|
||||
swapped := bytes.Clone(enc)
|
||||
copy(swapped[first:first+framed], enc[second:second+framed])
|
||||
copy(swapped[second:second+framed], enc[first:first+framed])
|
||||
|
||||
if _, _, err := decrypt(t, swapped, "password password"); !errors.Is(err, ErrWrongPassword) {
|
||||
t.Fatalf("reordered chunks = %v, want authentication failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyPasswordRejected(t *testing.T) {
|
||||
if _, err := NewWriter(io.Discard, TypeFullBackup, ""); err == nil {
|
||||
t.Fatal("empty password accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreasonableKDFParamsRejected(t *testing.T) {
|
||||
enc := encrypt(t, TypeFullBackup, "password password", []byte("x"))
|
||||
bad := bytes.Clone(enc)
|
||||
bad[MagicLen+2] = 40 // logN far beyond maxLogN
|
||||
if _, err := NewReader(bytes.NewReader(bad), "password password"); !errors.Is(err, ErrCorrupt) {
|
||||
t.Fatalf("err = %v, want ErrCorrupt", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user