Restore and deep verify downloaded and decrypted metadata/<key>/db.zst.age by object name alone. age decryption proves the database is readable, not that it is the snapshot that was asked for: an attacker who swaps in another valid db.zst.age could redirect the operation, and deep verify with a swapped database plus an empty manifest reported success with zero blobs verified. After the database is opened, both paths now confirm its identity: an exported per-snapshot database holds one snapshot row, and a snapshot remote key derives from that row ID, so the database is the requested one exactly when its sole snapshot hashes back to the remote key fetched. The shared check lives in verifySnapshotDBIdentity, backed by a new SnapshotRepository.GetOnlySnapshot. Model: opus-4-8
252 lines
8.3 KiB
Go
252 lines
8.3 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/config"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/storage"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// TestRestoreAndDeepVerifyRejectSwappedDatabase proves that swapping two
|
|
// snapshots' encrypted databases on the store is caught. age decryption
|
|
// alone proves only that a database is readable; without an identity check
|
|
// restore would happily write the wrong snapshot's files and deep verify
|
|
// would report success. After the swap, restore and deep verify of A both
|
|
// fail, and the error names the snapshot the database actually holds (B).
|
|
func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
fs := afero.NewOsFs()
|
|
tempDir := t.TempDir()
|
|
storeDir := filepath.Join(tempDir, "remote")
|
|
|
|
chunkSize := int64(64 * 1024)
|
|
|
|
storer, err := storage.NewFileStorer(storeDir)
|
|
require.NoError(t, err)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Two snapshots with different content, backed up into one shared
|
|
// store. Different names give them different remote keys, so their
|
|
// metadata directories are distinct and can be tampered with alone.
|
|
dataA := filepath.Join(tempDir, "srcA")
|
|
require.NoError(t, fs.MkdirAll(dataA, 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataA, "a.bin"),
|
|
bytesPattern("alpha-", int(chunkSize*2)), 0o644))
|
|
|
|
dataB := filepath.Join(tempDir, "srcB")
|
|
require.NoError(t, fs.MkdirAll(dataB, 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataB, "b.bin"),
|
|
bytesPattern("beta-", int(chunkSize*2)), 0o644))
|
|
|
|
idA := backupNamedSnapshotToStore(ctx, t, fs, dataA, storer,
|
|
filepath.Join(tempDir, "idxA.sqlite"), "alpha")
|
|
idB := backupNamedSnapshotToStore(ctx, t, fs, dataB, storer,
|
|
filepath.Join(tempDir, "idxB.sqlite"), "beta")
|
|
require.NotEqual(t, idA, idB)
|
|
|
|
keyA := snapshot.RemoteSnapshotKey(idA)
|
|
keyB := snapshot.RemoteSnapshotKey(idB)
|
|
require.NotEqual(t, keyA, keyB)
|
|
|
|
// Baseline: each snapshot verifies against its own intact metadata.
|
|
require.NoError(t, newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
|
idA, &vaultik.VerifyOptions{Deep: true}))
|
|
require.NoError(t, newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
|
idB, &vaultik.VerifyOptions{Deep: true}))
|
|
|
|
// Swap the two snapshots' encrypted databases on the store.
|
|
swapStoreFiles(t, fs,
|
|
filepath.Join(storeDir, "metadata", keyA, "db.zst.age"),
|
|
filepath.Join(storeDir, "metadata", keyB, "db.zst.age"))
|
|
|
|
// Restore of A now decrypts B's database; the identity check must
|
|
// reject it and name the snapshot it actually found.
|
|
restoreErr := newStoreClient(ctx, t, fs, storer).Restore(&vaultik.RestoreOptions{
|
|
SnapshotID: idA,
|
|
TargetDir: filepath.Join(tempDir, "restoreA"),
|
|
})
|
|
require.Error(t, restoreErr)
|
|
require.ErrorContains(t, restoreErr, idB)
|
|
|
|
// Deep verify of A must reject the swapped database for the same reason.
|
|
verifyErr := newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
|
idA, &vaultik.VerifyOptions{Deep: true})
|
|
require.Error(t, verifyErr)
|
|
require.ErrorContains(t, verifyErr, idB)
|
|
}
|
|
|
|
// TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest covers the case the
|
|
// issue calls out: swapping in a database whose blob set is empty and
|
|
// pairing it with an equally empty manifest. The manifest then agrees with
|
|
// the database, so every blob-level check passes and deep verify used to
|
|
// report success with zero blobs verified. The identity check rejects it
|
|
// before any blob check runs.
|
|
func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
fs := afero.NewOsFs()
|
|
tempDir := t.TempDir()
|
|
storeDir := filepath.Join(tempDir, "remote")
|
|
|
|
chunkSize := int64(64 * 1024)
|
|
|
|
storer, err := storage.NewFileStorer(storeDir)
|
|
require.NoError(t, err)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Snapshot A: real content, so its manifest lists blobs.
|
|
dataA := filepath.Join(tempDir, "srcA")
|
|
require.NoError(t, fs.MkdirAll(dataA, 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataA, "a.bin"),
|
|
bytesPattern("alpha-", int(chunkSize*2)), 0o644))
|
|
idA := backupNamedSnapshotToStore(ctx, t, fs, dataA, storer,
|
|
filepath.Join(tempDir, "idxA.sqlite"), "alpha")
|
|
|
|
// Snapshot C: a single empty file, so it references no blobs and its
|
|
// manifest is empty. This is the database/manifest pair an attacker
|
|
// would swap in to make the blob checks vacuously pass.
|
|
dataC := filepath.Join(tempDir, "srcC")
|
|
require.NoError(t, fs.MkdirAll(dataC, 0o755))
|
|
require.NoError(t, afero.WriteFile(fs,
|
|
filepath.Join(dataC, "empty.bin"), []byte{}, 0o644))
|
|
idC := backupNamedSnapshotToStore(ctx, t, fs, dataC, storer,
|
|
filepath.Join(tempDir, "idxC.sqlite"), "charlie")
|
|
|
|
keyA := snapshot.RemoteSnapshotKey(idA)
|
|
keyC := snapshot.RemoteSnapshotKey(idC)
|
|
|
|
// Replace A's database and manifest with C's empty pair.
|
|
copyStoreFile(t, fs,
|
|
filepath.Join(storeDir, "metadata", keyC, "db.zst.age"),
|
|
filepath.Join(storeDir, "metadata", keyA, "db.zst.age"))
|
|
copyStoreFile(t, fs,
|
|
filepath.Join(storeDir, "metadata", keyC, "manifest.json.zst"),
|
|
filepath.Join(storeDir, "metadata", keyA, "manifest.json.zst"))
|
|
|
|
verifyErr := newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
|
idA, &vaultik.VerifyOptions{Deep: true})
|
|
require.Error(t, verifyErr)
|
|
require.ErrorContains(t, verifyErr, idC)
|
|
}
|
|
|
|
// backupNamedSnapshotToStore backs up dataDir into the shared storer under
|
|
// the given snapshot name and returns the human snapshot ID. Two snapshots
|
|
// backed up under different names get different remote keys, so their
|
|
// metadata directories on the store are distinct.
|
|
func backupNamedSnapshotToStore(
|
|
ctx context.Context, t *testing.T, fs afero.Fs,
|
|
dataDir string, storer storage.Storer, dbPath, name string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
const (
|
|
chunkSize = int64(64 * 1024)
|
|
maxBlobSize = int64(512 * 1024)
|
|
)
|
|
|
|
cfg := &config.Config{
|
|
AgeRecipients: []string{testAgePublicKey},
|
|
AgeSecretKey: testAgeSecretKey,
|
|
CompressionLevel: 3,
|
|
Hostname: testHostname,
|
|
}
|
|
|
|
db, err := database.New(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
sm := snapshot.NewSnapshotManager(snapshot.SnapshotManagerParams{
|
|
Repos: repos,
|
|
Storage: storer,
|
|
Config: cfg,
|
|
})
|
|
sm.SetFilesystem(fs)
|
|
|
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
|
FS: fs,
|
|
Storage: storer,
|
|
ChunkSize: chunkSize,
|
|
MaxBlobSize: maxBlobSize,
|
|
CompressionLevel: cfg.CompressionLevel,
|
|
AgeRecipients: cfg.AgeRecipients,
|
|
Repositories: repos,
|
|
})
|
|
|
|
snapshotID, err := sm.CreateSnapshotWithName(
|
|
ctx, cfg.Hostname, name, "test-version", "test-git")
|
|
require.NoError(t, err)
|
|
|
|
_, err = scanner.Scan(ctx, dataDir, snapshotID)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
|
|
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
|
require.NoError(t, db.Close())
|
|
|
|
return snapshotID
|
|
}
|
|
|
|
// newStoreClient builds a Vaultik that reads only from the store: the
|
|
// secret key, the storer, and a filesystem, with no local index. This is
|
|
// what restore and deep verify need.
|
|
func newStoreClient(
|
|
ctx context.Context, t *testing.T, fs afero.Fs, storer storage.Storer,
|
|
) *vaultik.Vaultik {
|
|
t.Helper()
|
|
|
|
v := &vaultik.Vaultik{
|
|
Config: &config.Config{
|
|
AgeSecretKey: testAgeSecretKey,
|
|
Hostname: testHostname,
|
|
},
|
|
Storage: storer,
|
|
Fs: fs,
|
|
Stdout: io.Discard,
|
|
Stderr: io.Discard,
|
|
UI: ui.NewWithColor(io.Discard, false),
|
|
}
|
|
v.SetContext(ctx)
|
|
|
|
return v
|
|
}
|
|
|
|
// swapStoreFiles exchanges the contents of two files on the store.
|
|
func swapStoreFiles(t *testing.T, fs afero.Fs, a, b string) {
|
|
t.Helper()
|
|
|
|
dataA, err := afero.ReadFile(fs, a)
|
|
require.NoError(t, err)
|
|
|
|
dataB, err := afero.ReadFile(fs, b)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, afero.WriteFile(fs, a, dataB, 0o644))
|
|
require.NoError(t, afero.WriteFile(fs, b, dataA, 0o644))
|
|
}
|
|
|
|
// copyStoreFile overwrites dst with the contents of src on the store.
|
|
func copyStoreFile(t *testing.T, fs afero.Fs, src, dst string) {
|
|
t.Helper()
|
|
|
|
data, err := afero.ReadFile(fs, src)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, afero.WriteFile(fs, dst, data, 0o644))
|
|
}
|