Files
selfpost/internal/secretfile/secretfile_test.go
T
mixandClaude Opus 5.5 242515c305 audit 3: backup CLI, envelope budget, directory sync, docs; findings of the reviews
Steps 3-6 of stage 3 and what the two reviews found in steps 1-6, in one
commit: the review fixes sit in the same hunks.

Step 3, CLI backup (M3, M5, L1, L2; L4 of the stage's list):
- backup.snapshotDB refuses a database that is not there and opens the
  source mode=ro (measured: mode=ro creates no file).
- selfpost-backup holds an encrypting password to
  validate.MinSecretFilePasswordLen; -o of both directions writes a
  temporary file beside the name and renames it whole; -decrypt peeks the
  first chunk (password and type authenticated) before the output exists.
- what a backup leaves out (a link under data/, a link under certs/ to a
  directory, a socket...) is told to OnWarn; certs/ with no file is
  backup.ErrNoCerts, refused by Check and Create, named by the panel.
- parseTrustedProxies: a bare address takes /128 when written with a
  colon (::ffff:10.0.0.1/32 was ::/32).
Step 4, secretfile (M4, L3): scrypt's V at most 128 MiB, r <= 64, p <= 2;
bytes after the last chunk are ErrCorrupt.
Step 5 (L7): atomicfile.SyncDir after the rename, used by Write,
replaceSASLDB and the CLI; atomicfile_test.go.
Step 6: security.md and guide on backup and restore.

Reviews (technical, security):
- H1: a restore of a server with DMARC on was refused on every start - the
  archive records no owner, tar as root left the DMARC maps root's, and the
  entrypoint only chowned the maps it named. It now hands every file beside
  queue/ in /data/postfix to panel:selfpost 0640; e2e writes an extra map
  before the backup and finds it panel:selfpost 640 after a root unpack.
- the comparison reads regular files only (Lstat, size, LimitReader); a
  record unpacked again wins over the earlier check's mark; ConsumeRestore
  removes both; the mark is synced; a journal file beside a freshly unpacked
  database is refused.
- AttachRefusalLine refuses an empty line as the old statement did; skipped
  entries named in words; -decrypt of an empty payload is no failure; guide
  wording on the shell's '>' and a killed CLI; the in-place restore's
  'mv .' (older than this stage) fixed.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 643 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:38:39 +03:00

227 lines
7.5 KiB
Go

package secretfile
import (
"bytes"
"encoding/binary"
"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 the budget
if _, err := NewReader(bytes.NewReader(bad), "password password"); !errors.Is(err, ErrCorrupt) {
t.Fatalf("err = %v, want ErrCorrupt", err)
}
}
// A header may ask for no more than the stated budget: 128·N·r bytes of
// scrypt's array at most 128 MiB, r at most 64, p at most 2 (audit
// 2026-10-12, M4; security review of stage 3). The writer's own parameters
// are a quarter of it.
func TestKDFBudget(t *testing.T) {
enc := encrypt(t, TypeFullBackup, "password password", []byte("x"))
header := func(logN uint8, r, p uint32) []byte {
h := bytes.Clone(enc[:headerLen])
h[MagicLen+2] = logN
binary.BigEndian.PutUint32(h[MagicLen+3:], r)
binary.BigEndian.PutUint32(h[MagicLen+7:], p)
return h
}
for _, c := range []struct {
logN uint8
r, p uint32
ok bool
}{
{defaultLogN, defaultR, defaultP, true},
{17, 8, 2, true}, // 128 MiB, the budget
{20, 1, 1, true}, // 128 MiB another way
{18, 8, 1, false}, // 256 MiB
{15, 64, 1, false}, // 256 MiB
{20, 32, 16, false}, // what the old cap let through: 4 GiB
{15, 8, 3, false}, // p over 2
{0, 8, 1, false}, // N of 1
{31, 1, 1, false}, // past the shift
{1, 1 << 21, 1, false}, // r alone over the budget
{1, 1 << 19, 2, false}, // V small, B and XY 384 MiB
{11, 64, 2, true}, // r at its cap
{11, 65, 1, false}, // r past it
{30, 1 << 20, 1, false}, // the largest product, still refused
{15, 0, 1, false},
{15, 8, 0, false},
} {
_, err := parseHeader(header(c.logN, c.r, c.p))
if c.ok && err != nil {
t.Errorf("logN=%d r=%d p=%d refused: %v", c.logN, c.r, c.p, err)
}
if !c.ok && !errors.Is(err, ErrCorrupt) {
t.Errorf("logN=%d r=%d p=%d = %v, want ErrCorrupt", c.logN, c.r, c.p, err)
}
}
}
// Nothing may follow the last chunk: two envelopes end to end, or stray
// bytes, are damage rather than the first file (audit 2026-10-12, L3).
func TestBytesAfterTheLastChunkRejected(t *testing.T) {
first := encrypt(t, TypeFullBackup, "password password", []byte("first"))
second := encrypt(t, TypeFullBackup, "password password", []byte("second"))
for name, data := range map[string][]byte{
"two envelopes": append(bytes.Clone(first), second...),
"one byte": append(bytes.Clone(first), 0),
} {
if _, _, err := decrypt(t, data, "password password"); !errors.Is(err, ErrCorrupt) {
t.Errorf("%s: err = %v, want ErrCorrupt", name, err)
}
}
if plain, _, err := decrypt(t, first, "password password"); err != nil || string(plain) != "first" {
t.Errorf("the envelope alone = %q, %v", plain, err)
}
}