From 1954080faf8115fdf6ba1af82ad372422459e535 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 11 Nov 2026 01:41:28 +0000 Subject: [PATCH] Say plainly when the private key cannot decrypt a snapshot (closes #427) Restore and deep verify now report "cannot decrypt this snapshot: the private key in VAULTIK_AGE_SECRET_KEY is not one of the keys the backup was encrypted to", naming the key source like the invalid-key error. The doubled "creating decryption reader" prefix and the outer "downloading snapshot database" / "failed to decrypt database" prefixes are gone for this case. Model: opus-5-5 --- internal/vaultik/restore.go | 33 +++++++++- internal/vaultik/restore_wrong_key_test.go | 76 ++++++++++++++++++++++ internal/vaultik/verify.go | 8 ++- 3 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 internal/vaultik/restore_wrong_key_test.go diff --git a/internal/vaultik/restore.go b/internal/vaultik/restore.go index f02caeb..935ef72 100644 --- a/internal/vaultik/restore.go +++ b/internal/vaultik/restore.go @@ -40,6 +40,9 @@ var ( // which is secret, so the message is safe to print and log. errInvalidAgeSecretKey = errors.New( "configured age secret key holds no usable age identity") + // errWrongPrivateKey is returned when the configured key parses but + // is not one of the keys the snapshot was encrypted to. + errWrongPrivateKey = errors.New("cannot decrypt this snapshot") errBlobMissingFromIndex = errors.New("blob hash missing from blob index") errChunkNotInAnyBlob = errors.New("chunk not found in any blob") errBlobIDNotInHashIndex = errors.New("blob id missing from hash index") @@ -132,6 +135,10 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error { log.Info("Downloading snapshot metadata...") tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, identities) + if errors.Is(err, errWrongPrivateKey) { + return err + } + if err != nil { return fmt.Errorf("downloading snapshot database: %w", err) } @@ -323,6 +330,28 @@ func (v *Vaultik) restoreIdentities() ([]age.Identity, error) { return identities, nil } +// newSnapshotDBReader opens the decrypting reader for a snapshot +// database. When none of the identities can decrypt it, the error says +// in plain words that the configured private key is the wrong one and +// names where it was configured. +func (v *Vaultik) newSnapshotDBReader( + r io.Reader, identities []age.Identity, +) (*blobgen.Reader, error) { + blobReader, err := blobgen.NewReader(r, identities...) + + if _, ok := errors.AsType[*age.NoIdentityMatchError](err); ok { + return nil, fmt.Errorf( + "%w: the private key in %s is not one of the keys the backup was encrypted to", + errWrongPrivateKey, v.Config.AgeSecretKeySourceName()) + } + + if err != nil { + return nil, err + } + + return blobReader, nil +} + // restoreAllFiles processes files in blob-locality order: drain every // file whose blob set is on disk, download the missing blobs for the // pending file with the smallest uncached count, repeat. This keeps @@ -783,9 +812,9 @@ func (v *Vaultik) downloadSnapshotDB( // the plaintext to a temp file. Neither the encrypted bytes nor the // decrypted database is ever held whole in memory; a snapshot database // can be large. - blobReader, err := blobgen.NewReader(reader, identities...) + blobReader, err := v.newSnapshotDBReader(reader, identities) if err != nil { - return nil, "", fmt.Errorf("creating decryption reader: %w", err) + return nil, "", err } defer func() { _ = blobReader.Close() }() diff --git a/internal/vaultik/restore_wrong_key_test.go b/internal/vaultik/restore_wrong_key_test.go new file mode 100644 index 0000000..6843fa6 --- /dev/null +++ b/internal/vaultik/restore_wrong_key_test.go @@ -0,0 +1,76 @@ +package vaultik_test + +import ( + "bytes" + "context" + "io" + "testing" + + "filippo.io/age" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/blobgen" + "sneak.berlin/go/vaultik/internal/config" + "sneak.berlin/go/vaultik/internal/snapshot" + "sneak.berlin/go/vaultik/internal/ui" + "sneak.berlin/go/vaultik/internal/vaultik" +) + +// TestWrongPrivateKeyError checks that restore and deep verify, given a +// valid private key the backup was not encrypted to, fail with one plain +// sentence that names where the key was configured. +func TestWrongPrivateKeyError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + store := NewMockStorer() + + const snapshotID = "test-host_2026-01-01T00:00:00Z" + + remoteKey := snapshot.RemoteSnapshotKey(snapshotID) + + manifest, err := snapshot.EncodeManifest( + &snapshot.Manifest{SnapshotID: remoteKey}, 3) + require.NoError(t, err) + require.NoError(t, store.Put(ctx, + "metadata/"+remoteKey+"/manifest.json.zst", bytes.NewReader(manifest))) + + // The database is encrypted to the test recipient only. + var db bytes.Buffer + + writer, err := blobgen.NewWriter(&db, 1, []string{testAgePublicKey}) + require.NoError(t, err) + + _, err = writer.Write([]byte("snapshot database")) + require.NoError(t, err) + require.NoError(t, writer.Close()) + require.NoError(t, store.Put(ctx, + "metadata/"+remoteKey+"/db.zst.age", &db)) + + otherKey, err := age.GenerateX25519Identity() + require.NoError(t, err) + + v := &vaultik.Vaultik{ + Config: &config.Config{AgeSecretKey: otherKey.String()}, + Storage: store, + Fs: afero.NewMemMapFs(), + Stdout: io.Discard, + Stderr: io.Discard, + UI: ui.NewWithColor(io.Discard, false), + } + v.SetContext(ctx) + + // A Config built directly has no recorded source, so the error names + // the config-file field. + const want = "cannot decrypt this snapshot: the private key in " + + "age_secret_key is not one of the keys the backup was encrypted to" + + err = v.Restore(&vaultik.RestoreOptions{ + SnapshotID: snapshotID, + TargetDir: t.TempDir(), + }) + require.EqualError(t, err, want) + + err = v.RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true}) + require.EqualError(t, err, want) +} diff --git a/internal/vaultik/verify.go b/internal/vaultik/verify.go index ba6c2d3..f226db5 100644 --- a/internal/vaultik/verify.go +++ b/internal/vaultik/verify.go @@ -267,6 +267,10 @@ func (v *Vaultik) downloadVerifiedSnapshotDB( defer func() { _ = dbReader.Close() }() tdb, err := v.decryptAndLoadDatabase(dbReader, identities) + if errors.Is(err, errWrongPrivateKey) { + return nil, v.deepVerifyFailure(result, opts, err.Error(), err) + } + if err != nil { return nil, v.deepVerifyFailure(result, opts, fmt.Sprintf("failed to decrypt database: %v", err), @@ -351,9 +355,9 @@ func (v *Vaultik) decryptAndLoadDatabase( reader io.ReadCloser, identities []age.Identity, ) (*tempDB, error) { // Decrypt and decompress through the shared blobgen reader. - blobReader, err := blobgen.NewReader(reader, identities...) + blobReader, err := v.newSnapshotDBReader(reader, identities) if err != nil { - return nil, fmt.Errorf("failed to create decryption reader: %w", err) + return nil, err } defer func() { _ = blobReader.Close() }()