Say plainly when the private key cannot decrypt a snapshot (closes #427) #448
No files matched your search
@@ -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
|
||||
|
||||
@@ -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() }()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user