Reject a decrypted snapshot database that is not the requested one (closes #156)
check / check (pull_request) Successful in 3m6s

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 (no key material needed) could redirect the operation to a
different snapshot, 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 exactly one snapshot row, and a
snapshot's remote key is derived from that row's ID, so the database is the
requested one exactly when its sole snapshot hashes back to the remote key
fetched. Comparing the requested identifier directly would wrongly reject a
recovery host that supplies a remote-key prefix in place of the human ID it
cannot know.

The shared check lives in verifySnapshotDBIdentity, backed by a new
SnapshotRepository.GetOnlySnapshot; both restore and deep verify call it.

Model: opus-4-8
This commit is contained in:
2026-09-22 12:27:35 +00:00
parent ae6aaaa388
commit 2981f76e0d
4 changed files with 396 additions and 18 deletions
+50 -1
View File
@@ -19,6 +19,7 @@ import (
"sneak.berlin/go/vaultik/internal/blobgen"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/types"
)
@@ -41,6 +42,8 @@ var (
"restored file has trailing data after its last chunk")
errRestoreIncomplete = errors.New(
"restore loop ended with files still pending")
errSnapshotDBMismatch = errors.New(
"decrypted database is not the requested snapshot")
)
// snapshotDBFilename is the name the decrypted snapshot database is
@@ -655,7 +658,53 @@ func (v *Vaultik) downloadSnapshotDB(
log.Debug("Decrypted database", "size", ubytes(int64(len(dbData))))
return v.materializeSnapshotDB(dbData)
db, tempDir, err := v.materializeSnapshotDB(dbData)
if err != nil {
return nil, "", err
}
// Confirm the decrypted database really is the snapshot named by
// remoteKey before any files are read from it. On mismatch, close the
// database and remove its private directory so nothing is left behind.
err = v.verifySnapshotDBIdentity(db, snapshotID, remoteKey)
if err != nil {
_ = db.Close()
_ = v.Fs.RemoveAll(tempDir)
return nil, "", err
}
return db, tempDir, nil
}
// verifySnapshotDBIdentity confirms the decrypted metadata database really
// is the snapshot named by remoteKey. age decryption proves the database
// is readable, not that the object served at
// metadata/<remoteKey>/db.zst.age is the snapshot that was requested: an
// attacker who swaps in another valid db.zst.age (which needs no key
// material) would otherwise redirect restore and deep verify to a
// different snapshot's contents. The exported per-snapshot database holds
// exactly one snapshot row, and a snapshot's remote key is derived from
// that row's ID, so the database is the requested one exactly when its
// sole snapshot hashes back to remoteKey. Comparing the requested
// identifier directly would not do: it may be a remote-key prefix a
// recovery host uses in place of a human snapshot ID it cannot know.
func (v *Vaultik) verifySnapshotDBIdentity(
db *database.DB, requested, remoteKey string,
) error {
repos := database.NewRepositories(db)
snap, err := repos.Snapshots.GetOnlySnapshot(v.ctx)
if err != nil {
return fmt.Errorf("checking identity of database for %s: %w", requested, err)
}
if snapshot.RemoteSnapshotKey(snap.ID.String()) != remoteKey {
return fmt.Errorf("%w: requested %s but the database is snapshot %s",
errSnapshotDBMismatch, requested, snap.ID)
}
return nil
}
// materializeSnapshotDB writes the decrypted snapshot database bytes into