Initial vault: single-binary post-quantum file vault
Strip Fury back to its vault functionality in one `vault` command: genkey, init, add (file or directory, optional folder inside the vault), list, get, mv and rm. The hybrid FrodoKEM-640 + ML-KEM-768 encryption core is kept under internal/pqcrypt. Signing, policies, profiles, backups, SSH remotes, FIDO2, agents, provenance and compression are removed.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
// Copyright 2026 The fury Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package stream
|
||||
|
||||
import (
|
||||
"crypto/cipher"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
// NewParallelDecryptReader is equivalent to [NewDecryptReader], except that
|
||||
// chunks are opened by a pool of worker goroutines instead of inline on the
|
||||
// calling goroutine.
|
||||
//
|
||||
// The sequential DecryptReader disambiguates a full-length final chunk by
|
||||
// trying to open it with the non-last nonce first and, on authentication
|
||||
// failure, retrying with the last-chunk nonce. That trick is inherently
|
||||
// sequential (try, then fall back), so this reader instead keeps a single
|
||||
// raw chunk of read-ahead: the one goroutine that reads from src (reads must
|
||||
// stay sequential, there is only one underlying stream) always reads one
|
||||
// chunk further than it has dispatched, so by the time chunk i is handed to
|
||||
// the worker pool, whether it is the last chunk is already known for
|
||||
// certain from the shape of the next read. Each worker then does exactly
|
||||
// one Open call, with a predetermined nonce, no retry.
|
||||
//
|
||||
// For well-formed files this produces byte-for-byte identical plaintext and
|
||||
// the same errors as NewDecryptReader. For adversarially malformed files,
|
||||
// the accept/reject verdict is always the same, but the exact error message
|
||||
// in some corner cases (a corrupted chunk that happens to be full-length)
|
||||
// may differ, because the classification strategy differs; see the package
|
||||
// tests for the cases this was checked against.
|
||||
//
|
||||
// workers <= 1 returns a plain [NewDecryptReader] with no pipeline overhead.
|
||||
//
|
||||
// The returned Reader starts background goroutines that run until the
|
||||
// stream is fully consumed or a fatal error occurs; abandoning it before
|
||||
// reading to EOF leaks those goroutines (bounded to a small multiple of
|
||||
// workers) until they unblock or the process exits.
|
||||
func NewParallelDecryptReader(key []byte, src io.Reader, workers int) (io.Reader, error) {
|
||||
if workers <= 1 {
|
||||
return NewDecryptReader(key, src)
|
||||
}
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
depth := workers * pipelineDepthPerWorker
|
||||
r := ¶llelDecryptReader{
|
||||
a: aead,
|
||||
src: src,
|
||||
jobs: make(chan decJob, depth),
|
||||
order: make(chan chan decResult, depth),
|
||||
}
|
||||
r.wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go r.work()
|
||||
}
|
||||
go r.pump()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
type decJob struct {
|
||||
nonce [chacha20poly1305.NonceSize]byte
|
||||
ciphertext []byte
|
||||
result chan decResult
|
||||
}
|
||||
|
||||
// decResult is either a successfully decrypted chunk (err == nil) or a
|
||||
// terminal event: a decryption failure, a raw read error, or a trailing-data
|
||||
// violation. There is no explicit "clean EOF" value: a clean end of stream
|
||||
// is represented by the order channel closing with nothing left to receive.
|
||||
type decResult struct {
|
||||
plaintext []byte
|
||||
err error
|
||||
}
|
||||
|
||||
type parallelDecryptReader struct {
|
||||
a cipher.AEAD
|
||||
src io.Reader
|
||||
|
||||
jobs chan decJob
|
||||
order chan chan decResult
|
||||
wg sync.WaitGroup
|
||||
|
||||
unread []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *parallelDecryptReader) Read(p []byte) (int, error) {
|
||||
if len(r.unread) > 0 {
|
||||
n := copy(p, r.unread)
|
||||
r.unread = r.unread[n:]
|
||||
return n, nil
|
||||
}
|
||||
if r.err != nil {
|
||||
return 0, r.err
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
ch, ok := <-r.order
|
||||
if !ok {
|
||||
r.err = io.EOF
|
||||
return 0, r.err
|
||||
}
|
||||
res := <-ch
|
||||
if res.err != nil {
|
||||
r.err = res.err
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
r.unread = res.plaintext
|
||||
n := copy(p, r.unread)
|
||||
r.unread = r.unread[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// dispatch hands a raw chunk, whose classification is already known for
|
||||
// certain, to the worker pool.
|
||||
func (r *parallelDecryptReader) dispatch(index int64, ciphertext []byte, last bool) {
|
||||
nonce := nonceForChunk(index)
|
||||
if last {
|
||||
setLastChunkFlag(nonce)
|
||||
}
|
||||
ch := make(chan decResult, 1)
|
||||
r.order <- ch
|
||||
r.jobs <- decJob{nonce: *nonce, ciphertext: ciphertext, result: ch}
|
||||
}
|
||||
|
||||
// emitDirect enqueues a terminal result produced by the pump goroutine
|
||||
// itself (a read error, a malformed-stream error, or a trailing-data
|
||||
// violation), without involving the worker pool.
|
||||
func (r *parallelDecryptReader) emitDirect(res decResult) {
|
||||
ch := make(chan decResult, 1)
|
||||
r.order <- ch
|
||||
ch <- res
|
||||
}
|
||||
|
||||
// work opens dispatched chunks. Unlike the sequential reader, there is no
|
||||
// retry here: the pump goroutine has already determined, from the shape of
|
||||
// the raw stream, whether each chunk is the last one.
|
||||
func (r *parallelDecryptReader) work() {
|
||||
defer r.wg.Done()
|
||||
for j := range r.jobs {
|
||||
out := make([]byte, 0, ChunkSize)
|
||||
plaintext, err := r.a.Open(out, j.nonce[:], j.ciphertext, nil)
|
||||
if err != nil {
|
||||
j.result <- decResult{err: errors.New("failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with")}
|
||||
continue
|
||||
}
|
||||
j.result <- decResult{plaintext: plaintext}
|
||||
}
|
||||
}
|
||||
|
||||
type chunkKind int
|
||||
|
||||
const (
|
||||
chunkFull chunkKind = iota
|
||||
chunkShort
|
||||
chunkEOF
|
||||
chunkErr
|
||||
)
|
||||
|
||||
type rawChunk struct {
|
||||
kind chunkKind
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// readRawChunk reads exactly one raw encChunkSize block from src into a
|
||||
// freshly allocated buffer and classifies the read, mirroring the switch at
|
||||
// the top of the sequential DecryptReader.readChunk.
|
||||
func readRawChunk(src io.Reader) rawChunk {
|
||||
buf := make([]byte, encChunkSize)
|
||||
n, err := io.ReadFull(src, buf)
|
||||
switch {
|
||||
case err == nil:
|
||||
return rawChunk{kind: chunkFull, data: buf}
|
||||
case err == io.EOF:
|
||||
return rawChunk{kind: chunkEOF}
|
||||
case err == io.ErrUnexpectedEOF:
|
||||
return rawChunk{kind: chunkShort, data: buf[:n]}
|
||||
default:
|
||||
return rawChunk{kind: chunkErr, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// pump is the single goroutine that reads raw chunks from src, in order,
|
||||
// always staying one chunk ahead so that every dispatched chunk's
|
||||
// last-chunk status is already certain. It closes r.jobs and r.order when
|
||||
// the stream ends or a fatal error is found, which is what lets Read
|
||||
// observe a clean end of stream (order closed with nothing left) versus an
|
||||
// explicit terminal error (an emitDirect result).
|
||||
func (r *parallelDecryptReader) pump() {
|
||||
defer close(r.jobs)
|
||||
defer close(r.order)
|
||||
|
||||
first := readRawChunk(r.src)
|
||||
switch first.kind {
|
||||
case chunkErr:
|
||||
r.emitDirect(decResult{err: first.err})
|
||||
return
|
||||
case chunkEOF:
|
||||
// A message can't end without a marked chunk; this message is
|
||||
// truncated, exactly like the sequential reader's first read.
|
||||
r.emitDirect(decResult{err: io.ErrUnexpectedEOF})
|
||||
return
|
||||
case chunkShort:
|
||||
// This is chunk index 0: an empty chunk here is the valid
|
||||
// empty-plaintext-file case, not the "empty last chunk" error,
|
||||
// which can only apply to a later chunk.
|
||||
r.dispatch(0, first.data, true)
|
||||
r.probeTrailing()
|
||||
return
|
||||
}
|
||||
|
||||
// first.kind == chunkFull: ambiguous until we see what follows it.
|
||||
pendingIndex := int64(0)
|
||||
pending := first.data
|
||||
for {
|
||||
next := readRawChunk(r.src)
|
||||
switch next.kind {
|
||||
case chunkErr:
|
||||
r.dispatch(pendingIndex, pending, false)
|
||||
r.emitDirect(decResult{err: next.err})
|
||||
return
|
||||
case chunkEOF:
|
||||
// Nothing follows: pending was a full-length final chunk. The
|
||||
// clean EOF we just observed is itself the trailing-data proof,
|
||||
// no separate probe needed.
|
||||
r.dispatch(pendingIndex, pending, true)
|
||||
return
|
||||
case chunkShort:
|
||||
r.dispatch(pendingIndex, pending, false)
|
||||
if len(next.data) == chacha20poly1305.Overhead {
|
||||
// A short chunk here is never index 0, so an empty one is
|
||||
// always the disallowed case.
|
||||
r.emitDirect(decResult{err: errors.New("last chunk is empty; encrypted payload is malformed")})
|
||||
return
|
||||
}
|
||||
r.dispatch(pendingIndex+1, next.data, true)
|
||||
r.probeTrailing()
|
||||
return
|
||||
case chunkFull:
|
||||
r.dispatch(pendingIndex, pending, false)
|
||||
pendingIndex++
|
||||
pending = next.data
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// probeTrailing confirms there is no data after a short final chunk, exactly
|
||||
// as the sequential DecryptReader.Read does after processing a last chunk.
|
||||
func (r *parallelDecryptReader) probeTrailing() {
|
||||
var b [1]byte
|
||||
_, err := r.src.Read(b[:])
|
||||
switch {
|
||||
case err == nil:
|
||||
r.emitDirect(decResult{err: errors.New("trailing data after end of encrypted file")})
|
||||
case err != io.EOF:
|
||||
r.emitDirect(decResult{err: fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err)})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 The fury Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package stream
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/cipher"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
// pipelineDepthPerWorker bounds how many chunks may be queued for, or
|
||||
// awaiting collection from, a single worker at once. It caps the pipeline's
|
||||
// memory use to a small multiple of ChunkSize regardless of input size.
|
||||
const pipelineDepthPerWorker = 2
|
||||
|
||||
// NewParallelEncryptWriter is equivalent to [NewEncryptWriter], except that
|
||||
// chunks are sealed by a pool of worker goroutines instead of inline on the
|
||||
// calling goroutine. Its output is byte-for-byte identical to
|
||||
// NewEncryptWriter's: STREAM chunk nonces are a pure counter, so sealing
|
||||
// chunk i never depends on chunk i-1. Only the write order to dst must stay
|
||||
// sequential, which a dedicated collector goroutine enforces.
|
||||
//
|
||||
// workers <= 1 returns a plain [NewEncryptWriter] with no pipeline overhead.
|
||||
func NewParallelEncryptWriter(key []byte, dst io.Writer, workers int) (io.WriteCloser, error) {
|
||||
if workers <= 1 {
|
||||
return NewEncryptWriter(key, dst)
|
||||
}
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
depth := workers * pipelineDepthPerWorker
|
||||
w := ¶llelEncryptWriter{
|
||||
a: aead,
|
||||
dst: dst,
|
||||
jobs: make(chan encJob, depth),
|
||||
order: make(chan chan []byte, depth),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
w.wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go w.work()
|
||||
}
|
||||
go w.collect()
|
||||
return w, nil
|
||||
}
|
||||
|
||||
type encJob struct {
|
||||
nonce [chacha20poly1305.NonceSize]byte
|
||||
plaintext []byte
|
||||
result chan []byte
|
||||
}
|
||||
|
||||
type parallelEncryptWriter struct {
|
||||
a cipher.AEAD
|
||||
dst io.Writer
|
||||
|
||||
buf bytes.Buffer
|
||||
index int64
|
||||
|
||||
jobs chan encJob
|
||||
order chan chan []byte
|
||||
wg sync.WaitGroup
|
||||
done chan struct{} // closed once the collector goroutine has returned
|
||||
|
||||
mu sync.Mutex
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *parallelEncryptWriter) getErr() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.err
|
||||
}
|
||||
|
||||
func (w *parallelEncryptWriter) setErr(err error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.err == nil {
|
||||
w.err = err
|
||||
}
|
||||
}
|
||||
|
||||
func (w *parallelEncryptWriter) Write(p []byte) (n int, err error) {
|
||||
if err := w.getErr(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
total := len(p)
|
||||
for len(p) > 0 {
|
||||
room := ChunkSize - w.buf.Len()
|
||||
nn := min(len(p), room)
|
||||
w.buf.Write(p[:nn])
|
||||
p = p[nn:]
|
||||
|
||||
// Only submit a chunk once we know more data is coming, so the very
|
||||
// last chunk (submitted from Close) is always the one that gets the
|
||||
// last-chunk-flag nonce, exactly like the sequential EncryptWriter.
|
||||
if w.buf.Len() == ChunkSize && len(p) > 0 {
|
||||
if err := w.submitChunk(false); err != nil {
|
||||
w.setErr(err)
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// Close flushes the last chunk and waits for every queued chunk to be
|
||||
// sealed and written to dst, in order. It does not close the underlying
|
||||
// Writer.
|
||||
func (w *parallelEncryptWriter) Close() error {
|
||||
if err := w.getErr(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// submitChunk cannot fail here: we just confirmed there is no prior
|
||||
// error, and channel sends to w.jobs/w.order only block, they never
|
||||
// return an error.
|
||||
_ = w.submitChunk(true)
|
||||
|
||||
close(w.jobs)
|
||||
close(w.order)
|
||||
w.wg.Wait()
|
||||
<-w.done
|
||||
|
||||
if err := w.getErr(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.setErr(errors.New("stream.Writer is already closed"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// submitChunk copies out the current buffer as a chunk of plaintext, assigns
|
||||
// it the next sequential nonce, and hands it to the worker pool. last must be
|
||||
// true only for the final call, made from Close.
|
||||
func (w *parallelEncryptWriter) submitChunk(last bool) error {
|
||||
if err := w.getErr(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plaintext := append([]byte(nil), w.buf.Bytes()...)
|
||||
w.buf.Reset()
|
||||
|
||||
nonce := nonceForChunk(w.index)
|
||||
if last {
|
||||
setLastChunkFlag(nonce)
|
||||
}
|
||||
w.index++
|
||||
|
||||
result := make(chan []byte, 1)
|
||||
w.order <- result
|
||||
w.jobs <- encJob{nonce: *nonce, plaintext: plaintext, result: result}
|
||||
return nil
|
||||
}
|
||||
|
||||
// work seals queued chunks. Seal cannot fail: the nonce is always the
|
||||
// correct size, so the only possible failure in this pipeline is dst.Write,
|
||||
// handled in collect.
|
||||
func (w *parallelEncryptWriter) work() {
|
||||
defer w.wg.Done()
|
||||
for j := range w.jobs {
|
||||
ciphertext := w.a.Seal(make([]byte, 0, len(j.plaintext)+w.a.Overhead()), j.nonce[:], j.plaintext, nil)
|
||||
j.result <- ciphertext
|
||||
}
|
||||
}
|
||||
|
||||
// collect writes sealed chunks to dst strictly in submission order, even
|
||||
// though workers may finish sealing them in a different order.
|
||||
func (w *parallelEncryptWriter) collect() {
|
||||
defer close(w.done)
|
||||
var failed bool
|
||||
for result := range w.order {
|
||||
ciphertext := <-result
|
||||
if failed {
|
||||
continue
|
||||
}
|
||||
if _, err := w.dst.Write(ciphertext); err != nil {
|
||||
w.setErr(err)
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package stream implements a variant of the STREAM chunked encryption scheme.
|
||||
//
|
||||
// The invariants this package exists to hold:
|
||||
//
|
||||
// - Counter uniqueness. A chunk's nonce is a pure big-endian counter over
|
||||
// bytes 3..10, incremented once per chunk and never reset within a
|
||||
// stream. incNonce panics rather than wrapping, so a (key, nonce) pair
|
||||
// can never repeat: the counter is 88 bits and the panic is unreachable
|
||||
// for any real file.
|
||||
// - Content independence. The nonce depends only on the chunk index and the
|
||||
// last-chunk flag, never on chunk contents. That is what lets the parallel
|
||||
// encoder seal chunks out of order and still produce byte-identical
|
||||
// output.
|
||||
// - Truncation resistance. The final chunk is sealed under a nonce whose
|
||||
// last byte carries lastChunkFlag. A truncated stream therefore fails to
|
||||
// authenticate: the attacker would have to forge a last-chunk tag for a
|
||||
// chunk the writer sealed as non-final.
|
||||
// - No trailing data. After a last chunk is accepted, the reader confirms
|
||||
// the underlying source is at EOF and fails otherwise, so appended bytes
|
||||
// are rejected rather than silently ignored.
|
||||
package stream
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
const ChunkSize = 64 * 1024
|
||||
|
||||
func EncryptedChunkCount(encryptedSize int64) (int64, error) {
|
||||
chunks := (encryptedSize + encChunkSize - 1) / encChunkSize
|
||||
|
||||
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
|
||||
expChunks := (plaintextSize + ChunkSize - 1) / ChunkSize
|
||||
// Empty plaintext, the only case that allows (and requires) an empty chunk.
|
||||
if plaintextSize == 0 {
|
||||
expChunks = 1
|
||||
}
|
||||
if expChunks != chunks {
|
||||
return 0, fmt.Errorf("invalid encrypted payload size: %d", encryptedSize)
|
||||
}
|
||||
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func PlaintextSize(encryptedSize int64) (int64, error) {
|
||||
chunks, err := EncryptedChunkCount(encryptedSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
|
||||
return plaintextSize, nil
|
||||
}
|
||||
|
||||
type DecryptReader struct {
|
||||
a cipher.AEAD
|
||||
src io.Reader
|
||||
|
||||
unread []byte // decrypted but unread data, backed by buf
|
||||
buf [encChunkSize]byte
|
||||
|
||||
err error
|
||||
nonce [chacha20poly1305.NonceSize]byte
|
||||
}
|
||||
|
||||
const (
|
||||
encChunkSize = ChunkSize + chacha20poly1305.Overhead
|
||||
lastChunkFlag = 0x01
|
||||
)
|
||||
|
||||
func NewDecryptReader(key []byte, src io.Reader) (*DecryptReader, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DecryptReader{a: aead, src: src}, nil
|
||||
}
|
||||
|
||||
func (r *DecryptReader) Read(p []byte) (int, error) {
|
||||
if len(r.unread) > 0 {
|
||||
n := copy(p, r.unread)
|
||||
r.unread = r.unread[n:]
|
||||
return n, nil
|
||||
}
|
||||
if r.err != nil {
|
||||
return 0, r.err
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
last, err := r.readChunk()
|
||||
if err != nil {
|
||||
r.err = err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n := copy(p, r.unread)
|
||||
r.unread = r.unread[n:]
|
||||
|
||||
if last {
|
||||
// Ensure there is an EOF after the last chunk as expected. In other
|
||||
// words, check for trailing data after a full-length final chunk.
|
||||
// Hopefully, the underlying reader supports returning EOF even if it
|
||||
// had previously returned an EOF to ReadFull.
|
||||
if _, err := r.src.Read(make([]byte, 1)); err == nil {
|
||||
r.err = errors.New("trailing data after end of encrypted file")
|
||||
} else if err != io.EOF {
|
||||
r.err = fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err)
|
||||
} else {
|
||||
r.err = io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// readChunk reads the next chunk of ciphertext from r.src and makes it available
|
||||
// in r.unread. last is true if the chunk was marked as the end of the message.
|
||||
// readChunk must not be called again after returning a last chunk or an error.
|
||||
func (r *DecryptReader) readChunk() (last bool, err error) {
|
||||
if len(r.unread) != 0 {
|
||||
panic("stream: internal error: readChunk called with dirty buffer")
|
||||
}
|
||||
|
||||
in := r.buf[:]
|
||||
n, err := io.ReadFull(r.src, in)
|
||||
switch {
|
||||
case err == io.EOF:
|
||||
// A message can't end without a marked chunk. This message is truncated.
|
||||
return false, io.ErrUnexpectedEOF
|
||||
case err == io.ErrUnexpectedEOF:
|
||||
// The last chunk can be short, but not empty unless it's the first and
|
||||
// only chunk.
|
||||
if !nonceIsZero(&r.nonce) && n == r.a.Overhead() {
|
||||
return false, errors.New("last chunk is empty; encrypted payload is malformed")
|
||||
}
|
||||
in = in[:n]
|
||||
last = true
|
||||
setLastChunkFlag(&r.nonce)
|
||||
case err != nil:
|
||||
return false, err
|
||||
}
|
||||
|
||||
outBuf := make([]byte, 0, ChunkSize)
|
||||
out, err := r.a.Open(outBuf, r.nonce[:], in, nil)
|
||||
if err != nil && !last {
|
||||
// Check if this was a full-length final chunk.
|
||||
last = true
|
||||
setLastChunkFlag(&r.nonce)
|
||||
out, err = r.a.Open(outBuf, r.nonce[:], in, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return false, errors.New("failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with")
|
||||
}
|
||||
|
||||
incNonce(&r.nonce)
|
||||
r.unread = r.buf[:copy(r.buf[:], out)]
|
||||
return last, nil
|
||||
}
|
||||
|
||||
func incNonce(nonce *[chacha20poly1305.NonceSize]byte) {
|
||||
for i := len(nonce) - 2; i >= 0; i-- {
|
||||
nonce[i]++
|
||||
if nonce[i] != 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
// The counter is 88 bits, this is unreachable.
|
||||
panic("stream: chunk counter wrapped around")
|
||||
}
|
||||
|
||||
func nonceForChunk(chunkIndex int64) *[chacha20poly1305.NonceSize]byte {
|
||||
var nonce [chacha20poly1305.NonceSize]byte
|
||||
binary.BigEndian.PutUint64(nonce[3:11], uint64(chunkIndex))
|
||||
return &nonce
|
||||
}
|
||||
|
||||
func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) {
|
||||
nonce[len(nonce)-1] = lastChunkFlag
|
||||
}
|
||||
|
||||
func nonceIsZero(nonce *[chacha20poly1305.NonceSize]byte) bool {
|
||||
return *nonce == [chacha20poly1305.NonceSize]byte{}
|
||||
}
|
||||
|
||||
type EncryptWriter struct {
|
||||
a cipher.AEAD
|
||||
dst io.Writer
|
||||
buf bytes.Buffer
|
||||
nonce [chacha20poly1305.NonceSize]byte
|
||||
err error
|
||||
}
|
||||
|
||||
func NewEncryptWriter(key []byte, dst io.Writer) (*EncryptWriter, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EncryptWriter{a: aead, dst: dst}, nil
|
||||
}
|
||||
|
||||
func (w *EncryptWriter) Write(p []byte) (n int, err error) {
|
||||
if w.err != nil {
|
||||
return 0, w.err
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
total := len(p)
|
||||
for len(p) > 0 {
|
||||
n := min(len(p), ChunkSize-w.buf.Len())
|
||||
w.buf.Write(p[:n])
|
||||
p = p[n:]
|
||||
|
||||
// Only flush if there's a full chunk with bytes still to write, or we
|
||||
// can't know if this is the last chunk yet.
|
||||
if w.buf.Len() == ChunkSize && len(p) > 0 {
|
||||
if err := w.flushChunk(notLastChunk); err != nil {
|
||||
w.err = err
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// Close flushes the last chunk. It does not close the underlying Writer.
|
||||
func (w *EncryptWriter) Close() error {
|
||||
if w.err != nil {
|
||||
return w.err
|
||||
}
|
||||
|
||||
w.err = w.flushChunk(lastChunk)
|
||||
if w.err != nil {
|
||||
return w.err
|
||||
}
|
||||
|
||||
w.err = errors.New("stream.Writer is already closed")
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
lastChunk = true
|
||||
notLastChunk = false
|
||||
)
|
||||
|
||||
func (w *EncryptWriter) flushChunk(last bool) error {
|
||||
if !last && w.buf.Len() != ChunkSize {
|
||||
panic("stream: internal error: flush called with partial chunk")
|
||||
}
|
||||
|
||||
if last {
|
||||
setLastChunkFlag(&w.nonce)
|
||||
}
|
||||
w.buf.Grow(chacha20poly1305.Overhead)
|
||||
ciphertext := w.a.Seal(w.buf.Bytes()[:0], w.nonce[:], w.buf.Bytes(), nil)
|
||||
_, err := w.dst.Write(ciphertext)
|
||||
incNonce(&w.nonce)
|
||||
w.buf.Reset()
|
||||
return err
|
||||
}
|
||||
|
||||
type EncryptReader struct {
|
||||
a cipher.AEAD
|
||||
src io.Reader
|
||||
|
||||
// The first ready bytes of buf are already encrypted. This may be less than
|
||||
// buf.Len(), because we need to over-read to know if a chunk is the last.
|
||||
ready int
|
||||
buf bytes.Buffer
|
||||
|
||||
nonce [chacha20poly1305.NonceSize]byte
|
||||
err error
|
||||
}
|
||||
|
||||
func NewEncryptReader(key []byte, src io.Reader) (*EncryptReader, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EncryptReader{a: aead, src: src}, nil
|
||||
}
|
||||
|
||||
func (r *EncryptReader) Read(p []byte) (int, error) {
|
||||
if r.ready > 0 {
|
||||
n, err := r.buf.Read(p[:min(len(p), r.ready)])
|
||||
r.ready -= n
|
||||
return n, err
|
||||
}
|
||||
if r.err != nil {
|
||||
return 0, r.err
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if err := r.feedBuffer(); err != nil {
|
||||
r.err = err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n, err := r.buf.Read(p[:min(len(p), r.ready)])
|
||||
r.ready -= n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// feedBuffer reads and encrypts the next chunk from r.src and appends it to
|
||||
// r.buf. It sets r.ready to the number of newly available bytes in r.buf.
|
||||
func (r *EncryptReader) feedBuffer() error {
|
||||
if r.ready > 0 {
|
||||
panic("stream: internal error: feedBuffer called with dirty buffer")
|
||||
}
|
||||
|
||||
// CopyN will use r.buf.ReadFrom/WriteTo to fill the buffer directly.
|
||||
// We need ChunkSize + 1 bytes to determine if this is the last chunk.
|
||||
_, err := io.CopyN(&r.buf, r.src, int64(ChunkSize-r.buf.Len()+1))
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
|
||||
if last := r.buf.Len() <= ChunkSize; last {
|
||||
setLastChunkFlag(&r.nonce)
|
||||
|
||||
// After Grow, we know r.buf.Bytes() has enough capacity for the
|
||||
// overhead. We encrypt in place and then do a Write to include the
|
||||
// overhead in the buffer.
|
||||
r.buf.Grow(chacha20poly1305.Overhead)
|
||||
plaintext := r.buf.Bytes()
|
||||
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
|
||||
incNonce(&r.nonce)
|
||||
r.buf.Write(plaintext[len(plaintext) : len(plaintext)+chacha20poly1305.Overhead])
|
||||
r.ready = r.buf.Len()
|
||||
|
||||
r.err = io.EOF
|
||||
return nil
|
||||
}
|
||||
|
||||
// Same, but accounting for the tail byte which will remain unencrypted and
|
||||
// needs to be shifted past the overhead.
|
||||
if r.buf.Len() != ChunkSize+1 {
|
||||
panic("stream: internal error: unexpected buffer length")
|
||||
}
|
||||
tailByte := r.buf.Bytes()[ChunkSize]
|
||||
r.buf.Grow(chacha20poly1305.Overhead)
|
||||
plaintext := r.buf.Bytes()[:ChunkSize]
|
||||
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
|
||||
incNonce(&r.nonce)
|
||||
r.buf.Write(plaintext[len(plaintext)+1 : len(plaintext)+chacha20poly1305.Overhead])
|
||||
r.buf.WriteByte(tailByte)
|
||||
r.ready = ChunkSize + chacha20poly1305.Overhead
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type DecryptReaderAt struct {
|
||||
a cipher.AEAD
|
||||
src io.ReaderAt
|
||||
size int64
|
||||
chunks int64
|
||||
cache atomic.Pointer[cachedChunk]
|
||||
}
|
||||
|
||||
type cachedChunk struct {
|
||||
off int64
|
||||
data []byte
|
||||
}
|
||||
|
||||
func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReaderAt, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check that size is valid by decrypting the final chunk.
|
||||
chunks, err := EncryptedChunkCount(size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finalChunkIndex := chunks - 1
|
||||
finalChunkOff := finalChunkIndex * encChunkSize
|
||||
finalChunkSize := size - finalChunkOff
|
||||
finalChunk := make([]byte, finalChunkSize)
|
||||
if _, err := src.ReadAt(finalChunk, finalChunkOff); err != nil {
|
||||
return nil, fmt.Errorf("failed to read final chunk: %w", err)
|
||||
}
|
||||
nonce := nonceForChunk(finalChunkIndex)
|
||||
setLastChunkFlag(nonce)
|
||||
plaintext, err := aead.Open(finalChunk[:0], nonce[:], finalChunk, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt and authenticate final chunk: %w", err)
|
||||
}
|
||||
cache := &cachedChunk{off: finalChunkOff, data: plaintext}
|
||||
|
||||
plaintextSize := size - chunks*chacha20poly1305.Overhead
|
||||
r := &DecryptReaderAt{a: aead, src: src, size: plaintextSize, chunks: chunks}
|
||||
r.cache.Store(cache)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
|
||||
if off < 0 || off > r.size {
|
||||
return 0, fmt.Errorf("offset out of range [0:%d]: %d", r.size, off)
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var cacheUpdate *cachedChunk
|
||||
chunk := make([]byte, encChunkSize)
|
||||
for len(p) > 0 && off < r.size {
|
||||
chunkIndex := off / ChunkSize
|
||||
chunkOff := chunkIndex * encChunkSize
|
||||
encSize := r.size + r.chunks*chacha20poly1305.Overhead
|
||||
chunkSize := min(encSize-chunkOff, encChunkSize)
|
||||
|
||||
cached := r.cache.Load()
|
||||
var plaintext []byte
|
||||
if cached != nil && cached.off == chunkOff {
|
||||
plaintext = cached.data
|
||||
cacheUpdate = nil
|
||||
} else {
|
||||
nn, err := r.src.ReadAt(chunk[:chunkSize], chunkOff)
|
||||
if err == io.EOF {
|
||||
if int64(nn) != chunkSize {
|
||||
err = io.ErrUnexpectedEOF
|
||||
} else {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("failed to read chunk at offset %d: %w", chunkOff, err)
|
||||
}
|
||||
nonce := nonceForChunk(chunkIndex)
|
||||
if chunkIndex == r.chunks-1 {
|
||||
setLastChunkFlag(nonce)
|
||||
}
|
||||
plaintext, err = r.a.Open(chunk[:0], nonce[:], chunk[:chunkSize], nil)
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("failed to decrypt and authenticate chunk at offset %d: %w", chunkOff, err)
|
||||
}
|
||||
cacheUpdate = &cachedChunk{off: chunkOff, data: plaintext}
|
||||
}
|
||||
|
||||
plainChunkOff := int(off - chunkIndex*ChunkSize)
|
||||
copySize := min(len(plaintext)-plainChunkOff, len(p))
|
||||
copy(p, plaintext[plainChunkOff:plainChunkOff+copySize])
|
||||
p = p[copySize:]
|
||||
off += int64(copySize)
|
||||
n += copySize
|
||||
}
|
||||
if cacheUpdate != nil {
|
||||
r.cache.Store(cacheUpdate)
|
||||
}
|
||||
if off == r.size {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,950 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package stream_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
|
||||
"github.com/andydixon/vault/internal/stream"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
const cs = stream.ChunkSize
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
for _, length := range []int{0, 1000, cs - 1, cs, cs + 1, cs + 100, 2 * cs, 2*cs + 500} {
|
||||
for _, stepSize := range []int{512, 600, 1000, cs - 1, cs, cs + 1} {
|
||||
t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), func(t *testing.T) {
|
||||
testRoundTrip(t, stepSize, length)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
length, stepSize := 2*cs+500, 1
|
||||
t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), func(t *testing.T) {
|
||||
testRoundTrip(t, stepSize, length)
|
||||
})
|
||||
}
|
||||
|
||||
func testRoundTrip(t *testing.T, stepSize, length int) {
|
||||
src := make([]byte, length)
|
||||
if _, err := rand.Read(src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ciphertext []byte
|
||||
|
||||
t.Run("EncryptWriter", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var n int
|
||||
for n < length {
|
||||
b := min(length-n, stepSize)
|
||||
nn, err := w.Write(src[n : n+b])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nn != b {
|
||||
t.Errorf("Write returned %d, expected %d", nn, b)
|
||||
}
|
||||
n += nn
|
||||
|
||||
nn, err = w.Write(src[n:n])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nn != 0 {
|
||||
t.Errorf("Write returned %d, expected 0", nn)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Error("Close returned an error:", err)
|
||||
}
|
||||
|
||||
ciphertext = buf.Bytes()
|
||||
})
|
||||
|
||||
t.Run("DecryptReader", func(t *testing.T) {
|
||||
r, err := stream.NewDecryptReader(key, bytes.NewReader(ciphertext))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var n int
|
||||
readBuf := make([]byte, stepSize)
|
||||
for n < length {
|
||||
nn, err := r.Read(readBuf)
|
||||
if err != nil {
|
||||
t.Fatalf("Read error at index %d: %v", n, err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(readBuf[:nn], src[n:n+nn]) {
|
||||
t.Errorf("wrong data at indexes %d - %d", n, n+nn)
|
||||
}
|
||||
|
||||
n += nn
|
||||
}
|
||||
|
||||
t.Run("TestReader", func(t *testing.T) {
|
||||
if length > 1000 && testing.Short() {
|
||||
t.Skip("skipping slow iotest.TestReader on long input")
|
||||
}
|
||||
r, _ := stream.NewDecryptReader(key, bytes.NewReader(ciphertext))
|
||||
if err := iotest.TestReader(r, src); err != nil {
|
||||
t.Error("iotest.TestReader error on DecryptReader:", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("DecryptReaderAt", func(t *testing.T) {
|
||||
rAt, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := io.NewSectionReader(rAt, 0, int64(len(ciphertext)))
|
||||
|
||||
var n int
|
||||
readBuf := make([]byte, stepSize)
|
||||
for n < length {
|
||||
nn, err := rr.Read(readBuf)
|
||||
if n+nn == length && err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAt error at index %d: %v", n, err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(readBuf[:nn], src[n:n+nn]) {
|
||||
t.Errorf("wrong data at indexes %d - %d", n, n+nn)
|
||||
}
|
||||
|
||||
n += nn
|
||||
}
|
||||
|
||||
t.Run("TestReader", func(t *testing.T) {
|
||||
if length > 1000 && testing.Short() {
|
||||
t.Skip("skipping slow iotest.TestReader on long input")
|
||||
}
|
||||
rr := io.NewSectionReader(rAt, 0, int64(len(src)))
|
||||
if err := iotest.TestReader(rr, src); err != nil {
|
||||
t.Error("iotest.TestReader error on DecryptReaderAt:", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("EncryptReader", func(t *testing.T) {
|
||||
er, err := stream.NewEncryptReader(key, bytes.NewReader(src))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var n int
|
||||
readBuf := make([]byte, stepSize)
|
||||
for {
|
||||
nn, err := er.Read(readBuf)
|
||||
if nn == 0 && err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
t.Fatalf("EncryptReader Read error at index %d: %v", n, err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(readBuf[:nn], ciphertext[n:n+nn]) {
|
||||
t.Errorf("EncryptReader wrong data at indexes %d - %d", n, n+nn)
|
||||
}
|
||||
|
||||
n += nn
|
||||
}
|
||||
if n != len(ciphertext) {
|
||||
t.Errorf("EncryptReader read %d bytes, expected %d", n, len(ciphertext))
|
||||
}
|
||||
|
||||
t.Run("TestReader", func(t *testing.T) {
|
||||
if length > 1000 && testing.Short() {
|
||||
t.Skip("skipping slow iotest.TestReader on long input")
|
||||
}
|
||||
er, _ := stream.NewEncryptReader(key, bytes.NewReader(src))
|
||||
if err := iotest.TestReader(er, ciphertext); err != nil {
|
||||
t.Error("iotest.TestReader error on EncryptReader:", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// trackingReaderAt wraps an io.ReaderAt and tracks whether ReadAt was called.
|
||||
type trackingReaderAt struct {
|
||||
r io.ReaderAt
|
||||
called bool
|
||||
}
|
||||
|
||||
func (t *trackingReaderAt) ReadAt(p []byte, off int64) (int, error) {
|
||||
t.called = true
|
||||
return t.r.ReadAt(p, off)
|
||||
}
|
||||
|
||||
func (t *trackingReaderAt) reset() {
|
||||
t.called = false
|
||||
}
|
||||
|
||||
func TestDecryptReaderAt(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create plaintext spanning exactly 3 chunks: 2 full chunks + partial third
|
||||
// Chunk 0: [0, cs)
|
||||
// Chunk 1: [cs, 2*cs)
|
||||
// Chunk 2: [2*cs, 2*cs+500)
|
||||
plaintextSize := 2*cs + 500
|
||||
plaintext := make([]byte, plaintextSize)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
// Create tracking ReaderAt
|
||||
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
|
||||
|
||||
// Create DecryptReaderAt (this reads and caches the final chunk)
|
||||
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker.reset()
|
||||
|
||||
// Helper to check reads
|
||||
checkRead := func(name string, off int64, size int, wantN int, wantEOF bool, wantSrcRead bool) {
|
||||
t.Helper()
|
||||
tracker.reset()
|
||||
p := make([]byte, size)
|
||||
n, err := ra.ReadAt(p, off)
|
||||
|
||||
if wantEOF {
|
||||
if err != io.EOF {
|
||||
t.Errorf("%s: got err=%v, want EOF", name, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("%s: got err=%v, want nil", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if n != wantN {
|
||||
t.Errorf("%s: got n=%d, want %d", name, n, wantN)
|
||||
}
|
||||
|
||||
if tracker.called != wantSrcRead {
|
||||
t.Errorf("%s: src.ReadAt called=%v, want %v", name, tracker.called, wantSrcRead)
|
||||
}
|
||||
|
||||
// Verify data correctness
|
||||
if n > 0 && off >= 0 && off < int64(plaintextSize) {
|
||||
end := int(off) + n
|
||||
if end > plaintextSize {
|
||||
end = plaintextSize
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[off:end]) {
|
||||
t.Errorf("%s: data mismatch", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 1: Read from final chunk (cached by constructor)
|
||||
checkRead("final chunk (cached)", int64(2*cs+100), 100, 100, false, false)
|
||||
|
||||
// Test 2: Read spanning second and third chunk
|
||||
checkRead("span chunks 1-2", int64(cs+cs-50), 100, 100, false, true)
|
||||
|
||||
// Test 3: Read from final chunk again (cached from test 2)
|
||||
// When reading across chunks 1-2 in test 2, the loop processes chunk 1 then chunk 2,
|
||||
// so chunk 2 ends up in the cache.
|
||||
checkRead("final chunk after span", int64(2*cs+200), 100, 100, false, false)
|
||||
|
||||
// Test 4: Read from final chunk again (now cached)
|
||||
checkRead("final chunk (cached again)", int64(2*cs+50), 50, 50, false, false)
|
||||
|
||||
// Test 5: Read from first chunk (not cached)
|
||||
checkRead("first chunk", 0, 100, 100, false, true)
|
||||
|
||||
// Test 6: Read from first chunk again (now cached)
|
||||
checkRead("first chunk (cached)", 50, 100, 100, false, false)
|
||||
|
||||
// Test 7: Read spanning all chunks
|
||||
tracker.reset()
|
||||
p := make([]byte, plaintextSize)
|
||||
n, err := ra.ReadAt(p, 0)
|
||||
if err != io.EOF {
|
||||
t.Errorf("span all: got err=%v, want EOF", err)
|
||||
}
|
||||
if n != plaintextSize {
|
||||
t.Errorf("span all: got n=%d, want %d", n, plaintextSize)
|
||||
}
|
||||
if !bytes.Equal(p, plaintext) {
|
||||
t.Errorf("span all: data mismatch")
|
||||
}
|
||||
|
||||
// Test 8: Read beyond the end (offset > size)
|
||||
tracker.reset()
|
||||
p = make([]byte, 100)
|
||||
n, err = ra.ReadAt(p, int64(plaintextSize+100))
|
||||
if err == nil {
|
||||
t.Error("beyond end: expected error, got nil")
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("beyond end: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 9: Read with off = size (should return 0, EOF)
|
||||
tracker.reset()
|
||||
p = make([]byte, 100)
|
||||
n, err = ra.ReadAt(p, int64(plaintextSize))
|
||||
if err != io.EOF {
|
||||
t.Errorf("off=size: got err=%v, want EOF", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("off=size: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 10: Read spanning last chunk and beyond
|
||||
tracker.reset()
|
||||
p = make([]byte, 1000) // request more than available
|
||||
n, err = ra.ReadAt(p, int64(2*cs+400))
|
||||
if err != io.EOF {
|
||||
t.Errorf("span last+beyond: got err=%v, want EOF", err)
|
||||
}
|
||||
wantN := 500 - 400 // only 100 bytes available from offset 2*cs+400
|
||||
if n != wantN {
|
||||
t.Errorf("span last+beyond: got n=%d, want %d", n, wantN)
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[2*cs+400:]) {
|
||||
t.Error("span last+beyond: data mismatch")
|
||||
}
|
||||
|
||||
// Test 11: Read spanning second+last chunk and beyond
|
||||
tracker.reset()
|
||||
p = make([]byte, cs+1000) // request more than available
|
||||
n, err = ra.ReadAt(p, int64(cs+100))
|
||||
if err != io.EOF {
|
||||
t.Errorf("span 1-2+beyond: got err=%v, want EOF", err)
|
||||
}
|
||||
wantN = plaintextSize - (cs + 100)
|
||||
if n != wantN {
|
||||
t.Errorf("span 1-2+beyond: got n=%d, want %d", n, wantN)
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[cs+100:]) {
|
||||
t.Error("span 1-2+beyond: data mismatch")
|
||||
}
|
||||
|
||||
// Test 12: Negative offset
|
||||
tracker.reset()
|
||||
p = make([]byte, 100)
|
||||
n, err = ra.ReadAt(p, -1)
|
||||
if err == nil {
|
||||
t.Error("negative offset: expected error, got nil")
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("negative offset: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 13: Zero-length read in the middle
|
||||
tracker.reset()
|
||||
p = make([]byte, 0)
|
||||
n, err = ra.ReadAt(p, 100)
|
||||
if err != nil {
|
||||
t.Errorf("zero-length middle: got err=%v, want nil", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("zero-length middle: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 14: Zero-length read at end
|
||||
tracker.reset()
|
||||
p = make([]byte, 0)
|
||||
n, err = ra.ReadAt(p, int64(plaintextSize))
|
||||
if err != nil {
|
||||
t.Errorf("zero-length end: got err=%v, want nil", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("zero-length end: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 15: Read exactly one chunk at chunk boundary
|
||||
checkRead("exact chunk at boundary", int64(cs), cs, cs, false, true)
|
||||
|
||||
// Test 16: Read one byte at each chunk boundary
|
||||
checkRead("one byte at start", 0, 1, 1, false, true)
|
||||
checkRead("one byte at cs-1", int64(cs-1), 1, 1, false, false) // cached from test 15
|
||||
checkRead("one byte at cs", int64(cs), 1, 1, false, true)
|
||||
checkRead("one byte at 2*cs-1", int64(2*cs-1), 1, 1, false, false) // same chunk
|
||||
checkRead("one byte at 2*cs", int64(2*cs), 1, 1, false, true)
|
||||
checkRead("last byte", int64(plaintextSize-1), 1, 1, true, false) // same chunk, EOF because we reach end
|
||||
|
||||
// Test 17: Read crossing exactly one chunk boundary
|
||||
checkRead("cross boundary 0-1", int64(cs-50), 100, 100, false, true)
|
||||
checkRead("cross boundary 1-2", int64(2*cs-50), 100, 100, false, true)
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtEmpty(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create empty encrypted file
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
|
||||
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker.reset()
|
||||
|
||||
// Test 1: Read from empty file at offset 0
|
||||
p := make([]byte, 100)
|
||||
n, err := ra.ReadAt(p, 0)
|
||||
if err != io.EOF {
|
||||
t.Errorf("empty read: got err=%v, want EOF", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("empty read: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 2: Zero-length read from empty file
|
||||
p = make([]byte, 0)
|
||||
n, err = ra.ReadAt(p, 0)
|
||||
if err != nil {
|
||||
t.Errorf("empty zero-length: got err=%v, want nil", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("empty zero-length: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 3: Read beyond empty file
|
||||
p = make([]byte, 100)
|
||||
n, err = ra.ReadAt(p, 1)
|
||||
if err == nil {
|
||||
t.Error("empty beyond: expected error, got nil")
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("empty beyond: got n=%d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtSingleChunk(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Single chunk, not full
|
||||
plaintext := make([]byte, 1000)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
|
||||
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker.reset()
|
||||
|
||||
// All reads should use cache (final chunk = only chunk)
|
||||
p := make([]byte, 100)
|
||||
n, err := ra.ReadAt(p, 0)
|
||||
if err != nil {
|
||||
t.Errorf("single chunk start: got err=%v, want nil", err)
|
||||
}
|
||||
if n != 100 {
|
||||
t.Errorf("single chunk start: got n=%d, want 100", n)
|
||||
}
|
||||
if tracker.called {
|
||||
t.Error("single chunk start: unexpected src.ReadAt call")
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[:100]) {
|
||||
t.Error("single chunk start: data mismatch")
|
||||
}
|
||||
|
||||
// Read at end
|
||||
n, err = ra.ReadAt(p, 900)
|
||||
if err != io.EOF {
|
||||
t.Errorf("single chunk end: got err=%v, want EOF", err)
|
||||
}
|
||||
if n != 100 {
|
||||
t.Errorf("single chunk end: got n=%d, want 100", n)
|
||||
}
|
||||
if tracker.called {
|
||||
t.Error("single chunk end: unexpected src.ReadAt call")
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[900:]) {
|
||||
t.Error("single chunk end: data mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtFullChunks(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Exactly 2 full chunks
|
||||
plaintext := make([]byte, 2*cs)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
|
||||
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker.reset()
|
||||
|
||||
// Read last byte of second chunk (cached)
|
||||
p := make([]byte, 1)
|
||||
n, err := ra.ReadAt(p, int64(2*cs-1))
|
||||
if err != io.EOF {
|
||||
t.Errorf("last byte: got err=%v, want EOF", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("last byte: got n=%d, want 1", n)
|
||||
}
|
||||
if tracker.called {
|
||||
t.Error("last byte: unexpected src.ReadAt call (should be cached)")
|
||||
}
|
||||
if p[0] != plaintext[2*cs-1] {
|
||||
t.Error("last byte: data mismatch")
|
||||
}
|
||||
|
||||
// Read at exactly the boundary between chunks
|
||||
p = make([]byte, 100)
|
||||
n, err = ra.ReadAt(p, int64(cs-50))
|
||||
if err != nil {
|
||||
t.Errorf("boundary: got err=%v, want nil", err)
|
||||
}
|
||||
if n != 100 {
|
||||
t.Errorf("boundary: got n=%d, want 100", n)
|
||||
}
|
||||
if !bytes.Equal(p, plaintext[cs-50:cs+50]) {
|
||||
t.Error("boundary: data mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtWrongKey(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plaintext := make([]byte, 1000)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
// Try to decrypt with wrong key
|
||||
wrongKey := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(wrongKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = stream.NewDecryptReaderAt(wrongKey, bytes.NewReader(ciphertext), int64(len(ciphertext)))
|
||||
if err == nil {
|
||||
t.Error("wrong key: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtInvalidSize(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plaintext := make([]byte, 1000)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
// Wrong size (too small)
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)-1))
|
||||
if err == nil {
|
||||
t.Error("wrong size (small): expected error, got nil")
|
||||
}
|
||||
|
||||
// Wrong size (too large)
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)+1))
|
||||
if err == nil {
|
||||
t.Error("wrong size (large): expected error, got nil")
|
||||
}
|
||||
|
||||
// Size that would imply empty final chunk (invalid)
|
||||
// This would be: one full encrypted chunk + just overhead
|
||||
invalidSize := int64(cs + chacha20poly1305.Overhead + chacha20poly1305.Overhead)
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(make([]byte, invalidSize)), invalidSize)
|
||||
if err == nil {
|
||||
t.Error("invalid size (empty final chunk): expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtTruncated(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plaintext := make([]byte, 2*cs+500)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
// Truncate ciphertext but lie about size
|
||||
truncated := ciphertext[:len(ciphertext)-100]
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), int64(len(ciphertext)))
|
||||
if err == nil {
|
||||
t.Error("truncated: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtTruncatedChunk(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create 4 chunks: 3 full + 1 partial
|
||||
plaintext := make([]byte, 3*cs+500)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
// Truncate to 3 chunks (remove the actual final chunk)
|
||||
// The third chunk was NOT encrypted with the last chunk flag,
|
||||
// so decryption should fail when we try to use it as the final chunk.
|
||||
encChunkSize := cs + 16 // ChunkSize + Overhead
|
||||
truncatedSize := int64(3 * encChunkSize)
|
||||
truncated := ciphertext[:truncatedSize]
|
||||
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), truncatedSize)
|
||||
if err == nil {
|
||||
t.Error("truncated at chunk boundary: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtConcurrent(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create plaintext spanning 3 chunks: 2 full + partial
|
||||
plaintextSize := 2*cs + 500
|
||||
plaintext := make([]byte, plaintextSize)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("same chunk", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
const goroutines = 10
|
||||
const iterations = 100
|
||||
errc := make(chan error, goroutines)
|
||||
|
||||
for g := range goroutines {
|
||||
go func(id int) {
|
||||
for i := range iterations {
|
||||
off := int64((id*iterations + i) % 500)
|
||||
p := make([]byte, 100)
|
||||
n, err := ra.ReadAt(p, off)
|
||||
if err != nil {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: %v", id, i, err)
|
||||
return
|
||||
}
|
||||
if n != 100 {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want 100", id, i, n)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(p, plaintext[off:off+100]) {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
|
||||
return
|
||||
}
|
||||
}
|
||||
errc <- nil
|
||||
}(g)
|
||||
}
|
||||
|
||||
for range goroutines {
|
||||
if err := <-errc; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different chunks", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
const goroutines = 10
|
||||
const iterations = 100
|
||||
errc := make(chan error, goroutines)
|
||||
|
||||
for g := range goroutines {
|
||||
go func(id int) {
|
||||
for i := range iterations {
|
||||
// Each goroutine reads from a different chunk based on id
|
||||
chunkIdx := id % 3
|
||||
off := int64(chunkIdx*cs + (i % 400))
|
||||
size := 100
|
||||
if off+int64(size) > int64(plaintextSize) {
|
||||
size = plaintextSize - int(off)
|
||||
}
|
||||
p := make([]byte, size)
|
||||
n, err := ra.ReadAt(p, off)
|
||||
if n == size && err == io.EOF {
|
||||
err = nil // EOF at end is acceptable
|
||||
}
|
||||
if err != nil {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: off=%d: %v", id, i, off, err)
|
||||
return
|
||||
}
|
||||
if n != size {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want %d", id, i, n, size)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[off:off+int64(n)]) {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
|
||||
return
|
||||
}
|
||||
}
|
||||
errc <- nil
|
||||
}(g)
|
||||
}
|
||||
|
||||
for range goroutines {
|
||||
if err := <-errc; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("across chunks", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
const goroutines = 10
|
||||
const iterations = 100
|
||||
errc := make(chan error, goroutines)
|
||||
|
||||
for g := range goroutines {
|
||||
go func(id int) {
|
||||
for i := range iterations {
|
||||
// Read across chunk boundaries
|
||||
boundary := (id%2 + 1) * cs // either cs or 2*cs
|
||||
off := int64(boundary - 50 + (i % 30))
|
||||
size := 100
|
||||
if off+int64(size) > int64(plaintextSize) {
|
||||
size = plaintextSize - int(off)
|
||||
}
|
||||
if size <= 0 {
|
||||
continue
|
||||
}
|
||||
p := make([]byte, size)
|
||||
n, err := ra.ReadAt(p, off)
|
||||
if n == size && err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: off=%d size=%d: %v", id, i, off, size, err)
|
||||
return
|
||||
}
|
||||
if n != size {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want %d", id, i, n, size)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[off:off+int64(n)]) {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
|
||||
return
|
||||
}
|
||||
}
|
||||
errc <- nil
|
||||
}(g)
|
||||
}
|
||||
|
||||
for range goroutines {
|
||||
if err := <-errc; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtCorrupted(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plaintext := make([]byte, 2*cs+500)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := bytes.Clone(buf.Bytes())
|
||||
|
||||
// Corrupt final chunk - should fail in constructor
|
||||
corruptedFinal := bytes.Clone(ciphertext)
|
||||
corruptedFinal[len(corruptedFinal)-10] ^= 0xFF
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFinal), int64(len(corruptedFinal)))
|
||||
if err == nil {
|
||||
t.Error("corrupted final: expected error, got nil")
|
||||
}
|
||||
|
||||
// Corrupt first chunk - should fail on read
|
||||
corruptedFirst := bytes.Clone(ciphertext)
|
||||
corruptedFirst[10] ^= 0xFF
|
||||
ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFirst), int64(len(corruptedFirst)))
|
||||
if err != nil {
|
||||
t.Fatalf("corrupted first constructor: unexpected error: %v", err)
|
||||
}
|
||||
p := make([]byte, 100)
|
||||
_, err = ra.ReadAt(p, 0)
|
||||
if err == nil {
|
||||
t.Error("corrupted first read: expected error, got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user