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:
mix
2026-10-15 10:38:39 +03:00
co-authored by Claude Opus 5.5
parent 508ac96c79
commit 242515c305
24 changed files with 901 additions and 90 deletions
+27 -8
View File
@@ -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