fix: verify blob hash after download and decryption (closes #5) #39

Open
clawbot wants to merge 2 commits from fix/verify-blob-hash into main
2 changed files with 101 additions and 1 deletions
Showing only changes of commit 2bdbf38be6 - Show all commits

View File

@ -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)
}
})
}

View File

@ -2,6 +2,8 @@ package vaultik
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io" "io"
@ -14,7 +16,9 @@ type FetchAndDecryptBlobResult struct {
Data []byte 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) { func (v *Vaultik) FetchAndDecryptBlob(ctx context.Context, blobHash string, expectedSize int64, identity age.Identity) (*FetchAndDecryptBlobResult, error) {
rc, _, err := v.FetchBlob(ctx, blobHash, expectedSize) rc, _, err := v.FetchBlob(ctx, blobHash, expectedSize)
if err != nil { 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) 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 &FetchAndDecryptBlobResult{Data: data}, nil
} }