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>
This commit is contained in:
@@ -105,12 +105,19 @@ const (
|
||||
defaultR = 8
|
||||
defaultP = 1
|
||||
|
||||
// maxLogN caps what a file may ask for, so a hostile header cannot make the
|
||||
// reader allocate its way to an out-of-memory kill before it ever fails
|
||||
// authentication.
|
||||
maxLogN = 20
|
||||
maxR = 32
|
||||
maxP = 16
|
||||
// What a file may ask of the reader, so a hostile header cannot make it
|
||||
// allocate its way to an out-of-memory kill, or spin, before it ever fails
|
||||
// authentication (audit 2026-10-12, M4). golang.org/x/crypto v0.58.0,
|
||||
// scrypt.Key, allocates V, 128·r·N bytes (make([]uint32, 32*N*r)), B,
|
||||
// p·128·r bytes of PBKDF2 output, and XY, 256·r; its work is N·r·p. V is
|
||||
// held to four times what the writer asks (128·2^15·8 = 32 MiB), r to 64
|
||||
// - the writer's is 8 - so B and XY stay under 32 KiB together (a header
|
||||
// with N=2 and a huge r asked for 384 MiB in B and XY: security review of
|
||||
// stage 3), p to 2 - the writer's is 1. The domain import reads a file
|
||||
// any global user uploads, the CLI a file of any origin.
|
||||
maxScryptMemory = 128 << 20
|
||||
maxR = 64
|
||||
maxP = 2
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -180,8 +187,11 @@ func parseHeader(buf []byte) (*header, error) {
|
||||
h.salt = buf[i : i+saltLen]
|
||||
h.noncePrefix = buf[i+saltLen : i+saltLen+noncePrefixLen]
|
||||
|
||||
// Reject absurd work factors before spending any memory on them.
|
||||
if h.logN < 1 || h.logN > maxLogN || h.r < 1 || h.r > maxR || h.p < 1 || h.p > maxP {
|
||||
// Reject absurd work factors before spending any memory on them. logN
|
||||
// under 31 and r under 65 keep the shift and the product in range; then
|
||||
// the product is held to the budget.
|
||||
if h.logN < 1 || h.logN > 30 || h.r < 1 || h.r > maxR || h.p < 1 || h.p > maxP ||
|
||||
128*(uint64(1)<<h.logN)*uint64(h.r) > maxScryptMemory {
|
||||
return nil, fmt.Errorf("%w: unreasonable key-derivation parameters", ErrCorrupt)
|
||||
}
|
||||
return h, nil
|
||||
@@ -450,6 +460,15 @@ func (d *Reader) next() error {
|
||||
if err != nil {
|
||||
return ErrWrongPassword
|
||||
}
|
||||
// The last chunk ends the file: bytes after it - another envelope
|
||||
// appended, say - are damage, not something to ignore (audit
|
||||
// 2026-10-12, L3).
|
||||
var more [1]byte
|
||||
if n, err := io.ReadFull(d.r, more[:]); n > 0 {
|
||||
return fmt.Errorf("%w: bytes after the last chunk", ErrCorrupt)
|
||||
} else if err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("secretfile: read chunk: %w", err)
|
||||
}
|
||||
d.done = true
|
||||
}
|
||||
d.plain = plain
|
||||
|
||||
@@ -2,6 +2,7 @@ package secretfile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
@@ -155,8 +156,71 @@ func TestEmptyPasswordRejected(t *testing.T) {
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user