An object holding just the age header and its 16-byte nonce decrypts without error: the truncated read surfaces as io.ErrUnexpectedEOF at the age layer, which the zstd decoder maps to a clean EOF at frame start. blobgen then reported zero bytes and no error, so a truncated stream was indistinguishable from a valid empty one, and the metadata database export slipped through -- restore built a fresh schema on the empty file and reported 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. downloadSnapshotDB additionally rejects a zero-length decrypted database before any schema is built. Model: opus-4-8
86 lines
2.6 KiB
Go
86 lines
2.6 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"filippo.io/age"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// TestRestoreRejectsTruncatedMetadataDB backs up a real tree, then replaces
|
|
// the snapshot's db.zst.age with a stream cut right after the age header and
|
|
// its 16-byte nonce. age.Decrypt still accepts such an object and the zstd
|
|
// decoder turns the truncated read into a clean EOF, so before the fix restore
|
|
// built a fresh empty schema and reported success. Restore must now fail with
|
|
// io.ErrUnexpectedEOF, the error the reader raises for a truncated object.
|
|
// Asserting that specific error pins the reader fix: without it the truncation
|
|
// yields an empty database, which the identity check rejects for an unrelated
|
|
// reason, and this test would pass anyway.
|
|
func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
fs := afero.NewOsFs()
|
|
tempDir := t.TempDir()
|
|
|
|
dataDir := filepath.Join(tempDir, "source")
|
|
storeDir := filepath.Join(tempDir, "remote")
|
|
restoreDir := filepath.Join(tempDir, "restored")
|
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
|
|
chunkSize := int64(64 * 1024)
|
|
maxBlobSize := int64(512 * 1024)
|
|
|
|
setupE2ESourceTree(t, fs, dataDir, chunkSize)
|
|
|
|
ctx := context.Background()
|
|
|
|
cfg, storer, snapshotID := runFileStorageBackup(
|
|
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
|
|
|
// Encrypting empty plaintext to the snapshot recipient 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 an attacker can write over metadata without any key.
|
|
recipient, err := age.ParseX25519Recipient(testAgePublicKey)
|
|
require.NoError(t, err)
|
|
|
|
var full bytes.Buffer
|
|
|
|
w, err := age.Encrypt(&full, recipient)
|
|
require.NoError(t, err)
|
|
require.NoError(t, w.Close())
|
|
|
|
truncated := full.Bytes()[:full.Len()-16]
|
|
|
|
dbKeyPath := filepath.Join(storeDir, "metadata",
|
|
snapshot.RemoteSnapshotKey(snapshotID), "db.zst.age")
|
|
require.NoError(t, afero.WriteFile(fs, dbKeyPath, truncated, 0o644))
|
|
|
|
restoreVaultik := &vaultik.Vaultik{
|
|
Config: cfg,
|
|
Storage: storer,
|
|
Fs: fs,
|
|
Stdout: io.Discard,
|
|
Stderr: io.Discard,
|
|
UI: ui.NewWithColor(io.Discard, false),
|
|
}
|
|
restoreVaultik.SetContext(ctx)
|
|
|
|
err = restoreVaultik.Restore(&vaultik.RestoreOptions{
|
|
SnapshotID: snapshotID,
|
|
TargetDir: restoreDir,
|
|
Verify: true,
|
|
})
|
|
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
|
}
|