Remove the unused crypto path and write the blob-ID hash step once (closes #151)
check / check (push) Successful in 1m21s
check / check (pull_request) Successful in 2m39s

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:
2026-09-22 13:46:02 +02:00
parent 238ce3985f
commit f788668287
18 changed files with 154 additions and 976 deletions
+49 -57
View File
@@ -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)
}