Compare commits

..

2 Commits

Author SHA1 Message Date
f8d598b1d2 Merge branch 'main' into fix/verify-blob-hash 2026-02-20 11:24:44 +01:00
user
a524eb415e 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).
2026-02-20 02:22:44 -08:00
3 changed files with 31 additions and 87 deletions

View File

@ -5,7 +5,6 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"io"
"strings" "strings"
"testing" "testing"
@ -59,19 +58,12 @@ func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) {
ctx := context.Background() ctx := context.Background()
t.Run("correct hash succeeds", func(t *testing.T) { t.Run("correct hash succeeds", func(t *testing.T) {
rc, err := tv.FetchAndDecryptBlob(ctx, correctHash, int64(len(encryptedData)), identity) result, err := tv.FetchAndDecryptBlob(ctx, correctHash, int64(len(encryptedData)), identity)
if err != nil { if err != nil {
t.Fatalf("expected success, got error: %v", err) t.Fatalf("expected success, got error: %v", err)
} }
data, err := io.ReadAll(rc) if !bytes.Equal(result.Data, plaintext) {
if err != nil { t.Fatalf("decrypted data mismatch: got %q, want %q", result.Data, plaintext)
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)
} }
}) })
@ -83,13 +75,7 @@ func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) {
mockStorage.data[fakePath] = encryptedData mockStorage.data[fakePath] = encryptedData
mockStorage.mu.Unlock() mockStorage.mu.Unlock()
rc, err := tv.FetchAndDecryptBlob(ctx, fakeHash, int64(len(encryptedData)), identity) _, 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 { if err == nil {
t.Fatal("expected error for mismatched hash, got nil") t.Fatal("expected error for mismatched hash, got nil")
} }

View File

@ -5,79 +5,49 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"hash"
"io" "io"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/vaultik/internal/blobgen" "git.eeqj.de/sneak/vaultik/internal/blobgen"
) )
// hashVerifyReader wraps a reader and computes a double-SHA-256 hash of all // FetchAndDecryptBlobResult holds the result of fetching and decrypting a blob.
// data read through it. The hash is verified against the expected blob hash type FetchAndDecryptBlobResult struct {
// when Close is called. This allows streaming blob verification without Data []byte
// 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
} }
func (h *hashVerifyReader) Read(p []byte) (int, error) { // FetchAndDecryptBlob downloads a blob, decrypts and decompresses it, then
n, err := h.reader.Read(p) // verifies that the double-SHA-256 hash of the plaintext matches blobHash.
if n > 0 { // This ensures blob integrity end-to-end during restore operations.
h.hasher.Write(p[:n]) func (v *Vaultik) FetchAndDecryptBlob(ctx context.Context, blobHash string, expectedSize int64, identity age.Identity) (*FetchAndDecryptBlobResult, error) {
}
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) rc, _, err := v.FetchBlob(ctx, blobHash, expectedSize)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { _ = rc.Close() }()
reader, err := blobgen.NewReader(rc, identity) reader, err := blobgen.NewReader(rc, identity)
if err != nil { if err != nil {
_ = rc.Close()
return nil, fmt.Errorf("creating blob reader: %w", err) return nil, fmt.Errorf("creating blob reader: %w", err)
} }
defer func() { _ = reader.Close() }()
return &hashVerifyReader{ data, err := io.ReadAll(reader)
reader: reader, if err != nil {
fetcher: rc, return nil, fmt.Errorf("reading blob data: %w", err)
hasher: sha256.New(), }
blobHash: blobHash,
}, nil // 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
} }
// FetchBlob downloads a blob and returns a reader for the encrypted data. // FetchBlob downloads a blob and returns a reader for the encrypted data.

View File

@ -494,23 +494,11 @@ func (v *Vaultik) restoreRegularFile(
// downloadBlob downloads and decrypts a blob // downloadBlob downloads and decrypts a blob
func (v *Vaultik) downloadBlob(ctx context.Context, blobHash string, expectedSize int64, identity age.Identity) ([]byte, error) { func (v *Vaultik) downloadBlob(ctx context.Context, blobHash string, expectedSize int64, identity age.Identity) ([]byte, error) {
rc, err := v.FetchAndDecryptBlob(ctx, blobHash, expectedSize, identity) result, err := v.FetchAndDecryptBlob(ctx, blobHash, expectedSize, identity)
if err != nil { if err != nil {
return nil, err 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 // verifyRestoredFiles verifies that all restored files match their expected chunk hashes