An object holding just the age header and its 16-byte nonce decrypts without error: the truncated read surfaces as io.ErrUnexpectedEOF at the age layer, which the zstd decoder maps to a clean EOF at frame start. blobgen then reported zero bytes and no error, so a truncated stream was indistinguishable from a valid empty one, and the metadata database export slipped through -- restore built a fresh schema on the empty file and reported success. blobgen.Reader.Read now, on EOF, reads once more from the age reader and surfaces io.ErrUnexpectedEOF unless that read is (0, io.EOF), the state a genuine end leaves. downloadSnapshotDB additionally rejects a zero-length decrypted database before any schema is built. Model: opus-4-8
97 lines
2.5 KiB
Go
97 lines
2.5 KiB
Go
package blobgen
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"hash"
|
|
"io"
|
|
|
|
"filippo.io/age"
|
|
"github.com/klauspost/compress/zstd"
|
|
)
|
|
|
|
// Reader wraps decompression and decryption with SHA256 verification
|
|
type Reader struct {
|
|
reader io.Reader
|
|
decompressor *zstd.Decoder
|
|
decryptor io.Reader
|
|
hasher hash.Hash
|
|
teeReader io.Reader
|
|
bytesRead int64
|
|
}
|
|
|
|
// NewReader creates a new Reader that decrypts, decompresses, and verifies
|
|
// data. Every supplied identity is offered to age.Decrypt, so a blob
|
|
// encrypted to any one of them can be read.
|
|
func NewReader(r io.Reader, identities ...age.Identity) (*Reader, error) {
|
|
// Create decryption reader
|
|
decReader, err := age.Decrypt(r, identities...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating decryption reader: %w", err)
|
|
}
|
|
|
|
// Create decompression reader
|
|
decompressor, err := zstd.NewReader(decReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating decompression reader: %w", err)
|
|
}
|
|
|
|
// Create SHA256 hasher
|
|
hasher := sha256.New()
|
|
|
|
// Create tee reader that reads from decompressor and writes to hasher
|
|
teeReader := io.TeeReader(decompressor, hasher)
|
|
|
|
return &Reader{
|
|
reader: r,
|
|
decompressor: decompressor,
|
|
decryptor: decReader,
|
|
hasher: hasher,
|
|
teeReader: teeReader,
|
|
}, nil
|
|
}
|
|
|
|
// Read implements io.Reader
|
|
func (r *Reader) Read(p []byte) (int, error) {
|
|
n, err := r.teeReader.Read(p)
|
|
r.bytesRead += int64(n)
|
|
|
|
// When the ciphertext is cut right after the age header plus its
|
|
// 16-byte nonce, the age reader's first read fails with
|
|
// io.ErrUnexpectedEOF, and the zstd decoder maps that to a clean
|
|
// io.EOF at frame start. That makes a truncated stream look like a
|
|
// valid empty one. Distinguish the two: on EOF, read once more from
|
|
// the age reader. A genuine end leaves it at (0, io.EOF); a truncated
|
|
// stream leaves its stored io.ErrUnexpectedEOF, which we surface.
|
|
if errors.Is(err, io.EOF) {
|
|
var probe [1]byte
|
|
|
|
m, ageErr := r.decryptor.Read(probe[:])
|
|
if m != 0 || !errors.Is(ageErr, io.EOF) {
|
|
return n, io.ErrUnexpectedEOF
|
|
}
|
|
}
|
|
|
|
return n, err
|
|
}
|
|
|
|
// Close closes the decompressor
|
|
func (r *Reader) Close() error {
|
|
r.decompressor.Close()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Sum256 returns the single SHA-256 of the plaintext read so far. This is the
|
|
// first hash only; the stored object name is its double hash, which callers
|
|
// obtain by passing this digest to DoubleSHA256.
|
|
func (r *Reader) Sum256() []byte {
|
|
return r.hasher.Sum(nil)
|
|
}
|
|
|
|
// BytesRead returns the number of uncompressed bytes read
|
|
func (r *Reader) BytesRead() int64 {
|
|
return r.bytesRead
|
|
}
|