Files
vault/oracle/go/internal/stream/parallel_test.go
T
Andy DixonandClaude 69f4b90777 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>
2026-10-08 09:55:17 +01:00

333 lines
10 KiB
Go

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