Remove the unused crypto path and write the blob-ID hash step once (closes #151)
Production encryption and decryption already run through blobgen; the crypto package (Encryptor, Decryptor, UpdateRecipients, the fx Module) and Vaultik.GetEncryptor/GetDecryptor had no production caller. Delete crypto and route verify --deep through the same blobgen reader restore uses, parsing the age key once. The second blob-ID hash step is now one exported blobgen.DoubleSHA256; Writer.Sum256 (the double hash) becomes Writer.ContentID so it no longer collides with Reader.Sum256 (the single plaintext hash). Also delete the never-adopted internal/types newtypes and the uncalled CleanupIncompleteSnapshots and its now-dead deleteSnapshot caller, and correct ARCHITECTURE.md. No production behavior changes. Model: opus-4-8
This commit was merged in pull request #191.
This commit is contained in:
@@ -2,7 +2,6 @@ package vaultik
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -57,11 +56,7 @@ func (h *hashVerifyReader) Close() error {
|
||||
return errBlobNotFullyRead
|
||||
}
|
||||
|
||||
firstHash := h.reader.Sum256()
|
||||
secondHasher := sha256.New()
|
||||
secondHasher.Write(firstHash)
|
||||
|
||||
actualHashHex := hex.EncodeToString(secondHasher.Sum(nil))
|
||||
actualHashHex := hex.EncodeToString(blobgen.DoubleSHA256(h.reader.Sum256()))
|
||||
if actualHashHex != h.blobHash {
|
||||
return fmt.Errorf("%w: expected %s, got %s",
|
||||
errBlobHashMismatch, h.blobHash[:16], actualHashHex[:16])
|
||||
|
||||
@@ -40,13 +40,13 @@ func buildHashTestBlob(
|
||||
}
|
||||
|
||||
// Compute the double-SHA-256 hash of the plaintext (matches
|
||||
// blobgen.Writer.Sum256).
|
||||
// blobgen.Writer.ContentID).
|
||||
firstHash := sha256.Sum256(plaintext)
|
||||
secondHash := sha256.Sum256(firstHash[:])
|
||||
correctHash := hex.EncodeToString(secondHash[:])
|
||||
|
||||
// Verify our hash matches what blobgen.Writer produces
|
||||
writerHash := hex.EncodeToString(writer.Sum256())
|
||||
writerHash := hex.EncodeToString(writer.ContentID())
|
||||
if correctHash != writerHash {
|
||||
t.Fatalf("hash computation mismatch: manual=%s, writer=%s",
|
||||
correctHash, writerHash)
|
||||
|
||||
@@ -56,8 +56,8 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up incomplete snapshots FIRST, before any scanning
|
||||
// This is critical for data safety - see CleanupIncompleteSnapshots for details
|
||||
// Clean up incomplete snapshots FIRST, before any scanning.
|
||||
// This is critical for data safety; PruneDatabase below does it.
|
||||
hostname := v.Config.Hostname
|
||||
if hostname == "" {
|
||||
hostname, _ = os.Hostname()
|
||||
|
||||
@@ -5,7 +5,6 @@ package vaultik
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/crypto"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/globals"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
@@ -21,12 +19,6 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// Sentinel errors for misconfigured encryption settings.
|
||||
var (
|
||||
errNoAgeRecipients = errors.New("no age recipients configured")
|
||||
errNoAgeSecretKey = errors.New("no age secret key configured")
|
||||
)
|
||||
|
||||
// Vaultik contains all dependencies needed for vaultik operations
|
||||
type Vaultik struct {
|
||||
Globals *globals.Globals
|
||||
@@ -141,26 +133,6 @@ func (v *Vaultik) CanDecrypt() bool {
|
||||
return v.Config.AgeSecretKey != ""
|
||||
}
|
||||
|
||||
// GetEncryptor creates a new Encryptor instance based on the configured age recipients
|
||||
// Returns an error if no recipients are configured
|
||||
func (v *Vaultik) GetEncryptor() (*crypto.Encryptor, error) {
|
||||
if len(v.Config.AgeRecipients) == 0 {
|
||||
return nil, errNoAgeRecipients
|
||||
}
|
||||
|
||||
return crypto.NewEncryptor(v.Config.AgeRecipients)
|
||||
}
|
||||
|
||||
// GetDecryptor creates a new Decryptor instance based on the configured age secret key
|
||||
// Returns an error if no secret key is configured
|
||||
func (v *Vaultik) GetDecryptor() (*crypto.Decryptor, error) {
|
||||
if v.Config.AgeSecretKey == "" {
|
||||
return nil, errNoAgeSecretKey
|
||||
}
|
||||
|
||||
return crypto.NewDecryptor(v.Config.AgeSecretKey)
|
||||
}
|
||||
|
||||
// GetFilesystem returns the filesystem instance used by Vaultik
|
||||
//
|
||||
//nolint:ireturn // afero.Fs is the filesystem abstraction by design
|
||||
|
||||
+49
-57
@@ -6,14 +6,14 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"filippo.io/age"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
@@ -88,13 +88,22 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
errSecretKeyRequired.Error(), errSecretKeyRequired)
|
||||
}
|
||||
|
||||
// Parse the age secret key once, the same way restore does, and reuse
|
||||
// the identity for the database and every blob.
|
||||
identity, err := v.prepareRestoreIdentity()
|
||||
if err != nil {
|
||||
return v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("parsing age secret key: %v", err), err)
|
||||
}
|
||||
|
||||
log.Info("Starting snapshot verification", "snapshot_id", snapshotID, "mode", "deep")
|
||||
|
||||
if !opts.JSON {
|
||||
v.stdoutf("Deep verification of snapshot: %s\n\n", snapshotID)
|
||||
}
|
||||
|
||||
manifest, tempDB, dbBlobs, err := v.loadVerificationData(snapshotID, opts, result)
|
||||
manifest, tempDB, dbBlobs, err := v.loadVerificationData(
|
||||
snapshotID, opts, result, identity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -114,7 +123,8 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
|
||||
result.TotalSize = totalSize
|
||||
|
||||
err = v.runVerificationSteps(manifest, dbBlobs, tempDB, opts, result, totalSize)
|
||||
err = v.runVerificationSteps(
|
||||
manifest, dbBlobs, tempDB, opts, result, totalSize, identity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -139,6 +149,7 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
// loadVerificationData downloads manifest, database, and blob list for verification
|
||||
func (v *Vaultik) loadVerificationData(
|
||||
snapshotID string, opts *VerifyOptions, result *VerifyResult,
|
||||
identity age.Identity,
|
||||
) (*snapshot.Manifest, *tempDB, []snapshot.BlobInfo, error) {
|
||||
// Resolve the identifier to the snapshot's remote key. A human ID is
|
||||
// hashed; a remote key (or its abbreviation, as printed for a
|
||||
@@ -188,7 +199,7 @@ func (v *Vaultik) loadVerificationData(
|
||||
|
||||
defer func() { _ = dbReader.Close() }()
|
||||
|
||||
tdb, err := v.decryptAndLoadDatabase(dbReader)
|
||||
tdb, err := v.decryptAndLoadDatabase(dbReader, identity)
|
||||
if err != nil {
|
||||
return nil, nil, nil, v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("failed to decrypt database: %v", err),
|
||||
@@ -230,6 +241,7 @@ func (v *Vaultik) runVerificationSteps(
|
||||
opts *VerifyOptions,
|
||||
result *VerifyResult,
|
||||
totalSize int64,
|
||||
identity age.Identity,
|
||||
) error {
|
||||
if !opts.JSON {
|
||||
v.stdoutf("Verifying manifest against database...\n")
|
||||
@@ -256,7 +268,7 @@ func (v *Vaultik) runVerificationSteps(
|
||||
len(dbBlobs), ubytes(totalSize))
|
||||
}
|
||||
|
||||
err = v.performDeepVerificationFromDB(dbBlobs, tdb.db.Conn(), opts)
|
||||
err = v.performDeepVerificationFromDB(dbBlobs, tdb.db.Conn(), opts, identity)
|
||||
if err != nil {
|
||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
@@ -281,26 +293,18 @@ func (t *tempDB) Close() error {
|
||||
}
|
||||
|
||||
// decryptAndLoadDatabase decrypts and loads the binary SQLite database
|
||||
// from the encrypted stream.
|
||||
func (v *Vaultik) decryptAndLoadDatabase(reader io.ReadCloser) (*tempDB, error) {
|
||||
// Get decryptor
|
||||
decryptor, err := v.GetDecryptor()
|
||||
// from the encrypted stream. It reads through the same blobgen reader restore
|
||||
// uses, streaming the decrypted, decompressed database to a temp file.
|
||||
func (v *Vaultik) decryptAndLoadDatabase(
|
||||
reader io.ReadCloser, identity age.Identity,
|
||||
) (*tempDB, error) {
|
||||
// Decrypt and decompress through the shared blobgen reader.
|
||||
blobReader, err := blobgen.NewReader(reader, identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get decryptor: %w", err)
|
||||
return nil, fmt.Errorf("failed to create decryption reader: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt the stream
|
||||
decryptedReader, err := decryptor.DecryptStream(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt database: %w", err)
|
||||
}
|
||||
|
||||
// Decompress the binary database
|
||||
decompressor, err := zstd.NewReader(decryptedReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create decompressor: %w", err)
|
||||
}
|
||||
defer decompressor.Close()
|
||||
defer func() { _ = blobReader.Close() }()
|
||||
|
||||
// Materialize the decrypted database inside a private (0700) temp
|
||||
// directory so it is never world-readable, and remove the whole
|
||||
@@ -330,7 +334,7 @@ func (v *Vaultik) decryptAndLoadDatabase(reader io.ReadCloser) (*tempDB, error)
|
||||
// Stream decompress directly to file
|
||||
log.Info("Decompressing database...")
|
||||
|
||||
written, err := io.Copy(tempFile, decompressor)
|
||||
written, err := io.Copy(tempFile, blobReader)
|
||||
if err != nil {
|
||||
_ = tempFile.Close()
|
||||
|
||||
@@ -355,7 +359,9 @@ func (v *Vaultik) decryptAndLoadDatabase(reader io.ReadCloser) (*tempDB, error)
|
||||
}
|
||||
|
||||
// verifyBlob downloads and verifies a single blob
|
||||
func (v *Vaultik) verifyBlob(blobInfo snapshot.BlobInfo, db *sql.DB) error {
|
||||
func (v *Vaultik) verifyBlob(
|
||||
blobInfo snapshot.BlobInfo, db *sql.DB, identity age.Identity,
|
||||
) error {
|
||||
// Download blob using shared fetch method
|
||||
reader, _, err := v.FetchBlob(v.ctx, blobInfo.Hash, blobInfo.CompressedSize)
|
||||
if err != nil {
|
||||
@@ -364,38 +370,23 @@ func (v *Vaultik) verifyBlob(blobInfo snapshot.BlobInfo, db *sql.DB) error {
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
// Get decryptor
|
||||
decryptor, err := v.GetDecryptor()
|
||||
// Decrypt and decompress through the shared blobgen reader, which hashes
|
||||
// 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, identity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get decryptor: %w", err)
|
||||
return fmt.Errorf("failed to create blob reader: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt blob
|
||||
decryptedReader, err := decryptor.DecryptStream(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
defer func() { _ = blobReader.Close() }()
|
||||
|
||||
// Decompress blob
|
||||
decompressor, err := zstd.NewReader(decryptedReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decompress: %w", err)
|
||||
}
|
||||
defer decompressor.Close()
|
||||
|
||||
// A blob's hash — its remote name — is the double SHA256 of its
|
||||
// decompressed plaintext (see blobgen.Writer.Sum256), not of the
|
||||
// encrypted bytes. Hash the plaintext as chunk verification streams
|
||||
// it, then compare on completion.
|
||||
plaintextHasher := sha256.New()
|
||||
hashedStream := io.TeeReader(decompressor, plaintextHasher)
|
||||
|
||||
chunkCount, err := v.verifyBlobChunks(db, blobInfo.Hash, hashedStream)
|
||||
chunkCount, err := v.verifyBlobChunks(db, blobInfo.Hash, blobReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = v.verifyBlobFinalIntegrity(hashedStream, plaintextHasher, blobInfo.Hash)
|
||||
err = v.verifyBlobFinalIntegrity(blobReader, blobInfo.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -501,11 +492,12 @@ func (v *Vaultik) verifyBlobChunks(
|
||||
// verifyBlobFinalIntegrity checks that no trailing data exists in the
|
||||
// decompressed stream and that the blob hash matches the expected value.
|
||||
func (v *Vaultik) verifyBlobFinalIntegrity(
|
||||
plaintext io.Reader, plaintextHasher hash.Hash, expectedHash string,
|
||||
blobReader *blobgen.Reader, expectedHash string,
|
||||
) error {
|
||||
// Verify no remaining data in blob - if the chunk list is accurate,
|
||||
// the blob should be fully consumed.
|
||||
remaining, err := io.Copy(io.Discard, plaintext)
|
||||
// the blob should be fully consumed. Draining to EOF also completes the
|
||||
// reader's plaintext hash.
|
||||
remaining, err := io.Copy(io.Discard, blobReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for remaining blob data: %w", err)
|
||||
}
|
||||
@@ -514,10 +506,9 @@ func (v *Vaultik) verifyBlobFinalIntegrity(
|
||||
return fmt.Errorf("%w: %d bytes", errTrailingBlobData, remaining)
|
||||
}
|
||||
|
||||
// The blob hash is the double SHA256 of its plaintext content.
|
||||
firstHash := plaintextHasher.Sum(nil)
|
||||
secondHash := sha256.Sum256(firstHash)
|
||||
calculatedBlobHash := hex.EncodeToString(secondHash[:])
|
||||
// The blob hash is the double SHA-256 of its plaintext content.
|
||||
calculatedBlobHash := hex.EncodeToString(
|
||||
blobgen.DoubleSHA256(blobReader.Sum256()))
|
||||
|
||||
if calculatedBlobHash != expectedHash {
|
||||
return fmt.Errorf("%w: calculated %s, expected %s",
|
||||
@@ -667,6 +658,7 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||
// each blob using the database as source.
|
||||
func (v *Vaultik) performDeepVerificationFromDB(
|
||||
blobs []snapshot.BlobInfo, db *sql.DB, opts *VerifyOptions,
|
||||
identity age.Identity,
|
||||
) error {
|
||||
// Calculate total bytes for ETA
|
||||
var totalBytesExpected int64
|
||||
@@ -684,7 +676,7 @@ func (v *Vaultik) performDeepVerificationFromDB(
|
||||
|
||||
for i, blobInfo := range blobs {
|
||||
// Verify individual blob
|
||||
err := v.verifyBlob(blobInfo, db)
|
||||
err := v.verifyBlob(blobInfo, db, identity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("blob %s verification failed: %w", blobInfo.Hash, err)
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/crypto"
|
||||
)
|
||||
|
||||
// TestTeeReaderWithDecryption tests that TeeReader correctly hashes all encrypted
|
||||
// bytes when streaming through age decryption and zstd decompression.
|
||||
// This validates the verification path: hash encrypted blob -> decrypt -> decompress.
|
||||
func TestTeeReaderWithDecryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test data - use random data that doesn't compress well (5MB)
|
||||
testData := make([]byte, 5*1024*1024)
|
||||
_, err := rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compress the data
|
||||
var compressedBuf bytes.Buffer
|
||||
|
||||
compressor, err := zstd.NewWriter(&compressedBuf,
|
||||
zstd.WithEncoderLevel(zstd.SpeedDefault))
|
||||
require.NoError(t, err)
|
||||
_, err = compressor.Write(testData)
|
||||
require.NoError(t, err)
|
||||
err = compressor.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Encrypt the compressed data
|
||||
testRecipient := "age1cplgrwj77ta54dnmydvvmzn64ltk83ankxl5sww04mrt" +
|
||||
"mu62kv3s89gmvv"
|
||||
testSecretKey := "AGE-SECRET-KEY-1C77PYNTHXSHNNC6EYR2W52UWYXACXA5J" +
|
||||
"T00J9CCW9986M3XY87PSGP89AQ"
|
||||
|
||||
encryptor, err := crypto.NewEncryptor([]string{testRecipient})
|
||||
require.NoError(t, err)
|
||||
|
||||
var encryptedBuf bytes.Buffer
|
||||
|
||||
err = encryptor.EncryptStream(&encryptedBuf, bytes.NewReader(compressedBuf.Bytes()))
|
||||
require.NoError(t, err)
|
||||
|
||||
encryptedData := encryptedBuf.Bytes()
|
||||
|
||||
// Calculate the expected hash of the encrypted data directly
|
||||
expectedHash := sha256.Sum256(encryptedData)
|
||||
expectedHashStr := hex.EncodeToString(expectedHash[:])
|
||||
|
||||
t.Logf("Encrypted data size: %d bytes", len(encryptedData))
|
||||
t.Logf("Expected hash: %s", expectedHashStr)
|
||||
|
||||
// Now simulate what verifyBlob does: use TeeReader to hash while decrypting
|
||||
decryptor, err := crypto.NewDecryptor(testSecretKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create hasher and tee reader
|
||||
hasher := sha256.New()
|
||||
reader := bytes.NewReader(encryptedData)
|
||||
teeReader := io.TeeReader(reader, hasher)
|
||||
|
||||
// Decrypt through the tee reader
|
||||
decryptedReader, err := decryptor.DecryptStream(teeReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Decompress
|
||||
decompressor, err := zstd.NewReader(decryptedReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer decompressor.Close()
|
||||
|
||||
// Read all decompressed data (simulating chunk verification)
|
||||
decompressedData, err := io.ReadAll(decompressor)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify we got the original data back
|
||||
assert.Equal(t, testData, decompressedData, "Decompressed data should match original")
|
||||
|
||||
// Drain remaining decompressed data (should be 0)
|
||||
remaining, err := io.Copy(io.Discard, decompressor)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), remaining, "No remaining decompressed data")
|
||||
|
||||
// Calculate hash from tee reader
|
||||
calculatedHashStr := hex.EncodeToString(hasher.Sum(nil))
|
||||
t.Logf("Calculated hash (before drain): %s", calculatedHashStr)
|
||||
|
||||
// Verify the hash matches the direct hash of encrypted data
|
||||
assert.Equal(t, expectedHashStr, calculatedHashStr,
|
||||
"Hash calculated via TeeReader should match direct hash of encrypted data")
|
||||
}
|
||||
Reference in New Issue
Block a user