Freeze the Go implementation as a test-only oracle, with vectors
oracle/go is the Go code at 4c1507c, moved unchanged, plus test-only
generators that record what it computes. tests/vectors holds the
deterministic crypto/store vectors and synthetic fixtures they produce.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
// Copyright 2026 The fury Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package stream_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/vault/internal/stream"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
func workerCounts() []int {
|
||||
return []int{1, 2, 4, runtime.NumCPU() + 4}
|
||||
}
|
||||
|
||||
func randBytes(t *testing.T, n int) []byte {
|
||||
t.Helper()
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestParallelEncryptMatchesSequential is the main compatibility check: for a
|
||||
// range of lengths and worker counts, the parallel encrypter must produce
|
||||
// byte-identical ciphertext to the sequential one, and ciphertext from
|
||||
// either encrypter must decrypt correctly with either reader.
|
||||
func TestParallelEncryptMatchesSequential(t *testing.T) {
|
||||
lengths := []int{0, 1000, cs - 1, cs, cs + 1, cs + 100, 2 * cs, 2*cs + 500}
|
||||
|
||||
for _, length := range lengths {
|
||||
for _, workers := range workerCounts() {
|
||||
t.Run(fmt.Sprintf("len=%d,workers=%d", length, workers), func(t *testing.T) {
|
||||
src := randBytes(t, length)
|
||||
key := randBytes(t, chacha20poly1305.KeySize)
|
||||
|
||||
seqBuf := &bytes.Buffer{}
|
||||
sw, err := stream.NewEncryptWriter(key, seqBuf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := sw.Write(src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
parBuf := &bytes.Buffer{}
|
||||
pw, err := stream.NewParallelEncryptWriter(key, parBuf, workers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pw.Write(src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(seqBuf.Bytes(), parBuf.Bytes()) {
|
||||
t.Fatalf("parallel ciphertext (%d bytes) differs from sequential (%d bytes)",
|
||||
parBuf.Len(), seqBuf.Len())
|
||||
}
|
||||
|
||||
t.Run("decrypt-with-sequential", func(t *testing.T) {
|
||||
dr, err := stream.NewDecryptReader(key, bytes.NewReader(parBuf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkRoundTrip(t, dr, src)
|
||||
})
|
||||
t.Run("decrypt-with-parallel", func(t *testing.T) {
|
||||
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(parBuf.Bytes()), workers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkRoundTrip(t, dr, src)
|
||||
})
|
||||
t.Run("sequential-ciphertext-decrypts-with-parallel", func(t *testing.T) {
|
||||
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(seqBuf.Bytes()), workers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkRoundTrip(t, dr, src)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkRoundTrip(t *testing.T, r io.Reader, want []byte) {
|
||||
t.Helper()
|
||||
got, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("decrypted mismatch: got %d bytes, want %d bytes", len(got), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallelEncryptWriteInSteps confirms the parallel writer's output is
|
||||
// unaffected by how the caller chunks its Write calls, mirroring the
|
||||
// sequential TestRoundTrip's step-size coverage but scoped to a couple of
|
||||
// representative lengths to keep runtime reasonable.
|
||||
func TestParallelEncryptWriteInSteps(t *testing.T) {
|
||||
lengths := []int{cs + 100, 2*cs + 500}
|
||||
steps := []int{1, 537, cs}
|
||||
|
||||
for _, length := range lengths {
|
||||
for _, step := range steps {
|
||||
t.Run(fmt.Sprintf("len=%d,step=%d", length, step), func(t *testing.T) {
|
||||
src := randBytes(t, length)
|
||||
key := randBytes(t, chacha20poly1305.KeySize)
|
||||
|
||||
seqBuf := &bytes.Buffer{}
|
||||
sw, err := stream.NewEncryptWriter(key, seqBuf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeInSteps(t, sw, src, step)
|
||||
if err := sw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
parBuf := &bytes.Buffer{}
|
||||
pw, err := stream.NewParallelEncryptWriter(key, parBuf, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeInSteps(t, pw, src, step)
|
||||
if err := pw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(seqBuf.Bytes(), parBuf.Bytes()) {
|
||||
t.Fatalf("parallel ciphertext differs from sequential with step=%d", step)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeInSteps(t *testing.T, w io.Writer, src []byte, step int) {
|
||||
t.Helper()
|
||||
for len(src) > 0 {
|
||||
n := min(step, len(src))
|
||||
if _, err := w.Write(src[:n]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src = src[n:]
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallelDecryptMalformed exercises the same categories of malformed
|
||||
// input the sequential reader rejects. The parallel reader resolves the
|
||||
// last-chunk ambiguity from the shape of the raw stream (read-ahead) rather
|
||||
// than by a trial decryption, so in adversarial cases the exact error
|
||||
// message can differ from the sequential reader's -- what must not differ is
|
||||
// the accept/reject verdict. Where the message is deterministic under the
|
||||
// new strategy too, it's asserted explicitly.
|
||||
func TestParallelDecryptMalformed(t *testing.T) {
|
||||
newCiphertext := func(t *testing.T, key []byte, plaintext []byte) []byte {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
t.Run("wrong key", func(t *testing.T) {
|
||||
key := randBytes(t, chacha20poly1305.KeySize)
|
||||
ciphertext := newCiphertext(t, key, randBytes(t, 1000))
|
||||
wrongKey := randBytes(t, chacha20poly1305.KeySize)
|
||||
|
||||
dr, err := stream.NewParallelDecryptReader(wrongKey, bytes.NewReader(ciphertext), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.ReadAll(dr); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("corrupted byte in non-final chunk", func(t *testing.T) {
|
||||
key := randBytes(t, chacha20poly1305.KeySize)
|
||||
ciphertext := newCiphertext(t, key, randBytes(t, 2*cs+500))
|
||||
ciphertext[10] ^= 0xff // inside the first (non-final) chunk
|
||||
|
||||
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(ciphertext), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.ReadAll(dr); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("truncated", func(t *testing.T) {
|
||||
key := randBytes(t, chacha20poly1305.KeySize)
|
||||
ciphertext := newCiphertext(t, key, randBytes(t, 2*cs+500))
|
||||
truncated := ciphertext[:len(ciphertext)-100]
|
||||
|
||||
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(truncated), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.ReadAll(dr); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trailing data appended after short last chunk", func(t *testing.T) {
|
||||
// With a bytes.Reader, io.ReadFull for the (undersized) chunk buffer
|
||||
// greedily consumes the real short chunk AND the trailing garbage in
|
||||
// one read, since everything is available at once: the result is a
|
||||
// misaligned AEAD tag, not a distinguishable "trailing data" case.
|
||||
// This is also true of the sequential reader (same io.ReadFull over
|
||||
// the same fixed-size buffer) -- the verdict, an error, is what must
|
||||
// hold, not a specific message.
|
||||
key := randBytes(t, chacha20poly1305.KeySize)
|
||||
ciphertext := newCiphertext(t, key, randBytes(t, 1000)) // ends in a short chunk
|
||||
withTrailer := append(append([]byte(nil), ciphertext...), 1, 2, 3, 4, 5)
|
||||
|
||||
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(withTrailer), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.ReadAll(dr); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trailing data after full-length last chunk", func(t *testing.T) {
|
||||
key := randBytes(t, chacha20poly1305.KeySize)
|
||||
ciphertext := newCiphertext(t, key, randBytes(t, cs)) // exact multiple: full-length last chunk
|
||||
withTrailer := append(append([]byte(nil), ciphertext...), 1, 2, 3, 4, 5)
|
||||
|
||||
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(withTrailer), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The trailing bytes are re-interpreted as a short "final" chunk here
|
||||
// (a different, but still safe, rejection path than the sequential
|
||||
// reader's trailing-byte probe); the verdict must still be an error.
|
||||
if _, err := io.ReadAll(dr); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty chunk after a non-empty chunk is rejected", func(t *testing.T) {
|
||||
key := randBytes(t, chacha20poly1305.KeySize)
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var malformed []byte
|
||||
malformed = append(malformed, sealChunkForTest(t, aead, 0, false, randBytes(t, cs))...)
|
||||
malformed = append(malformed, sealChunkForTest(t, aead, 1, true, nil)...)
|
||||
|
||||
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(malformed), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = io.ReadAll(dr)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if got, want := err.Error(), "last chunk is empty; encrypted payload is malformed"; got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// sealChunkForTest manually seals a chunk using the same nonce scheme as the
|
||||
// stream package (a big-endian chunk counter in bytes [3:11], a last-chunk
|
||||
// flag in the final byte), to construct deliberately malformed streams that
|
||||
// aren't reachable through the public encrypt API.
|
||||
func sealChunkForTest(t *testing.T, aead cipher.AEAD, index int64, last bool, plaintext []byte) []byte {
|
||||
t.Helper()
|
||||
var nonce [chacha20poly1305.NonceSize]byte
|
||||
binary.BigEndian.PutUint64(nonce[3:11], uint64(index))
|
||||
if last {
|
||||
nonce[len(nonce)-1] = 1
|
||||
}
|
||||
return aead.Seal(nil, nonce[:], plaintext, nil)
|
||||
}
|
||||
|
||||
// TestParallelRace exercises the pipeline with oversubscribed workers on a
|
||||
// multi-chunk payload; run with -race to catch data races in the ordered
|
||||
// fan-in.
|
||||
func TestParallelRace(t *testing.T) {
|
||||
key := randBytes(t, chacha20poly1305.KeySize)
|
||||
src := randBytes(t, 6*cs+12345)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewParallelEncryptWriter(key, buf, runtime.NumCPU()*2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(buf.Bytes()), runtime.NumCPU()*2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkRoundTrip(t, dr, src)
|
||||
}
|
||||
Reference in New Issue
Block a user