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(
|
var errBlobNotFullyRead = errors.New(
|
||||||
"blob closed before fully read; hash not verified")
|
"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
|
// hashVerifyReader wraps a blobgen.Reader and verifies the double-SHA-256 hash
|
||||||
// of decrypted plaintext when Close is called. It reuses the hash that
|
// of decrypted plaintext when Close is called. It reuses the hash that
|
||||||
// blobgen.Reader already computes internally via its TeeReader, avoiding
|
// blobgen.Reader already computes internally via its TeeReader, avoiding
|
||||||
@@ -87,10 +91,14 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := blobgen.NewReader(rc, identities...)
|
reader, err := v.newBlobReader(rc, identities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = rc.Close()
|
_ = rc.Close()
|
||||||
|
|
||||||
|
if errors.Is(err, errWrongPrivateKeyForBlob) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("creating blob reader: %w", err)
|
return nil, fmt.Errorf("creating blob reader: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +110,28 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
|||||||
}, nil
|
}, 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.
|
// 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
|
// 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
|
// 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.
|
// which is secret, so the message is safe to print and log.
|
||||||
errInvalidAgeSecretKey = errors.New(
|
errInvalidAgeSecretKey = errors.New(
|
||||||
"configured age secret key holds no usable age identity")
|
"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")
|
errBlobMissingFromIndex = errors.New("blob hash missing from blob index")
|
||||||
errChunkNotInAnyBlob = errors.New("chunk not found in any blob")
|
errChunkNotInAnyBlob = errors.New("chunk not found in any blob")
|
||||||
errBlobIDNotInHashIndex = errors.New("blob id missing from hash index")
|
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...")
|
log.Info("Downloading snapshot metadata...")
|
||||||
|
|
||||||
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, remoteKey, identities)
|
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, remoteKey, identities)
|
||||||
|
if errors.Is(err, errWrongPrivateKey) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("downloading snapshot database: %w", err)
|
return fmt.Errorf("downloading snapshot database: %w", err)
|
||||||
}
|
}
|
||||||
@@ -338,6 +345,28 @@ func (v *Vaultik) restoreIdentities() ([]age.Identity, error) {
|
|||||||
return identities, nil
|
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
|
// restoreAllFiles processes files in blob-locality order: drain every
|
||||||
// file whose blob set is on disk, download the missing blobs for the
|
// file whose blob set is on disk, download the missing blobs for the
|
||||||
// pending file with the smallest uncached count, repeat. This keeps
|
// 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
|
// the plaintext to a temp file. Neither the encrypted bytes nor the
|
||||||
// decrypted database is ever held whole in memory; a snapshot database
|
// decrypted database is ever held whole in memory; a snapshot database
|
||||||
// can be large.
|
// can be large.
|
||||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
blobReader, err := v.newSnapshotDBReader(reader, identities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("creating decryption reader: %w", err)
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = blobReader.Close() }()
|
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() }()
|
defer func() { _ = dbReader.Close() }()
|
||||||
|
|
||||||
tdb, err := v.decryptAndLoadDatabase(dbReader, identities)
|
tdb, err := v.decryptAndLoadDatabase(dbReader, identities)
|
||||||
|
if errors.Is(err, errWrongPrivateKey) {
|
||||||
|
return nil, v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, v.deepVerifyFailure(result, opts,
|
return nil, v.deepVerifyFailure(result, opts,
|
||||||
fmt.Sprintf("failed to decrypt database: %v", err),
|
fmt.Sprintf("failed to decrypt database: %v", err),
|
||||||
@@ -348,9 +352,9 @@ func (v *Vaultik) decryptAndLoadDatabase(
|
|||||||
reader io.ReadCloser, identities []age.Identity,
|
reader io.ReadCloser, identities []age.Identity,
|
||||||
) (*tempDB, error) {
|
) (*tempDB, error) {
|
||||||
// Decrypt and decompress through the shared blobgen reader.
|
// Decrypt and decompress through the shared blobgen reader.
|
||||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
blobReader, err := v.newSnapshotDBReader(reader, identities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create decryption reader: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = blobReader.Close() }()
|
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
|
// 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
|
// double SHA-256 of that plaintext (see blobgen.DoubleSHA256), not of the
|
||||||
// encrypted bytes.
|
// encrypted bytes.
|
||||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
blobReader, err := v.newBlobReader(reader, identities)
|
||||||
|
if errors.Is(err, errWrongPrivateKeyForBlob) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create blob reader: %w", err)
|
return fmt.Errorf("failed to create blob reader: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user