Reject a metadata database truncated to the age header and nonce (closes #152)
check / check (pull_request) Successful in 2m33s

An object holding exactly the age header plus its 16-byte nonce decrypts
without error: age.Decrypt succeeds, and on the first read the age stream's
io.ErrUnexpectedEOF is mapped by the zstd decoder to a clean io.EOF at frame
start. blobgen.Reader.Read then reported zero bytes and no error, so a
truncated stream was indistinguishable from a valid empty one. Blobs are
caught by the Close-time hash check, but the metadata database export was not:
restore built a fresh schema on the empty file and exited reporting success.

blobgen.Reader.Read now, on EOF, reads once more from the age reader and
surfaces io.ErrUnexpectedEOF unless that read is (0, io.EOF) — the state a
genuine end leaves behind, so an empty payload still round-trips to empty with
no error. downloadSnapshotDB additionally rejects a zero-length decrypted
database before OpenReadOnly applies a schema.

On next the decrypted-DB identity check already rejects an empty database, so
the restore-level test guards the required end-to-end property; the blobgen
tests are the ones that fail without the reader change.

Model: opus-4-8
This commit is contained in:
2026-09-22 15:12:48 +00:00
parent 1244c9e48d
commit 03f126edc1
4 changed files with 177 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
package blobgen_test
import (
"bytes"
"io"
"testing"
"filippo.io/age"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/blobgen"
)
// TestReaderRejectsHeaderNonceTruncation guards against a stream cut right
// after the age header plus its 16-byte nonce. age.Decrypt still succeeds on
// such an object, and the zstd decoder maps the age reader's
// io.ErrUnexpectedEOF to a clean io.EOF at frame start, so without the extra
// check the truncated stream would read as a valid empty one. Reading it must
// now fail.
func TestReaderRejectsHeaderNonceTruncation(t *testing.T) {
t.Parallel()
identity, err := age.GenerateX25519Identity()
require.NoError(t, err)
// Encrypting empty plaintext yields header + nonce(16) + a single
// 16-byte final chunk tag. Dropping the trailing tag leaves exactly the
// age header plus its nonce — the truncation point that triggers the bug.
var full bytes.Buffer
w, err := age.Encrypt(&full, identity.Recipient())
require.NoError(t, err)
require.NoError(t, w.Close())
truncated := full.Bytes()[:full.Len()-16]
reader, err := blobgen.NewReader(bytes.NewReader(truncated), identity)
require.NoError(t, err)
defer func() { _ = reader.Close() }()
_, err = io.ReadAll(reader)
require.Error(t, err)
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
}
// TestReaderReadsGenuinelyEmptyBlob confirms the truncation check does not
// reject a legitimately empty payload: a blob written with no data must round
// trip back to zero bytes with no error.
func TestReaderReadsGenuinelyEmptyBlob(t *testing.T) {
t.Parallel()
identity, err := age.GenerateX25519Identity()
require.NoError(t, err)
var encrypted bytes.Buffer
writer, err := blobgen.NewWriter(
&encrypted, 3, []string{identity.Recipient().String()})
require.NoError(t, err)
require.NoError(t, writer.Close())
reader, err := blobgen.NewReader(bytes.NewReader(encrypted.Bytes()), identity)
require.NoError(t, err)
defer func() { _ = reader.Close() }()
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Empty(t, data)
}