From 2bdbf38be6e7bcbdc016052da861d0a74a2e92e3 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 02:22:44 -0800 Subject: [PATCH 1/2] fix: verify blob hash after download and decryption (closes #5) Add double-SHA-256 hash verification of decrypted plaintext in FetchAndDecryptBlob. This ensures blob integrity during restore operations by comparing the computed hash against the expected blob hash before returning data to the caller. Includes test for both correct hash (passes) and mismatched hash (returns error). --- internal/vaultik/blob_fetch_hash_test.go | 86 ++++++++++++++++++++++++ internal/vaultik/blob_fetch_stub.go | 16 ++++- 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 internal/vaultik/blob_fetch_hash_test.go diff --git a/internal/vaultik/blob_fetch_hash_test.go b/internal/vaultik/blob_fetch_hash_test.go new file mode 100644 index 0000000..a08420a --- /dev/null +++ b/internal/vaultik/blob_fetch_hash_test.go @@ -0,0 +1,86 @@ +package vaultik_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "filippo.io/age" + "git.eeqj.de/sneak/vaultik/internal/blobgen" + "git.eeqj.de/sneak/vaultik/internal/vaultik" +) + +// TestFetchAndDecryptBlobVerifiesHash verifies that FetchAndDecryptBlob checks +// the double-SHA-256 hash of the decrypted plaintext against the expected blob hash. +func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("generating identity: %v", err) + } + + // Create test data and encrypt it using blobgen.Writer + plaintext := []byte("hello world test data for blob hash verification") + var encBuf bytes.Buffer + writer, err := blobgen.NewWriter(&encBuf, 1, []string{identity.Recipient().String()}) + if err != nil { + t.Fatalf("creating blobgen writer: %v", err) + } + if _, err := writer.Write(plaintext); err != nil { + t.Fatalf("writing plaintext: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("closing writer: %v", err) + } + encryptedData := encBuf.Bytes() + + // Compute correct double-SHA-256 hash of the plaintext (matches blobgen.Writer.Sum256) + 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()) + if correctHash != writerHash { + t.Fatalf("hash computation mismatch: manual=%s, writer=%s", correctHash, writerHash) + } + + // Set up mock storage with the blob at the correct path + mockStorage := NewMockStorer() + blobPath := "blobs/" + correctHash[:2] + "/" + correctHash[2:4] + "/" + correctHash + mockStorage.mu.Lock() + mockStorage.data[blobPath] = encryptedData + mockStorage.mu.Unlock() + + tv := vaultik.NewForTesting(mockStorage) + ctx := context.Background() + + t.Run("correct hash succeeds", func(t *testing.T) { + result, err := tv.FetchAndDecryptBlob(ctx, correctHash, int64(len(encryptedData)), identity) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if !bytes.Equal(result.Data, plaintext) { + t.Fatalf("decrypted data mismatch: got %q, want %q", result.Data, plaintext) + } + }) + + t.Run("wrong hash fails", func(t *testing.T) { + // Use a fake hash that doesn't match the actual plaintext + fakeHash := strings.Repeat("ab", 32) // 64 hex chars + fakePath := "blobs/" + fakeHash[:2] + "/" + fakeHash[2:4] + "/" + fakeHash + mockStorage.mu.Lock() + mockStorage.data[fakePath] = encryptedData + mockStorage.mu.Unlock() + + _, err := tv.FetchAndDecryptBlob(ctx, fakeHash, int64(len(encryptedData)), identity) + if err == nil { + t.Fatal("expected error for mismatched hash, got nil") + } + if !strings.Contains(err.Error(), "hash mismatch") { + t.Fatalf("expected hash mismatch error, got: %v", err) + } + }) +} diff --git a/internal/vaultik/blob_fetch_stub.go b/internal/vaultik/blob_fetch_stub.go index e8ef0a7..38a5360 100644 --- a/internal/vaultik/blob_fetch_stub.go +++ b/internal/vaultik/blob_fetch_stub.go @@ -2,6 +2,8 @@ package vaultik import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "io" @@ -14,7 +16,9 @@ type FetchAndDecryptBlobResult struct { Data []byte } -// FetchAndDecryptBlob downloads a blob, decrypts it, and returns the plaintext data. +// FetchAndDecryptBlob downloads a blob, decrypts and decompresses it, then +// verifies that the double-SHA-256 hash of the plaintext matches blobHash. +// This ensures blob integrity end-to-end during restore operations. func (v *Vaultik) FetchAndDecryptBlob(ctx context.Context, blobHash string, expectedSize int64, identity age.Identity) (*FetchAndDecryptBlobResult, error) { rc, _, err := v.FetchBlob(ctx, blobHash, expectedSize) if err != nil { @@ -33,6 +37,16 @@ func (v *Vaultik) FetchAndDecryptBlob(ctx context.Context, blobHash string, expe return nil, fmt.Errorf("reading blob data: %w", err) } + // Verify blob integrity: compute double-SHA-256 of the decrypted plaintext + // and compare to the expected blob hash. The blob hash is SHA256(SHA256(plaintext)) + // as produced by blobgen.Writer.Sum256(). + firstHash := sha256.Sum256(data) + secondHash := sha256.Sum256(firstHash[:]) + actualHashHex := hex.EncodeToString(secondHash[:]) + if actualHashHex != blobHash { + return nil, fmt.Errorf("blob hash mismatch: expected %s, got %s", blobHash[:16], actualHashHex[:16]) + } + return &FetchAndDecryptBlobResult{Data: data}, nil } -- 2.45.2 From 22efd90f8c1baaf1cda7df963832804e51c350ed Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 20 Feb 2026 02:29:19 -0800 Subject: [PATCH 2/2] refactor: stream blob hash verification instead of buffering in memory FetchAndDecryptBlob now returns io.ReadCloser with a hashVerifyReader that computes the double-SHA-256 on-the-fly during reads. Hash is verified on Close() after the stream is fully consumed. This avoids loading entire blobs into memory, which could exceed available RAM. Addresses review feedback on PR #39. --- internal/vaultik/blob_fetch_hash_test.go | 22 +++++-- internal/vaultik/blob_fetch_stub.go | 80 ++++++++++++++++-------- internal/vaultik/restore.go | 16 ++++- 3 files changed, 87 insertions(+), 31 deletions(-) diff --git a/internal/vaultik/blob_fetch_hash_test.go b/internal/vaultik/blob_fetch_hash_test.go index a08420a..192ec78 100644 --- a/internal/vaultik/blob_fetch_hash_test.go +++ b/internal/vaultik/blob_fetch_hash_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "io" "strings" "testing" @@ -58,12 +59,19 @@ func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) { ctx := context.Background() t.Run("correct hash succeeds", func(t *testing.T) { - result, err := tv.FetchAndDecryptBlob(ctx, correctHash, int64(len(encryptedData)), identity) + rc, err := tv.FetchAndDecryptBlob(ctx, correctHash, int64(len(encryptedData)), identity) if err != nil { t.Fatalf("expected success, got error: %v", err) } - if !bytes.Equal(result.Data, plaintext) { - t.Fatalf("decrypted data mismatch: got %q, want %q", result.Data, plaintext) + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("reading stream: %v", err) + } + if err := rc.Close(); err != nil { + t.Fatalf("close (hash verification) failed: %v", err) + } + if !bytes.Equal(data, plaintext) { + t.Fatalf("decrypted data mismatch: got %q, want %q", data, plaintext) } }) @@ -75,7 +83,13 @@ func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) { mockStorage.data[fakePath] = encryptedData mockStorage.mu.Unlock() - _, err := tv.FetchAndDecryptBlob(ctx, fakeHash, int64(len(encryptedData)), identity) + rc, err := tv.FetchAndDecryptBlob(ctx, fakeHash, int64(len(encryptedData)), identity) + if err != nil { + t.Fatalf("unexpected error opening stream: %v", err) + } + // Read all data — hash is verified on Close + _, _ = io.ReadAll(rc) + err = rc.Close() if err == nil { t.Fatal("expected error for mismatched hash, got nil") } diff --git a/internal/vaultik/blob_fetch_stub.go b/internal/vaultik/blob_fetch_stub.go index 38a5360..af33576 100644 --- a/internal/vaultik/blob_fetch_stub.go +++ b/internal/vaultik/blob_fetch_stub.go @@ -5,49 +5,79 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "hash" "io" "filippo.io/age" "git.eeqj.de/sneak/vaultik/internal/blobgen" ) -// FetchAndDecryptBlobResult holds the result of fetching and decrypting a blob. -type FetchAndDecryptBlobResult struct { - Data []byte +// hashVerifyReader wraps a reader and computes a double-SHA-256 hash of all +// data read through it. The hash is verified against the expected blob hash +// when Close is called. This allows streaming blob verification without +// buffering the entire blob in memory. +type hashVerifyReader struct { + reader io.ReadCloser // underlying decrypted blob reader + fetcher io.ReadCloser // raw fetched stream (closed on Close) + hasher hash.Hash // running SHA-256 of plaintext + blobHash string // expected double-SHA-256 hex + done bool // EOF reached } -// FetchAndDecryptBlob downloads a blob, decrypts and decompresses it, then -// verifies that the double-SHA-256 hash of the plaintext matches blobHash. -// This ensures blob integrity end-to-end during restore operations. -func (v *Vaultik) FetchAndDecryptBlob(ctx context.Context, blobHash string, expectedSize int64, identity age.Identity) (*FetchAndDecryptBlobResult, error) { +func (h *hashVerifyReader) Read(p []byte) (int, error) { + n, err := h.reader.Read(p) + if n > 0 { + h.hasher.Write(p[:n]) + } + if err == io.EOF { + h.done = true + } + return n, err +} + +// Close verifies the hash (if the stream was fully read) and closes underlying readers. +func (h *hashVerifyReader) Close() error { + readerErr := h.reader.Close() + fetcherErr := h.fetcher.Close() + + if h.done { + firstHash := h.hasher.Sum(nil) + secondHasher := sha256.New() + secondHasher.Write(firstHash) + actualHashHex := hex.EncodeToString(secondHasher.Sum(nil)) + if actualHashHex != h.blobHash { + return fmt.Errorf("blob hash mismatch: expected %s, got %s", h.blobHash[:16], actualHashHex[:16]) + } + } + + if readerErr != nil { + return readerErr + } + return fetcherErr +} + +// FetchAndDecryptBlob downloads a blob, decrypts and decompresses it, and +// returns a streaming reader that computes the double-SHA-256 hash on the fly. +// The hash is verified when the returned reader is closed (after fully reading). +// This avoids buffering the entire blob in memory. +func (v *Vaultik) FetchAndDecryptBlob(ctx context.Context, blobHash string, expectedSize int64, identity age.Identity) (io.ReadCloser, error) { rc, _, err := v.FetchBlob(ctx, blobHash, expectedSize) if err != nil { return nil, err } - defer func() { _ = rc.Close() }() reader, err := blobgen.NewReader(rc, identity) if err != nil { + _ = rc.Close() return nil, fmt.Errorf("creating blob reader: %w", err) } - defer func() { _ = reader.Close() }() - data, err := io.ReadAll(reader) - if err != nil { - return nil, fmt.Errorf("reading blob data: %w", err) - } - - // Verify blob integrity: compute double-SHA-256 of the decrypted plaintext - // and compare to the expected blob hash. The blob hash is SHA256(SHA256(plaintext)) - // as produced by blobgen.Writer.Sum256(). - firstHash := sha256.Sum256(data) - secondHash := sha256.Sum256(firstHash[:]) - actualHashHex := hex.EncodeToString(secondHash[:]) - if actualHashHex != blobHash { - return nil, fmt.Errorf("blob hash mismatch: expected %s, got %s", blobHash[:16], actualHashHex[:16]) - } - - return &FetchAndDecryptBlobResult{Data: data}, nil + return &hashVerifyReader{ + reader: reader, + fetcher: rc, + hasher: sha256.New(), + blobHash: blobHash, + }, nil } // FetchBlob downloads a blob and returns a reader for the encrypted data. diff --git a/internal/vaultik/restore.go b/internal/vaultik/restore.go index afe58b7..6e926e8 100644 --- a/internal/vaultik/restore.go +++ b/internal/vaultik/restore.go @@ -494,11 +494,23 @@ func (v *Vaultik) restoreRegularFile( // downloadBlob downloads and decrypts a blob func (v *Vaultik) downloadBlob(ctx context.Context, blobHash string, expectedSize int64, identity age.Identity) ([]byte, error) { - result, err := v.FetchAndDecryptBlob(ctx, blobHash, expectedSize, identity) + rc, err := v.FetchAndDecryptBlob(ctx, blobHash, expectedSize, identity) if err != nil { return nil, err } - return result.Data, nil + + data, err := io.ReadAll(rc) + if err != nil { + _ = rc.Close() + return nil, fmt.Errorf("reading blob data: %w", err) + } + + // Close triggers hash verification + if err := rc.Close(); err != nil { + return nil, err + } + + return data, nil } // verifyRestoredFiles verifies that all restored files match their expected chunk hashes -- 2.45.2