diff --git a/internal/vaultik/blob_fetch.go b/internal/vaultik/blob_fetch.go index 4672921..4a2ccc1 100644 --- a/internal/vaultik/blob_fetch.go +++ b/internal/vaultik/blob_fetch.go @@ -22,6 +22,10 @@ var errBlobHashMismatch = errors.New("blob hash mismatch") var errBlobNotFullyRead = errors.New( "blob closed before fully read; hash not verified") +// errWrongPrivateKeyForBlob is returned when the configured key parses +// but is not one of the keys a blob was encrypted to. +var errWrongPrivateKeyForBlob = errors.New("cannot decrypt this blob") + // hashVerifyReader wraps a blobgen.Reader and verifies the double-SHA-256 hash // of decrypted plaintext when Close is called. It reuses the hash that // blobgen.Reader already computes internally via its TeeReader, avoiding @@ -87,10 +91,14 @@ func (v *Vaultik) FetchAndDecryptBlob( return nil, err } - reader, err := blobgen.NewReader(rc, identities...) + reader, err := v.newBlobReader(rc, identities) if err != nil { _ = rc.Close() + if errors.Is(err, errWrongPrivateKeyForBlob) { + return nil, err + } + return nil, fmt.Errorf("creating blob reader: %w", err) } @@ -102,6 +110,28 @@ func (v *Vaultik) FetchAndDecryptBlob( }, nil } +// newBlobReader opens the decrypting reader for a blob. When none of the +// identities can decrypt it, the error says in plain words that the +// configured private key is not one of the keys the blob was encrypted +// to and names where the key was configured. +func (v *Vaultik) newBlobReader( + 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 blob was encrypted to", + errWrongPrivateKeyForBlob, v.Config.AgeSecretKeySourceName()) + } + + if err != nil { + return nil, err + } + + return blobReader, nil +} + // FetchBlob downloads a blob and returns a reader for the encrypted data. // The reader is closed as soon as ctx is cancelled, which ends a read // blocked inside it. A storage error is returned as is: restore and deep diff --git a/internal/vaultik/restore.go b/internal/vaultik/restore.go index daedba2..928ca20 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") @@ -142,6 +145,10 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error { log.Info("Downloading snapshot metadata...") tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, remoteKey, identities) + if errors.Is(err, errWrongPrivateKey) { + return err + } + if err != nil { return fmt.Errorf("downloading snapshot database: %w", err) } @@ -338,6 +345,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 @@ -825,9 +854,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..05d88ba --- /dev/null +++ b/internal/vaultik/restore_wrong_key_test.go @@ -0,0 +1,161 @@ +package vaultik_test + +import ( + "bytes" + "context" + "io" + "path" + "path/filepath" + "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/log" + "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) +} + +// TestWrongPrivateKeyForBlobError backs up a file, then backs it up again +// from an empty local index with a second recipient added. The second +// snapshot reuses the stored blob, which stays encrypted to the first +// recipient only. With only the added key configured, restore and deep +// verify read the database but fail on the blob with one plain sentence +// that names where the key was configured. +func TestWrongPrivateKeyForBlobError(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + fs := afero.NewOsFs() + tempDir := t.TempDir() + dataDir := filepath.Join(tempDir, "source") + dataPath := filepath.Join(dataDir, "data.bin") + storeDir := t.TempDir() + secondDBPath := filepath.Join(tempDir, "second.sqlite") + + chunkSize := int64(64 * 1024) + maxBlobSize := int64(512 * 1024) + + require.NoError(t, fs.MkdirAll(dataDir, 0o755)) + require.NoError(t, afero.WriteFile(fs, dataPath, + bytesPattern("key-", int(chunkSize)), 0o644)) + + ctx := context.Background() + + runFileStorageBackup(ctx, t, fs, dataDir, storeDir, + filepath.Join(tempDir, "first.sqlite"), chunkSize, maxBlobSize) + + env := setupDedupBackupEnv( + ctx, t, fs, storeDir, secondDBPath, chunkSize, maxBlobSize) + + defer func() { _ = env.db.Close() }() + + addedKey, err := age.GenerateX25519Identity() + require.NoError(t, err) + + env.cfg.AgeRecipients = append(env.cfg.AgeRecipients, + addedKey.Recipient().String()) + + snapshotID, result := runDedupSnapshot(ctx, t, env.sm, env.makeScanner(), + env.cfg.Hostname, "second", dataDir, secondDBPath) + require.Zero(t, result.BlobsUploaded, + "the second backup should reuse the stored blob") + + require.NoError(t, env.db.Close()) + + blobKeys, err := env.storer.List(ctx, "blobs/") + require.NoError(t, err) + require.Len(t, blobKeys, 1) + + blobHash := path.Base(blobKeys[0]) + + env.cfg.AgeSecretKey = addedKey.String() + + v := &vaultik.Vaultik{ + Config: env.cfg, + Storage: env.storer, + Fs: fs, + Stdout: io.Discard, + Stderr: io.Discard, + UI: ui.NewWithColor(io.Discard, false), + } + v.SetContext(ctx) + + const sentence = "cannot decrypt this blob: the private key in " + + "age_secret_key is not one of the keys the blob was encrypted to" + + // Restore names the blob by the first 16 characters of its hash. + err = v.Restore(&vaultik.RestoreOptions{ + SnapshotID: snapshotID, + TargetDir: t.TempDir(), + }) + require.EqualError(t, err, "restoring "+dataPath+": downloading blob "+ + blobHash[:16]+": "+sentence+ + " (pass --skip-errors to continue past restore failures)") + + err = v.RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true}) + require.EqualError(t, err, + "blob "+blobHash+" verification failed: "+sentence) +} diff --git a/internal/vaultik/verify.go b/internal/vaultik/verify.go index c8f9ff5..0284e4a 100644 --- a/internal/vaultik/verify.go +++ b/internal/vaultik/verify.go @@ -264,6 +264,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), @@ -348,9 +352,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() }() @@ -423,7 +427,11 @@ func (v *Vaultik) verifyBlob( // the plaintext as it is read. A blob's hash — its remote name — is the // double SHA-256 of that plaintext (see blobgen.DoubleSHA256), not of the // encrypted bytes. - blobReader, err := blobgen.NewReader(reader, identities...) + blobReader, err := v.newBlobReader(reader, identities) + if errors.Is(err, errWrongPrivateKeyForBlob) { + return err + } + if err != nil { return fmt.Errorf("failed to create blob reader: %w", err) }