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)) }