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 }