From fba0f7466789e568eceafcaf1604fb5f2fbc8917 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 22 Sep 2026 12:01:40 +0000 Subject: [PATCH] Add negative and boundary tests for blobgen and types (closes #170) Test-only. internal/blobgen and internal/types had no negative or boundary coverage. Adds, in package blobgen_test: Writer-to-Reader round trips at the 64 KiB age-segment edges for random and compressible data, checking plaintext, byte counts and the reader/writer hashes by decrypting; a wrong-identity open; truncation and single-byte corruption of a multi-segment blob at every region; trailing bytes, empty input and garbage; rejected and accepted compression levels; nil, empty and invalid recipients; and a failing destination. In package types_test: Value/Scan round trips, NULL, wrong-type and malformed Scan, Parse and IsZero for FileID and BlobID. internal/crypto and CompressStream/CompressData no longer exist and are skipped. The "cut right after the age header and nonce" truncation is excluded: it reads as valid and empty today and belongs to #152. Reworded two writer_test.go messages that overstated what the double hash prevents. Model: opus-4-8 --- internal/blobgen/helpers_test.go | 119 ++++++++++++++++++ internal/blobgen/negative_test.go | 190 +++++++++++++++++++++++++++++ internal/blobgen/roundtrip_test.go | 132 ++++++++++++++++++++ internal/blobgen/writer_test.go | 11 +- internal/types/types_test.go | 136 +++++++++++++++++++++ 5 files changed, 583 insertions(+), 5 deletions(-) create mode 100644 internal/blobgen/helpers_test.go create mode 100644 internal/blobgen/negative_test.go create mode 100644 internal/blobgen/roundtrip_test.go create mode 100644 internal/types/types_test.go diff --git a/internal/blobgen/helpers_test.go b/internal/blobgen/helpers_test.go new file mode 100644 index 0000000..13bb7b3 --- /dev/null +++ b/internal/blobgen/helpers_test.go @@ -0,0 +1,119 @@ +package blobgen_test + +import ( + "bytes" + "crypto/rand" + "errors" + "io" + "testing" + + "filippo.io/age" + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/blobgen" +) + +// ageChunkSize is age's STREAM plaintext chunk size (64 KiB); each encrypted +// chunk adds a 16-byte ChaCha20-Poly1305 tag. +const ( + ageChunkSize = 64 * 1024 + ageChunkTagSize = 16 + ageSegmentSize = ageChunkSize + ageChunkTagSize + ageNonceSize = 16 +) + +// makeIdentity returns a fresh X25519 identity and its recipient string. +func makeIdentity(t *testing.T) (*age.X25519Identity, string) { + t.Helper() + + id, err := age.GenerateX25519Identity() + require.NoError(t, err) + + return id, id.Recipient().String() +} + +// randomBytes returns n cryptographically random bytes, which do not compress +// so the encrypted payload spans multiple age segments. +func randomBytes(t *testing.T, n int) []byte { + t.Helper() + + b := make([]byte, n) + _, err := rand.Read(b) + require.NoError(t, err) + + return b +} + +// compressibleBytes returns n bytes of a repeating pattern, which zstd packs +// down to a small payload. +func compressibleBytes(n int) []byte { + pattern := bytes.Repeat([]byte("compressible-"), n/13+1) + + return pattern[:n] +} + +// encryptBlob compresses, encrypts and returns a blob for plaintext at +// compression level 1. +func encryptBlob(t *testing.T, plaintext []byte, recipients ...string) []byte { + t.Helper() + + var buf bytes.Buffer + + w, err := blobgen.NewWriter(&buf, 1, recipients) + require.NoError(t, err) + + _, err = w.Write(plaintext) + require.NoError(t, err) + require.NoError(t, w.Close()) + + return buf.Bytes() +} + +// ageHeaderLen returns the byte length of blob's age header, i.e. the offset +// of the 16-byte payload nonce that follows it. The header ends with a MAC +// line "--- \n"; the nonce begins right after that newline. +func ageHeaderLen(t *testing.T, blob []byte) int { + t.Helper() + + i := bytes.Index(blob, []byte("\n--- ")) + require.GreaterOrEqual(t, i, 0, "age MAC footer line not found") + + nl := bytes.IndexByte(blob[i+1:], '\n') + require.GreaterOrEqual(t, nl, 0, "newline ending MAC line not found") + + return i + 1 + nl + 1 +} + +// requireBlobUnreadable asserts that data never decrypts to a plaintext with a +// nil error: either NewReader fails, or reading it does. +func requireBlobUnreadable(t *testing.T, data []byte, id age.Identity) { + t.Helper() + + r, err := blobgen.NewReader(bytes.NewReader(data), id) + if err != nil { + return + } + + _, err = io.ReadAll(r) + _ = r.Close() + + require.Error(t, err, "reading a damaged blob must fail") +} + +// errFailWriter is returned by failAfterWriter once its byte limit is passed. +var errFailWriter = errors.New("destination write failed") + +// failAfterWriter accepts writes until more than limit bytes have been sent, +// then fails every write. It models a destination that dies mid-blob. +type failAfterWriter struct { + limit int + written int +} + +func (f *failAfterWriter) Write(p []byte) (int, error) { + f.written += len(p) + if f.written > f.limit { + return 0, errFailWriter + } + + return len(p), nil +} diff --git a/internal/blobgen/negative_test.go b/internal/blobgen/negative_test.go new file mode 100644 index 0000000..ef24723 --- /dev/null +++ b/internal/blobgen/negative_test.go @@ -0,0 +1,190 @@ +package blobgen_test + +import ( + "bytes" + "fmt" + "testing" + + "filippo.io/age" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/blobgen" +) + +// TestNewReaderWrongIdentity covers issue case 4: opening a blob with an +// identity other than the recipient reports no matching identity. +func TestNewReaderWrongIdentity(t *testing.T) { + t.Parallel() + + _, recipient := makeIdentity(t) + other, _ := makeIdentity(t) + + blob := encryptBlob(t, []byte("secret payload"), recipient) + + _, err := blobgen.NewReader(bytes.NewReader(blob), other) + require.Error(t, err) + + var noMatch *age.NoIdentityMatchError + assert.ErrorAs(t, err, &noMatch) +} + +// TestNewReaderTruncated covers issue case 6: a multi-segment blob cut at +// several points must never read back as valid data. The point immediately +// after the header and nonce is intentionally excluded: it reads as a valid +// empty blob today and is the regression case for +// https://git.eeqj.de/sneak/vaultik/issues/152. +func TestNewReaderTruncated(t *testing.T) { + t.Parallel() + + id, recipient := makeIdentity(t) + blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient) + h := ageHeaderLen(t, blob) + + require.Greater(t, len(blob), h+ageNonceSize+ageSegmentSize, + "test needs a blob of at least two age segments") + + cases := []struct { + name string + size int + }{ + {"inside header", h / 2}, + {"inside nonce", h + 8}, + {"inside first segment", h + ageNonceSize + 100}, + {"end of first full segment", h + ageNonceSize + ageSegmentSize}, + {"last byte removed", len(blob) - 1}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + requireBlobUnreadable(t, blob[:tc.size], id) + }) + } +} + +// TestNewReaderCorrupted covers issue case 7: one flipped byte in each region +// of a multi-segment blob makes it unreadable. +func TestNewReaderCorrupted(t *testing.T) { + t.Parallel() + + id, recipient := makeIdentity(t) + blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient) + h := ageHeaderLen(t, blob) + + firstNL := bytes.IndexByte(blob, '\n') + require.Positive(t, firstNL, "header must have a version line") + + cases := []struct { + name string + pos int + }{ + {"header stanza", firstNL + 5}, + {"header MAC line", h - 2}, + {"nonce", h + 4}, + {"body segment", h + ageNonceSize + 50}, + {"final tag", len(blob) - 1}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + corrupt := append([]byte(nil), blob...) + corrupt[tc.pos] ^= 0xff + requireBlobUnreadable(t, corrupt, id) + }) + } +} + +// TestNewReaderTrailingAndGarbage covers issue case 8: bytes appended after a +// valid blob, empty input, and random garbage each fail to read. +func TestNewReaderTrailingAndGarbage(t *testing.T) { + t.Parallel() + + id, recipient := makeIdentity(t) + + valid := encryptBlob(t, []byte("small payload"), recipient) + appended := append(append([]byte(nil), valid...), []byte("trailing junk")...) + + t.Run("appended bytes", func(t *testing.T) { + t.Parallel() + requireBlobUnreadable(t, appended, id) + }) + t.Run("empty input", func(t *testing.T) { + t.Parallel() + requireBlobUnreadable(t, []byte{}, id) + }) + t.Run("random garbage", func(t *testing.T) { + t.Parallel() + requireBlobUnreadable(t, randomBytes(t, 512), id) + }) +} + +// TestNewWriterInvalidLevel covers the rejected end of issue case 9: an +// out-of-range compression level errors and writes nothing to the destination. +func TestNewWriterInvalidLevel(t *testing.T) { + t.Parallel() + + _, recipient := makeIdentity(t) + + for _, level := range []int{0, -1, 20} { + t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + w, err := blobgen.NewWriter(&buf, level, []string{recipient}) + require.ErrorIs(t, err, blobgen.ErrInvalidCompressionLevel) + assert.Nil(t, w) + assert.Zero(t, buf.Len(), "nothing written on an invalid level") + }) + } +} + +// TestNewWriterInvalidRecipients covers issue case 10: nil and empty recipient +// lists and an unparsable recipient string each error. +func TestNewWriterInvalidRecipients(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + recipients []string + }{ + {"nil list", nil}, + {"empty list", []string{}}, + {"invalid recipient string", []string{"not-a-recipient"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + w, err := blobgen.NewWriter(&buf, 1, tc.recipients) + require.Error(t, err) + assert.Nil(t, w) + }) + } +} + +// TestNewWriterFailingDestination covers issue case 11: a destination that +// fails mid-blob surfaces its error from Write or Close. +func TestNewWriterFailingDestination(t *testing.T) { + t.Parallel() + + _, recipient := makeIdentity(t) + + // The limit clears the age header and nonce so NewWriter succeeds, then + // trips once the compressed body starts flowing. + dst := &failAfterWriter{limit: 512} + + w, err := blobgen.NewWriter(dst, 1, []string{recipient}) + require.NoError(t, err) + + _, writeErr := w.Write(randomBytes(t, 256*1024)) + closeErr := w.Close() + + assert.True(t, writeErr != nil || closeErr != nil, + "destination failure must surface from Write or Close") +} diff --git a/internal/blobgen/roundtrip_test.go b/internal/blobgen/roundtrip_test.go new file mode 100644 index 0000000..da22ec7 --- /dev/null +++ b/internal/blobgen/roundtrip_test.go @@ -0,0 +1,132 @@ +package blobgen_test + +import ( + "bytes" + "crypto/sha256" + "fmt" + "io" + "testing" + + "filippo.io/age" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/blobgen" +) + +// checkRoundTrip writes input through a Writer, reads it back through a Reader, +// and verifies the plaintext, the byte counts, and the content hashes. +func checkRoundTrip( + t *testing.T, id *age.X25519Identity, recipient string, + level int, input []byte, +) { + t.Helper() + + var buf bytes.Buffer + + w, err := blobgen.NewWriter(&buf, level, []string{recipient}) + require.NoError(t, err) + + n, err := w.Write(input) + require.NoError(t, err) + assert.Equal(t, len(input), n) + require.NoError(t, w.Close()) + require.Equal(t, int64(len(input)), w.BytesWritten()) + + r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id) + require.NoError(t, err) + + got, err := io.ReadAll(r) + require.NoError(t, err) + require.NoError(t, r.Close()) + + assert.Equal(t, input, got, "decrypted output must equal input") + require.Equal(t, int64(len(input)), r.BytesRead()) + + // The hash values are checked by decrypting: the reader's single SHA-256 + // is the hash of the plaintext, and hashing it once more (DoubleSHA256) + // gives the writer's ContentID. + single := sha256.Sum256(got) + assert.Equal(t, single[:], r.Sum256()) + assert.Equal(t, blobgen.DoubleSHA256(r.Sum256()), w.ContentID()) +} + +// TestWriterReaderRoundTrip covers issue cases 1 and 2: every size round trips +// for both random and compressible data, and the reader hash, its double hash +// and the byte counts all agree. +func TestWriterReaderRoundTrip(t *testing.T) { + t.Parallel() + + id, recipient := makeIdentity(t) + + // Sizes exercise the age segment boundary (64 KiB) from just below to a + // few segments above it, plus the empty and single-byte edges. + sizes := []int{0, 1, 65535, 65536, 65537, 4*65536 + 123} + + kinds := []struct { + name string + fill func(*testing.T, int) []byte + }{ + {"random", randomBytes}, + {"compressible", func(_ *testing.T, n int) []byte { + return compressibleBytes(n) + }}, + } + + for _, k := range kinds { + for _, size := range sizes { + name := fmt.Sprintf("%s/%d", k.name, size) + t.Run(name, func(t *testing.T) { + t.Parallel() + checkRoundTrip(t, id, recipient, 1, k.fill(t, size)) + }) + } + } +} + +// TestZeroLengthNoWrite covers issue case 3: a Writer closed with no Write at +// all produces the double hash of the empty input, and the blob reads back as +// empty with no error. +func TestZeroLengthNoWrite(t *testing.T) { + t.Parallel() + + id, recipient := makeIdentity(t) + + var buf bytes.Buffer + + w, err := blobgen.NewWriter(&buf, 1, []string{recipient}) + require.NoError(t, err) + require.NoError(t, w.Close()) + assert.Equal(t, int64(0), w.BytesWritten()) + + empty := sha256.Sum256(nil) + doubled := sha256.Sum256(empty[:]) + assert.Equal(t, doubled[:], w.ContentID(), + "ContentID of empty input is SHA256(SHA256(\"\"))") + + r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id) + require.NoError(t, err) + + got, err := io.ReadAll(r) + require.NoError(t, err) + require.NoError(t, r.Close()) + + assert.Empty(t, got, "empty blob decrypts to empty output") + assert.Equal(t, int64(0), r.BytesRead()) + assert.Equal(t, empty[:], r.Sum256()) +} + +// TestNewWriterValidLevelsRoundTrip covers the accepted end of issue case 9: +// the boundary compression levels 1 and 19 both round trip. +func TestNewWriterValidLevelsRoundTrip(t *testing.T) { + t.Parallel() + + id, recipient := makeIdentity(t) + input := randomBytes(t, 4096) + + for _, level := range []int{1, 19} { + t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) { + t.Parallel() + checkRoundTrip(t, id, recipient, level, input) + }) + } +} diff --git a/internal/blobgen/writer_test.go b/internal/blobgen/writer_test.go index 57573be..a1032c9 100644 --- a/internal/blobgen/writer_test.go +++ b/internal/blobgen/writer_test.go @@ -13,8 +13,9 @@ import ( ) // TestWriterHashIsDoubleHash verifies that Writer.ContentID() returns -// the double hash SHA256(SHA256(plaintext)) for security. -// Double hashing prevents attackers from confirming existence of known content. +// SHA256(SHA256(plaintext)). Stored objects are named by this second hash so a +// name is not the plaintext's own SHA-256; this does not stop someone who +// already holds the plaintext from confirming it. func TestWriterHashIsDoubleHash(t *testing.T) { t.Parallel() @@ -60,11 +61,11 @@ func TestWriterHashIsDoubleHash(t *testing.T) { // The writer hash should match the double hash assert.Equal(t, expectedDoubleHash, writerHash, - "Writer.ContentID() should return SHA256(SHA256(plaintext)) for security") + "Writer.ContentID() must be SHA256(SHA256(plaintext))") - // Verify it's NOT the single hash (would leak information) + // It must be the second hash, not the plaintext's own SHA-256. assert.NotEqual(t, singleHashStr, writerHash, - "Writer hash should not be single hash (would allow content confirmation attacks)") + "Writer hash must be the double hash, not the single SHA-256") } // TestWriterDeterministicHash verifies that the same input always produces diff --git a/internal/types/types_test.go b/internal/types/types_test.go new file mode 100644 index 0000000..80d02b8 --- /dev/null +++ b/internal/types/types_test.go @@ -0,0 +1,136 @@ +package types_test + +import ( + "database/sql" + "database/sql/driver" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/types" +) + +// scannableID is the shared behaviour of the UUID-backed id types. A pointer +// to FileID or BlobID satisfies it, so both are tested through one set of +// cases. +type scannableID interface { + driver.Valuer + sql.Scanner + fmt.Stringer + IsZero() bool +} + +// idKind adapts one id type to the generic tests below. +type idKind struct { + name string + newZero func() scannableID + newRandom func() scannableID + parse func(string) (scannableID, error) +} + +func idKinds() []idKind { + return []idKind{ + { + name: "FileID", + newZero: func() scannableID { return &types.FileID{} }, + newRandom: func() scannableID { + id := types.NewFileID() + + return &id + }, + parse: func(s string) (scannableID, error) { + id, err := types.ParseFileID(s) + + return &id, err + }, + }, + { + name: "BlobID", + newZero: func() scannableID { return &types.BlobID{} }, + newRandom: func() scannableID { + id := types.NewBlobID() + + return &id + }, + parse: func(s string) (scannableID, error) { + id, err := types.ParseBlobID(s) + + return &id, err + }, + }, + } +} + +// TestIDValueScan checks that Value then Scan round trips from both a string +// and a []byte, that a NULL scans to the zero id, and that a non-string type +// and malformed text are rejected. +func TestIDValueScan(t *testing.T) { + t.Parallel() + + for _, k := range idKinds() { + t.Run(k.name, func(t *testing.T) { + t.Parallel() + + orig := k.newRandom() + v, err := orig.Value() + require.NoError(t, err) + + s, ok := v.(string) + require.True(t, ok, "Value must yield a string") + + fromString := k.newZero() + require.NoError(t, fromString.Scan(s)) + assert.Equal(t, orig.String(), fromString.String()) + assert.False(t, fromString.IsZero()) + + fromBytes := k.newZero() + require.NoError(t, fromBytes.Scan([]byte(s))) + assert.Equal(t, orig.String(), fromBytes.String()) + + nulled := k.newRandom() + require.NoError(t, nulled.Scan(nil)) + assert.True(t, nulled.IsZero(), "NULL scans to the zero id") + + require.Error(t, k.newZero().Scan(42), + "a non-string type must be rejected") + assert.Error(t, k.newZero().Scan("not-a-uuid"), + "malformed text must be rejected") + }) + } +} + +// TestIDParse checks that the Parse function accepts a canonical id and +// rejects malformed text. +func TestIDParse(t *testing.T) { + t.Parallel() + + for _, k := range idKinds() { + t.Run(k.name, func(t *testing.T) { + t.Parallel() + + canonical := k.newRandom().String() + + parsed, err := k.parse(canonical) + require.NoError(t, err) + assert.Equal(t, canonical, parsed.String()) + + _, err = k.parse("not-a-uuid") + assert.Error(t, err) + }) + } +} + +// TestIDIsZero checks IsZero on the zero and on a freshly generated id. +func TestIDIsZero(t *testing.T) { + t.Parallel() + + for _, k := range idKinds() { + t.Run(k.name, func(t *testing.T) { + t.Parallel() + + assert.True(t, k.newZero().IsZero()) + assert.False(t, k.newRandom().IsZero()) + }) + } +} -- 2.54.0