diff --git a/README.md b/README.md index 95f9c91..97756ce 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ import ( | Package | Purpose | |---|---| -| [`fee`](.) (root) | Composes the primitives below into a small encrypt/decrypt API for whole objects. Adds no cryptography of its own. | -| [`aesstream`](./aesstream) | The chunked AES-256-GCM STREAM body cipher: streaming `Writer`/`Reader` plus the range-decryption API (`CiphertextRange`, `SpanReader`, `OpenSpan`). | +| [`fee`](.) (root) | Composes the primitives below into a small API: whole-object `Encrypt`/`Decrypt`, byte-range `DecryptRange`, and the cacheable envelope parameters (`BodyDescriptor`) that let a range read skip the header. Adds no cryptography of its own. | +| [`aesstream`](./aesstream) | The chunked AES-256-GCM STREAM body cipher: streaming `Writer`/`Reader` plus the range primitives (`CiphertextRange`, `SpanReader`, `OpenSpan`) that `fee.DecryptRange` is built on. | | [`cose`](./cose) | Just enough of COSE (RFC 9052): `COSE_Encrypt` (tag 96) / `COSE_Encrypt0` (tag 16) with a detached payload, and the `Enc_structure` AAD. | | [`ecdhkw`](./ecdhkw) | ECDH-ES+A256KW key wrap over X25519 (COSE algorithm −31). | | [`aeskw`](./aeskw) | RFC 3394 AES Key Wrap / A256KW (COSE algorithm −5). | @@ -90,7 +90,7 @@ func main() { kid := []byte("did:key:z6MkExample#key-1") // Encrypt. The returned reader streams envelope‖ciphertext. - r, err := fee.Encrypt( + r, _, err := fee.Encrypt( bytes.NewReader([]byte("hello, filecoin")), []fee.Recipient{fee.NewECDHESRecipient(kid, priv.PublicKey())}, ) @@ -127,7 +127,7 @@ never chooses it. ```go func encryptShared(data []byte, alicePub *ecdh.PublicKey, kek []byte) ([]byte, error) { - r, err := fee.Encrypt(bytes.NewReader(data), []fee.Recipient{ + r, _, err := fee.Encrypt(bytes.NewReader(data), []fee.Recipient{ // ECDH-ES+A256KW to Alice's X25519 public key. fee.NewECDHESRecipient([]byte("did:key:alice#key-1"), alicePub), // A256KW under a pre-shared 32-byte key-encryption key. @@ -172,7 +172,7 @@ func encryptFile(src, dst string, recipients []fee.Recipient) error { // WithContentLength is optional: it records the chunk count in the // envelope (useful to range/seek consumers) and fails the stream if the // plaintext turns out to be a different length. - r, err := fee.Encrypt(in, recipients, fee.WithContentLength(info.Size())) + r, _, err := fee.Encrypt(in, recipients, fee.WithContentLength(info.Size())) if err != nil { return err } @@ -227,7 +227,7 @@ func roundTripExternalCEK(data []byte) ([]byte, error) { return nil, err } - r, err := fee.EncryptWithCEK(bytes.NewReader(data), cek, nil) + r, _, err := fee.EncryptWithCEK(bytes.NewReader(data), cek, nil) if err != nil { return nil, err } @@ -254,73 +254,100 @@ func roundTripExternalCEK(data []byte) ([]byte, error) { ### Range (seekable) decryption Because chunks are sealed independently, any plaintext byte range can be -decrypted from a single contiguous slice of the ciphertext — one HTTP range -request against a remote blob. The root `fee` package covers whole-object -decryption only; ranges use the `cose` and `aesstream` packages directly: +decrypted without fetching or decrypting the rest of the object. +`fee.DecryptRange` takes the stored blob as an `io.ReaderAt` plus its exact size, +unwraps the CEK just as `fee.Decrypt` does, and returns a reader over exactly the +requested bytes: ```go import ( - "bytes" - "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" fee "github.com/filecoin-project/go-fee" - "github.com/filecoin-project/go-fee/aesstream" - "github.com/filecoin-project/go-fee/cose" ) -// readRange decrypts plaintext[off : off+length] from a FEE blob without -// decrypting the whole object. cek is the content-encryption key, obtained out -// of band or unwrapped from a recipient entry (see ecdhkw / aeskw). -func readRange(blob, cek []byte, off, length int64) ([]byte, error) { - const labelChunkSize = int64(-65790) - - // Decode the envelope header: base nonce, chunk size, and the - // Enc_structure that every chunk is authenticated against. - env, ciphertext, err := cose.Decode(blob, cose.WithExpectedType(fee.EnvelopeType)) +// serveRange answers an HTTP range request straight from an encrypted object. +func serveRange(w http.ResponseWriter, f *os.File, size int64, u fee.RecipientUnwrapper, off, length int64) error { + r, err := fee.DecryptRange(f, size, u, off, length) if err != nil { - return nil, err - } - baseNonce, ok := env.Headers.Unprotected.Bytes(cose.HeaderLabelIV) - if !ok { - return nil, errors.New("missing base nonce") - } - chunkSize := aesstream.DefaultChunkSize - if n, ok := env.Headers.Unprotected.Int(labelChunkSize); ok { - chunkSize = int(n) - } - aad, err := env.EncStructure(nil) - if err != nil { - return nil, err + return err // aesstream.ErrRange here means a 416 } - // Which contiguous ciphertext bytes cover the requested plaintext range? - // The third result (ignored here) is the clamped plaintext length of the - // range — available before any fetch, e.g. for an HTTP Content-Length. - start, n, _, err := aesstream.CiphertextRange(int64(len(ciphertext)), chunkSize, off, length) - if err != nil { - return nil, err + // Len is the requested length clamped to the object; Size is the whole + // object's plaintext size. Both are known before any ciphertext is read. + if r.Len() == 0 { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", r.Size())) + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return nil } + w.Header().Set("Content-Length", strconv.FormatInt(r.Len(), 10)) + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", off, off+r.Len()-1, r.Size())) + w.WriteHeader(http.StatusPartialContent) - // Fetch exactly that span. Against a remote blob this is one range - // request for bytes [headerLen+start, headerLen+start+n), where - // headerLen = len(blob) - len(ciphertext). - span := bytes.NewReader(ciphertext[start : start+n]) - - // Decrypt and trim to exactly the requested range. For large ranges, - // aesstream.NewSpanReader streams instead of buffering. - return aesstream.OpenSpan(aesstream.Config{ - Key: cek, - BaseNonce: baseNonce, - AAD: aad, - ChunkSize: chunkSize, - }, span, int64(len(ciphertext)), off, length) + _, err = io.Copy(w, r) // decrypts chunk by chunk, O(chunk size) memory + return err } ``` +Only the envelope header and the ciphertext chunks the range overlaps are read: +one small `ReadAt` at offset 0 for the header, then one `ReadAt` per overlapping +chunk as the result is read. Every chunk read is authenticated, so a tampered +chunk fails rather than yielding corrupt plaintext. + +Nothing beyond the header is fetched until the first `Read`, so a caller backed +by a remote store can prefetch the whole span in a single range request: + +```go +r, err := fee.DecryptRange(blob, size, unwrapper, off, length) +// ... +spanOff, spanLen := r.CiphertextSpan() // blob-absolute; one range request +``` + The span is chunk-aligned, so it over-fetches by at most the unused head of the -first chunk and tail of the last (under 2 × chunk size total). For a local -random-access source, wrap it with `io.NewSectionReader(src, start, n)` instead -of a fetch. +first chunk and tail of the last (under 2 × chunk size total). + +`fee.PlaintextSize` reports an object's decrypted size from the envelope header +alone — no key material, no ciphertext — which is what a `HEAD` response or a +suffix range (`bytes=-N`) needs. `fee.DecryptRangeWithCEK` is the external-CEK +counterpart of `DecryptRange`. Callers holding raw ciphertext spans rather than a +whole blob can use `aesstream.CiphertextRange` / `SpanReader` / `OpenSpan` +directly. + +### Caching the envelope parameters + +The envelope is a fixed prefix of every stored object, so a store that keeps its +own metadata beside the blob can record what a range decrypt needs from it and +skip the header read as well. `fee.Encrypt` reports those four values alongside +the reader: envelope length, base nonce, chunk size, and the `Enc_structure` AAD. +They are complete before any plaintext is read, so a writer can store them while +the upload is still streaming: + +```go +// d goes alongside the blob's location and size. +r, d, err := fee.Encrypt(plaintext, recipients) +``` + +`fee.DecryptRangeWithCEK(blob, blobSize, cek, off, length, &d)` then serves a +range with no envelope round trip at all: the only bytes fetched are the +ciphertext chunks the range overlaps. `d.PlaintextSize(blobSize)` answers a +`HEAD` or resolves a suffix range from the stored record alone, reading +nothing. + +Every field is non-secret — all four are already in the clear at the front of the +blob — and the CEK is deliberately not among them. Store the AAD rather than the +protected header it contains: the `Enc_structure`'s context string differs between +a `COSE_Encrypt` and a recipient-less `COSE_Encrypt0`, so caching the protected +header alone would need a companion flag recording which form was written. A stale +or corrupted record cannot serve wrong plaintext, since all four values are bound +into every chunk's GCM tag or decide which bytes are read; it fails with +`aesstream.ErrCorrupted` instead. The one check it gives up is `ErrSizeMismatch`: +with no envelope to consult, nothing cross-checks `blobSize` against the declared +chunk count, so a caller that stores the size should compare it with the store's +own before trusting a range. ## Wire format @@ -344,6 +371,13 @@ recipient = [ {1: alg}, {4: kid, ...}, wrappedKey ] # alg -31 or -5 - **Body AAD** — the COSE `Enc_structure` over the protected header, identical for every chunk, so the algorithm, envelope type and any protected metadata are authenticated into the ciphertext. +- **Chunking** — a producer writes `ceil(len / chunkSize)` chunks, minimum 1, + with the remainder in the final chunk; empty input is one empty chunk. A + decoder also accepts a stream that ends with an empty final chunk, so a + plaintext of exactly `k × chunkSize` bytes is a valid stream of either `k` + chunks or `k+1`. Only the ciphertext length distinguishes them + (`aesstream.ChunkCount`), which is why a declared count must never be checked + against a count re-derived from the plaintext length. - **Chunk count** (label −65791) — advisory metadata for range/seek consumers, emitted only when the plaintext length is known; not required to decrypt. - **ECDH-ES key derivation** (alg −31) — HKDF-SHA-256 (RFC 5869) over the X25519 diff --git a/aesstream/spanreader.go b/aesstream/spanreader.go index 8bff776..ca33fa4 100644 --- a/aesstream/spanreader.go +++ b/aesstream/spanreader.go @@ -102,6 +102,29 @@ func DecryptedSize(ciphertextLen int64, chunkSize int) (int64, error) { return plaintextLen, err } +// ChunkCount returns how many chunks a complete ciphertext of ciphertextLen +// bytes contains at the given chunk size. A zero chunkSize selects +// DefaultChunkSize; any other value must be in [MinChunkSize, MaxChunkSize] +// (else ErrChunkSize), the same rule as Config.ChunkSize. It returns +// ErrCiphertextSize if ciphertextLen is not a structurally valid stream length. +// +// The count is not derivable from the plaintext length, which is why this is +// worth asking for: the final chunk may be full, partial, or empty, so a +// plaintext of exactly k*chunkSize bytes is a valid stream of either k chunks +// (the last one full) or k+1 (the last one empty). Both are read the same way +// and yield the same plaintext, and only the ciphertext length tells them apart. +// A caller checking a stream against a separately recorded chunk count should +// compare against this rather than against ceil(plaintextLen/chunkSize), which +// only describes the first form. +func ChunkCount(ciphertextLen int64, chunkSize int) (int64, error) { + chunkSize, err := resolveChunkSize(chunkSize) + if err != nil { + return 0, err + } + numChunks, _, _, err := chunkLayout(ciphertextLen, chunkSize) + return numChunks, err +} + // CiphertextRange returns the single contiguous ciphertext byte range // [start, start+n) that must be read to serve the plaintext range // [off, off+length) of a stream whose complete ciphertext is @@ -241,10 +264,6 @@ func NewSpanReader(span io.Reader, cfg Config, ciphertextSize, off, length int64 if err := cfg.validate(); err != nil { return nil, err } - aead, err := newGCM(cfg.Key) - if err != nil { - return nil, err - } chunkSize := cfg.effectiveChunkSize() numChunks, lastCipherLen, plaintextLen, err := chunkLayout(ciphertextSize, chunkSize) @@ -261,6 +280,26 @@ func NewSpanReader(span io.Reader, cfg Config, ciphertextSize, off, length int64 effLen = avail } + if effLen == 0 { + r := &SpanReader{ + src: span, + chunkSize: chunkSize, + encChunk: int64(chunkSize) + TagSize, + numChunks: numChunks, + lastCipherLen: lastCipherLen, + total: 0, + remaining: 0, + err: io.EOF, + } + copy(r.base[:], cfg.BaseNonce) + return r, nil + } + + aead, err := newGCM(cfg.Key) + if err != nil { + return nil, err + } + r := &SpanReader{ aead: aead, src: span, @@ -276,14 +315,9 @@ func NewSpanReader(span io.Reader, cfg Config, ciphertextSize, off, length int64 } copy(r.base[:], cfg.BaseNonce) - if effLen > 0 { - r.nextChunk = off / int64(chunkSize) - r.skipFirst = int(off - r.nextChunk*int64(chunkSize)) - r.onFirst = true - } else { - // Nothing to emit: report EOF immediately and read no ciphertext. - r.err = io.EOF - } + r.nextChunk = off / int64(chunkSize) + r.skipFirst = int(off - r.nextChunk*int64(chunkSize)) + r.onFirst = true return r, nil } diff --git a/aesstream/spanreader_test.go b/aesstream/spanreader_test.go index ca4d0e6..2c0a744 100644 --- a/aesstream/spanreader_test.go +++ b/aesstream/spanreader_test.go @@ -44,6 +44,13 @@ func (c *countingReader) Read(p []byte) (int, error) { return n, err } +type rejectReader struct{ t *testing.T } + +func (r *rejectReader) Read(p []byte) (int, error) { + r.t.Fatalf("unexpected Read(len=%d)", len(p)) + return 0, io.EOF +} + // drainTiny reads r to EOF one byte at a time, asserting a clean EOF. func drainTiny(t *testing.T, r io.Reader) []byte { t.Helper() @@ -429,15 +436,13 @@ func TestSpanReader(t *testing.T) { ct := seal(t, cfg, pattern(100)) size := int64(len(ct)) - cr := &countingReader{r: bytes.NewReader(nil)} - r, err := aesstream.NewSpanReader(cr, cfg, size, 5, 0) + r, err := aesstream.NewSpanReader(&rejectReader{t: t}, cfg, size, 100, 100) require.NoError(t, err) require.Zero(t, r.Len(), "Len") require.Equal(t, cs, r.ChunkSize(), "ChunkSize") n, err := r.Read(make([]byte, 8)) require.Zero(t, n) require.ErrorIs(t, err, io.EOF) - require.Zero(t, cr.n, "no ciphertext read for an empty range") }) } @@ -497,3 +502,51 @@ func TestDecryptedSize_InvertsEncryptedSize(t *testing.T) { require.Equalf(t, n, got, "DecryptedSize(EncryptedSize(%d))", n) } } + +// TestChunkCount pins the exported chunk count over the boundary ciphertext +// lengths, including the two encodings of an exact-multiple plaintext: the same +// plaintext length is one chunk more when the stream ends with an empty final +// chunk, which is why callers must not re-derive the count from a plaintext size. +func TestChunkCount(t *testing.T) { + const cs = aesstream.MinChunkSize + enc := int64(cs) + aesstream.TagSize + + cases := map[string]struct { + ciphertextLen int64 + want int64 + }{ + "empty stream (one tag-only chunk)": {aesstream.TagSize, 1}, + "single partial chunk": {aesstream.TagSize + 100, 1}, + "single full chunk": {enc, 1}, + "full + empty final": {enc + aesstream.TagSize, 2}, + "full + partial final": {enc + aesstream.TagSize + 7, 2}, + "two full chunks": {2 * enc, 2}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + got, err := aesstream.ChunkCount(c.ciphertextLen, cs) + require.NoError(t, err) + require.Equal(t, c.want, got) + }) + } +} + +// TestChunkCountInvalid confirms ChunkCount reports the same errors as the rest +// of the geometry API for a length or chunk size that cannot describe a stream. +func TestChunkCountInvalid(t *testing.T) { + t.Run("ciphertext too short to hold a tag", func(t *testing.T) { + _, err := aesstream.ChunkCount(aesstream.TagSize-1, aesstream.MinChunkSize) + require.ErrorIs(t, err, aesstream.ErrCiphertextSize) + }) + + t.Run("chunk size out of range", func(t *testing.T) { + _, err := aesstream.ChunkCount(aesstream.TagSize, aesstream.MinChunkSize-1) + require.ErrorIs(t, err, aesstream.ErrChunkSize) + }) + + t.Run("zero chunk size selects the default", func(t *testing.T) { + got, err := aesstream.ChunkCount(aesstream.EncryptedSize(3*aesstream.DefaultChunkSize, 0), 0) + require.NoError(t, err) + require.Equal(t, int64(3), got) + }) +} diff --git a/cose/cose.go b/cose/cose.go index ed602a1..b6e5452 100644 --- a/cose/cose.go +++ b/cose/cose.go @@ -110,6 +110,11 @@ var ( // ErrMalformed means the CBOR could not be parsed into a well-formed // COSE_Encrypt or COSE_Encrypt0 structure. A decode that returns ErrMalformed // returns no partial structure. + // + // When the input ran out before the leading item was complete, the error also + // wraps io.ErrUnexpectedEOF. A caller decoding a prefix of a larger object can + // therefore tell "read more bytes" from "these bytes are complete and wrong"; + // no other decode failure carries that signal. ErrMalformed = errors.New("cose: malformed COSE envelope") // ErrDetachedPayload means the body ciphertext field was not CBOR null; // this package only handles detached payloads. diff --git a/cose/decode.go b/cose/decode.go index ef2f192..e8d06d2 100644 --- a/cose/decode.go +++ b/cose/decode.go @@ -2,6 +2,7 @@ package cose import ( "bytes" + "errors" "fmt" "io" @@ -68,7 +69,7 @@ func Decode(data []byte, opts ...DecodeOption) (env *Envelope, rest []byte, err dec := decMode.NewDecoder(bytes.NewReader(data)) var first cbor.RawMessage if err := dec.Decode(&first); err != nil { - return nil, nil, fmt.Errorf("%w: %v", ErrMalformed, err) + return nil, nil, malformedItem(err) } rest = data[dec.NumBytesRead():] @@ -86,6 +87,21 @@ func Decode(data []byte, opts ...DecodeOption) (env *Envelope, rest []byte, err return env, rest, nil } +// malformedItem reports a failure to read the single leading CBOR item as +// ErrMalformed, additionally wrapping io.ErrUnexpectedEOF when the input simply +// ran out mid-item (or held no item at all). +// +// That distinction is what lets a caller decoding a prefix of a larger object +// tell "give me more bytes" from "these bytes are complete and wrong" — fee's +// envelope-header probe grows its read only for the former. Every other decode +// failure is a final answer no amount of extra input can change. +func malformedItem(err error) error { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return fmt.Errorf("%w: %w", ErrMalformed, io.ErrUnexpectedEOF) + } + return fmt.Errorf("%w: %v", ErrMalformed, err) +} + // decodeTagArray unmarshals one already-read CBOR item into the tag number and // element array it wraps: the item must be a tag whose content is an array. It // is the shared preamble of [Decode] and [DecodeReader] — which differ only in @@ -180,7 +196,7 @@ func DecodeReader(r io.Reader, opts ...DecodeOption) (env *Envelope, rest io.Rea dec := decMode.NewDecoder(r) var first cbor.RawMessage if err := dec.Decode(&first); err != nil { - return nil, nil, fmt.Errorf("%w: %v", ErrMalformed, err) + return nil, nil, malformedItem(err) } rest = io.MultiReader(dec.Buffered(), r) diff --git a/cose/decode_test.go b/cose/decode_test.go index 2831a4e..3feb406 100644 --- a/cose/decode_test.go +++ b/cose/decode_test.go @@ -1,6 +1,7 @@ package cose import ( + "io" "testing" "github.com/stretchr/testify/require" @@ -47,6 +48,40 @@ func TestDecodeMalformed(t *testing.T) { } } +// TestDecodeTruncatedIsUnexpectedEOF pins that a decode failing only because the +// input stopped mid-item is distinguishable from one whose bytes are complete and +// wrong. A caller reading a prefix of a larger object (see fee's envelope-header +// probe) grows its read only for the former; without this a complete-but-invalid +// item would be re-read at ever larger sizes to no purpose. +func TestDecodeTruncatedIsUnexpectedEOF(t *testing.T) { + truncated := map[string]string{ + "empty input": "", + "truncated array": "d86084", + "truncated mid-item": "d8608443a101", + } + for name, h := range truncated { + t.Run(name, func(t *testing.T) { + _, _, err := Decode(hexDec(t, h)) + require.ErrorIs(t, err, io.ErrUnexpectedEOF) + }) + } + + // Complete items that are simply not what we accept must not look truncated, + // however short they are. + complete := map[string]string{ + "bare integer, not a tag": "01", + "tag 96 array too short": "d8608340a0f6", + "duplicate protected label": "d8608445a201030104a0f6818340a041aa", + "body ciphertext not null": "d8608440a041ff818340a041aa", + } + for name, h := range complete { + t.Run(name, func(t *testing.T) { + _, _, err := Decode(hexDec(t, h)) + require.NotErrorIs(t, err, io.ErrUnexpectedEOF) + }) + } +} + func removeSpaces(s string) string { out := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { diff --git a/descriptor.go b/descriptor.go new file mode 100644 index 0000000..2d79eb5 --- /dev/null +++ b/descriptor.go @@ -0,0 +1,142 @@ +package fee + +import ( + "bytes" + "errors" + "fmt" + + "github.com/filecoin-project/go-fee/aesstream" +) + +// ErrInvalidDescriptor means a [BodyDescriptor] is missing a field, or carries +// one that cannot describe a FEE body — a value that could not decrypt anything. +var ErrInvalidDescriptor = errors.New("fee: invalid body descriptor") + +// BodyDescriptor is everything a range decrypt needs from a FEE envelope, so a +// caller that cached it can serve a byte range without fetching or decoding the +// envelope header at all. It is returned by [Encrypt] / [EncryptWithCEK] at +// encryption time and consumed by [DecryptRangeWithCEK] as its desc argument. +// +// It exists for stores that keep their own metadata alongside the blob: the +// envelope is a fixed prefix of every stored object, so re-reading it on each +// range request costs a round trip to the object store for bytes that never +// change. Persisting these four values next to the blob's location removes that +// round trip; the only per-request I/O left is the ciphertext the range overlaps. +// +// Every field is non-secret — all four are already in the clear at the front of +// the stored blob — so caching them discloses nothing that fetching the blob's +// first bytes would not. The content-encryption key is deliberately not part of +// it; how the CEK is stored and recovered is the caller's concern. +// +// A cached copy cannot silently serve wrong data. BaseNonce and AAD are bound +// into every chunk's AES-GCM tag, and HeaderLen and ChunkSize determine which +// bytes are read and under which nonce, so a stale or corrupted value surfaces +// as an authentication failure ([aesstream.ErrCorrupted]) rather than as +// plausible-looking plaintext. The worst case is an unreadable object, not an +// incorrect one. +// +// The zero value is not usable; see [BodyDescriptor.Validate]. +type BodyDescriptor struct { + // HeaderLen is the encoded length of the envelope, and so the offset within + // the blob at which the detached ciphertext begins. + HeaderLen int64 + + // BaseNonce is the envelope's iv header: the STREAM base nonce every chunk + // nonce is derived from. It is [aesstream.BaseNonceSize] bytes. + BaseNonce []byte + + // ChunkSize is the STREAM plaintext chunk size, in bytes. + ChunkSize int + + // AAD is the envelope's Enc_structure — the additional authenticated data + // bound into every chunk. It is cached whole rather than rebuilt from the + // protected header because the Enc_structure's context string differs + // between a COSE_Encrypt and a recipient-less COSE_Encrypt0, a distinction + // this value has no other way to record. Caching the finished bytes keeps + // BodyDescriptor identical for both envelope forms. The protected header + // remains recoverable from it: it is the structure's second element. + AAD []byte +} + +// Validate reports whether m carries a complete, in-range description of a FEE +// body. It is the all-or-nothing check a store should apply before persisting +// material — a partially populated record would produce a row that no later +// range read could use. +// +// [DecryptRangeWithCEK] calls it when desc is non-nil, so a bad value fails +// there with [ErrInvalidDescriptor] rather than as an authentication error +// further down. +func (m BodyDescriptor) Validate() error { + if m.HeaderLen <= 0 { + return fmt.Errorf("%w: header length %d is not positive", ErrInvalidDescriptor, m.HeaderLen) + } + if len(m.BaseNonce) != aesstream.BaseNonceSize { + return fmt.Errorf("%w: base nonce is %d bytes, want %d", + ErrInvalidDescriptor, len(m.BaseNonce), aesstream.BaseNonceSize) + } + if m.ChunkSize < aesstream.MinChunkSize || m.ChunkSize > aesstream.MaxChunkSize { + return fmt.Errorf("%w: chunk size %d out of range [%d, %d]", + ErrInvalidDescriptor, m.ChunkSize, aesstream.MinChunkSize, aesstream.MaxChunkSize) + } + if len(m.AAD) == 0 { + return fmt.Errorf("%w: missing AAD", ErrInvalidDescriptor) + } + return nil +} + +// PlaintextSize reports the total decrypted size of a blob of blobSize bytes +// described by m. It reads nothing and needs no key material, so it answers a +// HEAD request, fills in the total of a Content-Range header, or resolves a +// suffix range ("bytes=-N" is off = size-N) from cached metadata alone. +// +// blobSize is the whole stored object, envelope included, exactly as passed to +// [DecryptRangeWithCEK] when desc is non-nil. It reports [ErrInvalidDescriptor] +// for an unusable m, and [aesstream.ErrCiphertextSize] if blobSize cannot +// describe a FEE blob at this header length and chunk size. +func (m BodyDescriptor) PlaintextSize(blobSize int64) (int64, error) { + if err := m.Validate(); err != nil { + return 0, err + } + return plaintextSizeFrom(blobSize, m.HeaderLen, m.ChunkSize) +} + +// plaintextSizeFrom derives the total plaintext size of a blobSize-byte blob +// whose envelope occupies headerLen bytes and whose STREAM chunks carry chunkSize +// plaintext bytes each. +// +// It is shared by [BodyDescriptor.PlaintextSize] and the envelope-backed paths (via +// envelopePlaintextSize), so a blob size that cannot describe a FEE body is reported +// the same way whether the parameters came from a cache or from the envelope. +func plaintextSizeFrom(blobSize, headerLen int64, chunkSize int) (int64, error) { + ciphertextSize := blobSize - headerLen + if ciphertextSize < 0 { + return 0, fmt.Errorf("fee: blob size %d is shorter than its %d-byte envelope: %w", + blobSize, headerLen, aesstream.ErrCiphertextSize) + } + n, err := aesstream.DecryptedSize(ciphertextSize, chunkSize) + if err != nil { + return 0, fmt.Errorf("fee: blob of %d ciphertext bytes: %w", ciphertextSize, err) + } + return n, nil +} + +// bodyParams returns the envelope body parameters m describes, for the range +// wiring it shares with the envelope-backed paths. HeaderLen is not among them: +// it says where the ciphertext starts, not how to decrypt it. +func (m BodyDescriptor) bodyParams() bodyParams { + return bodyParams{baseNonce: m.BaseNonce, chunkSize: m.ChunkSize} +} + +// aad returns the cached Enc_structure bytes m carries for body decryption. +func (m BodyDescriptor) aad() []byte { + return m.AAD +} + +// clone returns a deep copy, so a BodyDescriptor handed to a caller shares no +// backing array with the envelope it came from (and one handed back to us cannot +// be mutated underneath a live reader). +func (m BodyDescriptor) clone() BodyDescriptor { + m.BaseNonce = bytes.Clone(m.BaseNonce) + m.AAD = bytes.Clone(m.AAD) + return m +} diff --git a/descriptor_test.go b/descriptor_test.go new file mode 100644 index 0000000..aa60ebc --- /dev/null +++ b/descriptor_test.go @@ -0,0 +1,376 @@ +package fee_test + +import ( + "bytes" + "io" + "math" + "testing" + + "github.com/filecoin-project/go-fee" + "github.com/filecoin-project/go-fee/aeskw" + "github.com/filecoin-project/go-fee/aesstream" + "github.com/filecoin-project/go-fee/cose" + "github.com/stretchr/testify/require" +) + +// blobLocationRow is the shape a metadata store persists per encrypted blob, +// modelled on the consumer this API exists for: the four BodyDescriptor columns, +// the key-management columns that let the row's CEK be recovered, and the blob's +// size and location. +// +// The read half of every test below reads *only* from a value of this type. That +// is the point of the exercise: if the range path needed anything a store could +// not persist, these tests would not compile. +type blobLocationRow struct { + // Key management: the CEK wrapped under a store-held key, never the CEK. + regionWrappedCEK []byte + regionKeyVersion string + tenantRecipientKID string + + // Body descriptor. chunkSize is int64 rather than int because a SQL bigint is + // what a store round-trips, so the conversion is part of what is tested. + headerLen int64 + baseNonce []byte + chunkSize int64 + aad []byte + + // Location. + size int64 +} + +// descriptor rebuilds the BodyDescriptor from the persisted columns, as a reader +// would after loading the row. +func (r blobLocationRow) descriptor() fee.BodyDescriptor { + return fee.BodyDescriptor{ + HeaderLen: r.headerLen, + BaseNonce: r.baseNonce, + ChunkSize: int(r.chunkSize), + AAD: r.aad, + } +} + +// encryptWithDescriptor seals plaintext under cek and returns the wire blob +// together with the descriptor captured from the encrypt call — the write half of +// the store flow. +func encryptWithDescriptor(t *testing.T, plaintext, cek []byte, recipients []fee.Recipient, opts ...fee.EncryptOption) ([]byte, fee.BodyDescriptor) { + t.Helper() + // The descriptor arrives before a single byte is read, which is what lets a + // writer record the row while the upload is still streaming. + enc, desc, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, recipients, opts...) + require.NoError(t, err) + blob, err := io.ReadAll(enc) + require.NoError(t, err) + require.NoError(t, enc.Close()) + return blob, desc +} + +// requireNoEnvelopeRead asserts that nothing below headerLen was fetched: the +// whole purpose of caching the descriptor is that the envelope is never read again. +func requireNoEnvelopeRead(t *testing.T, r *recordingReaderAt, headerLen int64) { + t.Helper() + for _, rd := range r.reads { + require.GreaterOrEqualf(t, rd.off, headerLen, + "read at offset %d (%d bytes) fell inside the %d-byte envelope; the cached descriptor was not used", + rd.off, rd.n, headerLen) + } +} + +// TestIngotWriteReadFlow is the acceptance criterion for the whole feature: a +// writer encrypts an object and persists what a store column can hold, and a +// later reader serves arbitrary byte ranges from those columns alone — fetching +// ciphertext only, never the envelope. +func TestIngotWriteReadFlow(t *testing.T) { + const size = 4*rangeChunk + 123 // a partial final chunk + + // --- write path ------------------------------------------------------- + tenantKey := newX25519Key(t) + regionKEK := newKEK(t) + plaintext := patternBytes(size) + + cek := newCEK(t) + regionWrapped, err := aeskw.Wrap(regionKEK, cek) + require.NoError(t, err) + + blob, desc := encryptWithDescriptor(t, plaintext, cek, + []fee.Recipient{fee.NewECDHESRecipient(ecdhKID, tenantKey.PublicKey())}, + fee.WithChunkSize(rangeChunk), fee.WithContentLength(size)) + + // The writer's copy of the CEK is done with; only the wrapped form persists. + clear(cek) + + row := blobLocationRow{ + regionWrappedCEK: regionWrapped, + regionKeyVersion: "region-key-v1", + tenantRecipientKID: string(ecdhKID), + headerLen: desc.HeaderLen, + baseNonce: desc.BaseNonce, + chunkSize: int64(desc.ChunkSize), + aad: desc.AAD, + size: int64(len(blob)), + } + + t.Run("descriptor describes the stored bytes", func(t *testing.T) { + // Cross-check against the envelope actually written, so an extraction bug + // on the encrypt side cannot hide behind a self-consistent round trip. + env, rest, err := cose.Decode(blob, cose.WithExpectedType(fee.EnvelopeType)) + require.NoError(t, err) + iv, ok := env.Headers.Unprotected.Bytes(cose.HeaderLabelIV) + require.True(t, ok) + aad, err := env.EncStructure(nil) + require.NoError(t, err) + + // One comparison over the whole value, so a field added to BodyDescriptor + // cannot go unchecked here. + require.Equal(t, fee.BodyDescriptor{ + HeaderLen: int64(len(blob) - len(rest)), + BaseNonce: iv, + ChunkSize: rangeChunk, + AAD: aad, + }, desc) + }) + + t.Run("plaintext size from the row alone", func(t *testing.T) { + got, err := row.descriptor().PlaintextSize(row.size) + require.NoError(t, err) + require.Equal(t, int64(size), got) + }) + + // --- read path -------------------------------------------------------- + for name, tc := range map[string]struct{ off, length int64 }{ + "whole object": {0, size}, + "inside one chunk": {100, 50}, + "aligned chunk": {rangeChunk, rangeChunk}, + "crosses a boundary": {rangeChunk - 10, 20}, + "partial final chunk": {4 * rangeChunk, 123}, + "open-ended suffix": {size - 50, math.MaxInt64}, + "single byte at start": {0, 1}, + "single byte at end": {size - 1, 1}, + "empty range at eof": {size, 0}, + } { + t.Run(name, func(t *testing.T) { + // Everything from here reads the row, never desc or the envelope. + cek, err := aeskw.Unwrap(regionKEK, row.regionWrappedCEK) + require.NoError(t, err) + defer clear(cek) + + recording := newRecordingReaderAt(t, blob) + desc := row.descriptor() + r, err := fee.DecryptRangeWithCEK(recording, row.size, cek, tc.off, tc.length, &desc) + require.NoError(t, err) + + want := plaintext[tc.off : tc.off+clampLen(size, tc.off, tc.length)] + require.Equal(t, int64(len(want)), r.Len(), "Len must be known before reading") + require.Equal(t, int64(size), r.Size()) + + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, want, got) + + requireNoEnvelopeRead(t, recording, row.headerLen) + }) + } +} + +// TestEncryptDescriptorDoesNotAliasTheStream pins that the descriptor handed back +// shares no backing array with the encryption still in flight: a caller that +// adjusts its copy — or a store that reuses the buffers it read a row into — +// cannot disturb the blob being produced. +func TestEncryptDescriptorDoesNotAliasTheStream(t *testing.T) { + cek := newCEK(t) + plaintext := patternBytes(2 * rangeChunk) + + rc, desc, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, nil, + fee.WithChunkSize(rangeChunk)) + require.NoError(t, err) + defer rc.Close() + + // Scribble on the caller's copy before a single byte is read, keeping the + // values a store would have persisted. + kept := fee.BodyDescriptor{ + HeaderLen: desc.HeaderLen, + BaseNonce: bytes.Clone(desc.BaseNonce), + ChunkSize: desc.ChunkSize, + AAD: bytes.Clone(desc.AAD), + } + desc.BaseNonce[0] ^= 0xff + desc.AAD[0] ^= 0xff + + blob, err := io.ReadAll(rc) + require.NoError(t, err) + + // The blob still decrypts under the pristine values, so the mutation never + // reached the cipher or the encoded header. + r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), int64(len(blob)), cek, 0, int64(len(plaintext)), &kept) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, plaintext, got) +} + +// TestDecryptRangeWithCEKAndBodyDescriptorEncrypt0 pins that the descriptor is +// envelope-form agnostic: a recipient-less COSE_Encrypt0 yields a usable +// descriptor with no flag and no special case, which is what lets +// BodyDescriptor cache the finished AAD rather than a protected header plus a +// context discriminator. +func TestDecryptRangeWithCEKAndBodyDescriptorEncrypt0(t *testing.T) { + const size = 2 * rangeChunk + plaintext := patternBytes(size) + cek := newCEK(t) + + blob, desc := encryptWithDescriptor(t, plaintext, cek, nil, + fee.WithChunkSize(rangeChunk), fee.WithContentLength(size)) + + // It really is the recipient-less form. + tag, err := cose.PeekTag(blob) + require.NoError(t, err) + require.Equal(t, cose.TagCOSEEncrypt0, tag) + + recording := newRecordingReaderAt(t, blob) + r, err := fee.DecryptRangeWithCEK(recording, int64(len(blob)), cek, 10, 4000, &desc) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, plaintext[10:4010], got) + requireNoEnvelopeRead(t, recording, desc.HeaderLen) +} + +// TestDecryptRangeWithCEKAndBodyDescriptorMatchesEnvelopePath asserts the cached +// path and the envelope path are interchangeable — same bytes out for the same +// request, so caching is an optimisation and not a second behaviour to reason +// about. +func TestDecryptRangeWithCEKAndBodyDescriptorMatchesEnvelopePath(t *testing.T) { + const size = 3*rangeChunk + 7 + tenantKey := newX25519Key(t) + plaintext := patternBytes(size) + cek := newCEK(t) + + blob, desc := encryptWithDescriptor(t, plaintext, cek, + []fee.Recipient{fee.NewECDHESRecipient(ecdhKID, tenantKey.PublicKey())}, + fee.WithChunkSize(rangeChunk), fee.WithContentLength(size)) + unwrapper := fee.NewECDHESUnwrapper(ecdhKID, tenantKey) + + for _, off := range []int64{0, 1, rangeChunk - 1, rangeChunk, 2 * rangeChunk, size - 7} { + _, viaEnvelope := decryptRange(t, blob, unwrapper, off, 500) + + r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), int64(len(blob)), cek, off, 500, &desc) + require.NoError(t, err) + viaDescriptor, err := io.ReadAll(r) + require.NoError(t, err) + + require.Equalf(t, viaEnvelope, viaDescriptor, "paths disagree at off=%d", off) + } +} + +// TestBodyDescriptorValidate covers the all-or-nothing rule a store mirrors before +// persisting a row: a partial record is refused rather than written and +// discovered unusable on some later read. +func TestBodyDescriptorValidate(t *testing.T) { + good := fee.BodyDescriptor{ + HeaderLen: 128, + BaseNonce: make([]byte, aesstream.BaseNonceSize), + ChunkSize: rangeChunk, + AAD: []byte("enc-structure"), + } + require.NoError(t, good.Validate()) + + for name, mutate := range map[string]func(*fee.BodyDescriptor){ + "zero value": func(m *fee.BodyDescriptor) { *m = fee.BodyDescriptor{} }, + "no header length": func(m *fee.BodyDescriptor) { m.HeaderLen = 0 }, + "negative header": func(m *fee.BodyDescriptor) { m.HeaderLen = -1 }, + "no base nonce": func(m *fee.BodyDescriptor) { m.BaseNonce = nil }, + "short base nonce": func(m *fee.BodyDescriptor) { m.BaseNonce = make([]byte, 3) }, + "no chunk size": func(m *fee.BodyDescriptor) { m.ChunkSize = 0 }, + "chunk size tiny": func(m *fee.BodyDescriptor) { m.ChunkSize = aesstream.MinChunkSize - 1 }, + "chunk size huge": func(m *fee.BodyDescriptor) { m.ChunkSize = aesstream.MaxChunkSize + 1 }, + "no aad": func(m *fee.BodyDescriptor) { m.AAD = nil }, + "empty (not nil) aad": func(m *fee.BodyDescriptor) { m.AAD = []byte{} }, + } { + t.Run(name, func(t *testing.T) { + m := good + mutate(&m) + require.ErrorIs(t, m.Validate(), fee.ErrInvalidDescriptor) + + // The range entry point rejects it up front for the same reason, + // rather than letting it fail as an authentication error later. + _, err := fee.DecryptRangeWithCEK(bytes.NewReader([]byte("blob")), 4096, + make([]byte, aesstream.KeySize), 0, 10, &m) + require.ErrorIs(t, err, fee.ErrInvalidDescriptor) + }) + } +} + +// TestDecryptRangeWithCEKAndBodyDescriptorPoisoned is the safety property that +// makes caching this descriptor acceptable: a row that has drifted from the +// bytes on disk fails loudly. Because BaseNonce and the AAD are bound into every +// chunk's GCM tag and HeaderLen decides which bytes are read at all, a wrong +// value can only produce an unreadable object — never plausible but incorrect +// plaintext. +func TestDecryptRangeWithCEKAndBodyDescriptorPoisoned(t *testing.T) { + const size = 3 * rangeChunk + plaintext := patternBytes(size) + cek := newCEK(t) + blob, desc := encryptWithDescriptor(t, plaintext, cek, nil, + fee.WithChunkSize(rangeChunk), fee.WithContentLength(size)) + + for name, mutate := range map[string]func(m *fee.BodyDescriptor){ + "header length off by one": func(m *fee.BodyDescriptor) { m.HeaderLen++ }, + "wrong base nonce": func(m *fee.BodyDescriptor) { + m.BaseNonce = bytes.Clone(m.BaseNonce) + m.BaseNonce[0] ^= 0xff + }, + "tampered aad": func(m *fee.BodyDescriptor) { + m.AAD = bytes.Clone(m.AAD) + m.AAD[len(m.AAD)-1] ^= 0xff + }, + "wrong chunk size": func(m *fee.BodyDescriptor) { m.ChunkSize = rangeChunk * 2 }, + } { + t.Run(name, func(t *testing.T) { + poisoned := desc + mutate(&poisoned) + + r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), int64(len(blob)), cek, 0, 200, &poisoned) + if err != nil { + return // rejected at construction, which is a fine outcome + } + got, err := io.ReadAll(r) + require.Error(t, err, "a poisoned row must not decrypt") + require.NotEqual(t, plaintext[:200], got) + }) + } +} + +// TestDecryptRangeWithCEKAndBodyDescriptorInvalidArgs covers the argument checks +// that do not depend on the descriptor being right. +func TestDecryptRangeWithCEKAndBodyDescriptorInvalidArgs(t *testing.T) { + const size = 2 * rangeChunk + plaintext := patternBytes(size) + cek := newCEK(t) + blob, desc := encryptWithDescriptor(t, plaintext, cek, nil, + fee.WithChunkSize(rangeChunk), fee.WithContentLength(size)) + blobSize := int64(len(blob)) + + t.Run("short cek", func(t *testing.T) { + _, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), blobSize, make([]byte, 16), 0, 10, &desc) + require.ErrorIs(t, err, fee.ErrInvalidCEK) + }) + + t.Run("nil blob", func(t *testing.T) { + _, err := fee.DecryptRangeWithCEK(nil, blobSize, cek, 0, 10, &desc) + require.Error(t, err) + }) + + t.Run("blob shorter than its envelope", func(t *testing.T) { + _, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), desc.HeaderLen-1, cek, 0, 10, &desc) + require.ErrorIs(t, err, aesstream.ErrCiphertextSize) + }) + + t.Run("offset past the end", func(t *testing.T) { + _, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), blobSize, cek, size+1, 10, &desc) + require.ErrorIs(t, err, aesstream.ErrRange) + }) + + t.Run("negative offset", func(t *testing.T) { + _, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), blobSize, cek, -1, 10, &desc) + require.ErrorIs(t, err, aesstream.ErrRange) + }) +} diff --git a/example_descriptor_test.go b/example_descriptor_test.go new file mode 100644 index 0000000..98b5132 --- /dev/null +++ b/example_descriptor_test.go @@ -0,0 +1,99 @@ +package fee_test + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "fmt" + "io" + "log" + + "github.com/filecoin-project/go-fee" + "github.com/filecoin-project/go-fee/aesstream" +) + +// countingBlob is an io.ReaderAt that reports how many bytes were served from +// inside the envelope — the round trip a caching caller is trying to avoid. +type countingBlob struct { + blob *bytes.Reader + headerLen int64 + envelope int64 // bytes served from below headerLen +} + +func (c *countingBlob) ReadAt(p []byte, off int64) (int, error) { + n, err := c.blob.ReadAt(p, off) + if off < c.headerLen { + c.envelope += min(int64(n), c.headerLen-off) + } + return n, err +} + +// ExampleDecryptRangeWithCEK_withBodyDescriptor stores an object once and then +// serves a byte range of it without re-reading the envelope, the way a store +// that keeps metadata beside its blobs would: the writer records what +// [EncryptWithCEK] reports, and the reader rebuilds a decryptor from those +// columns alone. +func ExampleDecryptRangeWithCEK_withBodyDescriptor() { + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + log.Fatal(err) + } + kid := []byte("did:key:zExampleRecipient#key-1") + + // The caller draws the CEK so it can wrap it under its own key-encryption + // key; only the wrapped form is stored (omitted here for brevity). + cek := make([]byte, aesstream.KeySize) + if _, err := rand.Read(cek); err != nil { + log.Fatal(err) + } + + // The descriptor is complete before a byte is read, so a writer can record it + // while the upload is still streaming. + plaintext := []byte("the quick brown fox jumps over the lazy dog") + enc, descriptor, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, + []fee.Recipient{fee.NewECDHESRecipient(kid, priv.PublicKey())}, + fee.WithContentLength(int64(len(plaintext)))) + if err != nil { + log.Fatal(err) + } + + blob, err := io.ReadAll(enc) + if err != nil { + log.Fatal(err) + } + if err := enc.Close(); err != nil { + log.Fatal(err) + } + + // What a store persists alongside the blob's location: the descriptor, plus + // the blob's exact size. + row := struct { + descriptor fee.BodyDescriptor + blobSize int64 + }{descriptor, int64(len(blob))} + + // Serving a range later. Nothing here decodes the envelope — the reader is + // built from the stored row, so the only bytes fetched are ciphertext. + src := &countingBlob{blob: bytes.NewReader(blob), headerLen: row.descriptor.HeaderLen} + const off, length = 4, 15 + r, err := fee.DecryptRangeWithCEK(src, row.blobSize, cek, off, length, &row.descriptor) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Content-Length: %d\n", r.Len()) + fmt.Printf("Content-Range: bytes %d-%d/%d\n", off, off+r.Len()-1, r.Size()) + + got, err := io.ReadAll(r) + if err != nil { + log.Fatal(err) + } + fmt.Printf("range: %q\n", got) + fmt.Printf("envelope bytes fetched: %d\n", src.envelope) + + // Output: + // Content-Length: 15 + // Content-Range: bytes 4-18/43 + // range: "quick brown fox" + // envelope bytes fetched: 0 +} diff --git a/example_range_test.go b/example_range_test.go new file mode 100644 index 0000000..49239a9 --- /dev/null +++ b/example_range_test.go @@ -0,0 +1,62 @@ +package fee_test + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "fmt" + "io" + "log" + + "github.com/filecoin-project/go-fee" +) + +// ExampleDecryptRange serves one byte range of an encrypted object the way an +// HTTP handler would: it derives the response's Content-Length and Content-Range +// from the reader before any ciphertext is fetched, then streams the range out. +func ExampleDecryptRange() { + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + log.Fatal(err) + } + kid := []byte("did:key:zExampleRecipient#key-1") + + plaintext := []byte("the quick brown fox jumps over the lazy dog") + enc, _, err := fee.Encrypt(bytes.NewReader(plaintext), + []fee.Recipient{fee.NewECDHESRecipient(kid, priv.PublicKey())}, + fee.WithContentLength(int64(len(plaintext)))) + if err != nil { + log.Fatal(err) + } + blob, err := io.ReadAll(enc) + if err != nil { + log.Fatal(err) + } + if err := enc.Close(); err != nil { + log.Fatal(err) + } + + // A store hands over random access to the blob and its exact size; only the + // envelope header and the chunks the range overlaps are ever read. + const off, length = 4, 15 + r, err := fee.DecryptRange(bytes.NewReader(blob), int64(len(blob)), + fee.NewECDHESUnwrapper(kid, priv), off, length) + if err != nil { + log.Fatal(err) + } + + // Both are known up front, so a handler can write its headers before + // decrypting a single chunk. + fmt.Printf("Content-Length: %d\n", r.Len()) + fmt.Printf("Content-Range: bytes %d-%d/%d\n", off, off+r.Len()-1, r.Size()) + got, err := io.ReadAll(r) + if err != nil { + log.Fatal(err) + } + fmt.Printf("range: %q\n", got) + + // Output: + // Content-Length: 15 + // Content-Range: bytes 4-18/43 + // range: "quick brown fox" +} diff --git a/fee.go b/fee.go index b07c6b2..5ff878d 100644 --- a/fee.go +++ b/fee.go @@ -62,12 +62,26 @@ // detached ciphertext from its source on demand. Neither buffers the whole // object. // +// # Byte ranges +// +// [DecryptRange] serves one plaintext byte range of a stored blob without +// fetching or decrypting the rest of it: it decodes the envelope header, recovers +// the CEK exactly as [Decrypt] does, and reads only the ciphertext chunks the +// range overlaps. [DecryptRangeWithCEK] is its external-CEK counterpart, and +// [PlaintextSize] answers an object's decrypted size from the header alone, with +// no key material. Callers that hold raw ciphertext spans rather than a blob can +// use the underlying primitives in fee/aesstream directly. +// +// A store that keeps metadata beside its blobs can drop the header read too. +// [Encrypt] reports the envelope parameters a range decrypt needs as a +// [BodyDescriptor]; persisting those and passing them to +// [DecryptRangeWithCEK] as its desc argument serves a range with no envelope +// round trip at all, and [BodyDescriptor.PlaintextSize] answers a HEAD from +// the same record. +// // # Scope // -// This package covers full-object encrypt/decrypt only. Range-based decryption -// is a separate primitive in fee/aesstream, keyed off the ciphertext length and -// chunk size rather than the envelope's chunk count; a higher-level range API is -// tracked separately. This package adds no cryptography of its own. +// This package sequences the primitives and adds no cryptography of its own. package fee import ( @@ -199,13 +213,19 @@ func WithContentLength(n int64) EncryptOption { // Encryption runs in a background goroutine that feeds the returned reader, so a // caller MUST either read it to EOF or Close it: Close aborts the goroutine. An // encryption failure surfaces as a non-EOF error from the reader's Read. -func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, error) { +// +// The second result is the envelope parameters a later range decrypt needs, for a +// caller that wants to cache them rather than re-read the header; see +// [BodyDescriptor]. It is complete on return, before any plaintext is read, so a +// writer can record it while the upload is still streaming. Callers with no use +// for it discard it. +func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, BodyDescriptor, error) { if len(recipients) == 0 { - return nil, ErrNoRecipients + return nil, BodyDescriptor{}, ErrNoRecipients } cek := make([]byte, aesstream.KeySize) if _, err := rand.Read(cek); err != nil { - return nil, fmt.Errorf("fee: generating content-encryption key: %w", err) + return nil, BodyDescriptor{}, fmt.Errorf("fee: generating content-encryption key: %w", err) } // encryptStream copies the CEK into the body cipher and wraps it to the // recipients (synchronously, before it returns), so our generated copy can be @@ -237,9 +257,13 @@ func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption) // // The caller retains ownership of cek: it is copied into the body cipher (and // wrapped to any recipients) but neither retained nor wiped by this call. -func EncryptWithCEK(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, error) { - if len(cek) != aesstream.KeySize { - return nil, fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek)) +// +// As with [Encrypt], the second result carries the envelope parameters a later +// range decrypt needs; see [BodyDescriptor]. They are reported for a +// recipient-less COSE_Encrypt0 exactly as for a COSE_Encrypt. +func EncryptWithCEK(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, BodyDescriptor, error) { + if err := checkCEK(cek); err != nil { + return nil, BodyDescriptor{}, err } return encryptStream(plaintext, cek, recipients, opts...) } @@ -255,16 +279,16 @@ func EncryptWithCEK(plaintext io.Reader, cek []byte, recipients []Recipient, opt // as it returns — even though the returned reader has not been read and its // background encryption goroutine is still running. That goroutine works from // the writer's internalized key, never from the cek slice. -func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, error) { +func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, BodyDescriptor, error) { if plaintext == nil { - return nil, errors.New("fee: nil plaintext reader") + return nil, BodyDescriptor{}, errors.New("fee: nil plaintext reader") } for i, r := range recipients { if r == nil { - return nil, fmt.Errorf("fee: recipient %d is nil", i) + return nil, BodyDescriptor{}, fmt.Errorf("fee: recipient %d is nil", i) } if err := r.validate(); err != nil { - return nil, fmt.Errorf("fee: recipient %d: %w", i, err) + return nil, BodyDescriptor{}, fmt.Errorf("fee: recipient %d: %w", i, err) } } @@ -280,12 +304,12 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts // path — no envelope exists yet — so it is not ErrMalformedEnvelope (a // decode-side classification). Surface aesstream.ErrChunkSize, the same // sentinel aesstream.NewWriter would return for this size. - return nil, fmt.Errorf("fee: chunk size %d: %w", cfg.chunkSize, aesstream.ErrChunkSize) + return nil, BodyDescriptor{}, fmt.Errorf("fee: chunk size %d: %w", cfg.chunkSize, aesstream.ErrChunkSize) } baseNonce, err := aesstream.NewBaseNonce() if err != nil { - return nil, fmt.Errorf("fee: generating base nonce: %w", err) + return nil, BodyDescriptor{}, fmt.Errorf("fee: generating base nonce: %w", err) } // The body header is fixed before encryption: the algorithm and envelope @@ -313,7 +337,7 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts for i, r := range recipients { entry, werr := r.wrap(cek) if werr != nil { - return nil, werr + return nil, BodyDescriptor{}, werr } entries[i] = entry } @@ -326,11 +350,11 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts env := &cose.Envelope{Headers: headers, Recipients: entries} aad, err := env.EncStructure(nil) if err != nil { - return nil, fmt.Errorf("fee: building envelope AAD: %w", err) + return nil, BodyDescriptor{}, fmt.Errorf("fee: building envelope AAD: %w", err) } header, err := env.Encode() if err != nil { - return nil, fmt.Errorf("fee: encoding envelope: %w", err) + return nil, BodyDescriptor{}, fmt.Errorf("fee: encoding envelope: %w", err) } // The body cipher streams into a pipe that the returned reader drains. Create @@ -347,7 +371,7 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts if err != nil { _ = pw.Close() _ = pr.Close() - return nil, fmt.Errorf("fee: initializing body cipher: %w", err) + return nil, BodyDescriptor{}, fmt.Errorf("fee: initializing body cipher: %w", err) } declaredLen := cfg.contentLength @@ -369,14 +393,30 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts _ = pw.CloseWithError(cerr) }() + // Every value the descriptor reports is fixed above, before any plaintext is + // read, so a caller can record it while the upload is still streaming. The + // clone keeps it independent of the buffers the cipher and the encoded header + // were built from. + descriptor := BodyDescriptor{ + HeaderLen: int64(len(header)), + BaseNonce: baseNonce, + ChunkSize: cfg.chunkSize, + AAD: aad, + }.clone() return &encryptReader{ body: io.MultiReader(bytes.NewReader(header), pr), pr: pr, - }, nil + }, descriptor, nil } // chunkCountFor reports how many STREAM chunks a plaintext of nPlain bytes // produces at the given chunk size. Empty input is one (empty) final chunk. +// +// This is the producer's rule, matching the reference implementation, and it is +// only for writing the chunk-count header. It is not a decoder's rule: a stream +// whose plaintext is an exact multiple of the chunk size may legitimately carry +// one more chunk than this (an empty final chunk), so a count read off the wire +// is checked against [aesstream.ChunkCount] instead. func chunkCountFor(nPlain, chunkSize int64) int64 { if nPlain <= 0 { return 1 @@ -384,10 +424,12 @@ func chunkCountFor(nPlain, chunkSize int64) int64 { return (nPlain + chunkSize - 1) / chunkSize } -// encryptReader is the io.ReadCloser returned by [Encrypt] / [EncryptWithCEK]. -// Read serves the envelope header and then the streamed ciphertext; Close aborts -// the background encryption goroutine by closing the pipe, so it is safe to -// abandon a partial read. +// encryptReader is the wire blob [Encrypt] hands back: the encoded envelope +// header, served from memory, followed by the ciphertext the background +// encryption goroutine streams through the pipe. +// +// Close closes the pipe, which aborts that goroutine, so a caller may abandon a +// partial read. type encryptReader struct { body io.Reader // io.MultiReader(header, pipe reader) pr *io.PipeReader // closing it stops the encryption goroutine @@ -456,8 +498,8 @@ func DecryptWithCEK(src io.Reader, cek []byte) (io.Reader, error) { if src == nil { return nil, errors.New("fee: nil envelope reader") } - if len(cek) != aesstream.KeySize { - return nil, fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek)) + if err := checkCEK(cek); err != nil { + return nil, err } env, ciphertext, err := cose.DecodeReader(src, cose.WithExpectedType(EnvelopeType)) if err != nil { @@ -476,17 +518,67 @@ func DecryptWithCEK(src io.Reader, cek []byte) (io.Reader, error) { // lazily on later reads, which work from the internalized key, never the cek // slice. func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader, error) { + body, aad, err := buildBodyParamsWithAAD(env) + if err != nil { + return nil, err + } + r, err := aesstream.NewReader(ciphertext, body.streamConfig(cek, aad)) + if err != nil { + return nil, fmt.Errorf("fee: initializing body cipher: %w", err) + } + return r, nil +} + +// bodyParams is the validated STREAM configuration a FEE envelope's body header +// carries: everything fee/aesstream needs to size and locate the detached +// ciphertext apart from the content-encryption key and Enc_structure AAD. +// +// [buildBodyParamsWithAAD] rebuilds that AAD alongside the validated params, and +// [BodyDescriptor] caches the same inputs plus the envelope's encoded length — +// where the ciphertext starts within a stored blob. That is what a caller caches +// and what a range read needs; a whole-object read has neither the number (the +// streaming decoder does not report it) nor a use for it, so the shapes stay +// distinct and [BodyDescriptor.bodyParams] / [BodyDescriptor.aad] convert one +// way. +type bodyParams struct { + baseNonce []byte + chunkSize int +} + +// streamConfig returns the fee/aesstream configuration for decrypting a body +// with these parameters under cek, authenticating aad as the envelope +// Enc_structure. It is the only place FEE body parameters become a stream +// configuration, so the whole-object reader ([openStream]) and the range reader +// ([spanRangeReader]) cannot drift apart in how they configure the cipher. +func (b bodyParams) streamConfig(cek, aad []byte) aesstream.Config { + return aesstream.Config{ + Key: cek, + BaseNonce: b.baseNonce, + AAD: aad, + ChunkSize: b.chunkSize, + } +} + +// validateBodyParams checks a decoded envelope's FEE body headers — the +// algorithm is the chunked AES-256-GCM-STREAM cipher, the base nonce (iv) is +// present, and the self-describing chunk size is in range. +// +// It is shared by the whole-object path's AAD-building resolution +// ([buildBodyParamsWithAAD]) and the header-only sizing path ([PlaintextSize]), +// so both accept exactly the same envelopes and report the same errors for a +// body header they cannot honour. +func validateBodyParams(env *cose.Envelope) (bodyParams, error) { alg, ok := env.Headers.Protected.Int(cose.HeaderLabelAlg) if !ok { - return nil, fmt.Errorf("%w: body algorithm header missing or not an integer", ErrUnsupportedBodyAlg) + return bodyParams{}, fmt.Errorf("%w: body algorithm header missing or not an integer", ErrUnsupportedBodyAlg) } if alg != algChunkedAES256GCMStream { - return nil, fmt.Errorf("%w: body algorithm %d is not chunked AES-256-GCM-STREAM", ErrUnsupportedBodyAlg, alg) + return bodyParams{}, fmt.Errorf("%w: body algorithm %d is not chunked AES-256-GCM-STREAM", ErrUnsupportedBodyAlg, alg) } baseNonce, ok := env.Headers.Unprotected.Bytes(cose.HeaderLabelIV) if !ok { - return nil, fmt.Errorf("%w: missing iv (base nonce)", ErrMalformedEnvelope) + return bodyParams{}, fmt.Errorf("%w: missing iv (base nonce)", ErrMalformedEnvelope) } // Self-describing chunk size; an envelope that omits it is read at the FEE @@ -497,33 +589,45 @@ func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader if env.Headers.Unprotected.Has(labelChunkSize) { n, ok := env.Headers.Unprotected.Int(labelChunkSize) if !ok { - return nil, fmt.Errorf("%w: chunk-size header is present but not an integer", ErrMalformedEnvelope) + return bodyParams{}, fmt.Errorf("%w: chunk-size header is present but not an integer", ErrMalformedEnvelope) } chunkSize = n } if chunkSize < int64(aesstream.MinChunkSize) || chunkSize > int64(aesstream.MaxChunkSize) { - return nil, fmt.Errorf("%w: declared chunk size %d out of range [%d, %d]", + return bodyParams{}, fmt.Errorf("%w: declared chunk size %d out of range [%d, %d]", ErrMalformedEnvelope, chunkSize, aesstream.MinChunkSize, aesstream.MaxChunkSize) } + return bodyParams{baseNonce: baseNonce, chunkSize: int(chunkSize)}, nil +} + +// buildBodyParamsWithAAD validates the body headers and rebuilds the +// Enc_structure AAD that the encoder bound into every chunk. +func buildBodyParamsWithAAD(env *cose.Envelope) (bodyParams, []byte, error) { + body, err := validateBodyParams(env) + if err != nil { + return bodyParams{}, nil, err + } + // The decrypt-side AAD is rebuilt from the decoded envelope, using the // Enc_structure context that matches its tag — byte-identical to the value // the encoder bound into every chunk. aad, err := env.EncStructure(nil) if err != nil { - return nil, fmt.Errorf("fee: building envelope AAD: %w", err) + return bodyParams{}, nil, fmt.Errorf("fee: building envelope AAD: %w", err) } - r, err := aesstream.NewReader(ciphertext, aesstream.Config{ - Key: cek, - BaseNonce: baseNonce, - AAD: aad, - ChunkSize: int(chunkSize), - }) - if err != nil { - return nil, fmt.Errorf("fee: initializing body cipher: %w", err) + return body, aad, nil +} + +// checkCEK reports whether a caller-provided content-encryption key is the right +// length for the FEE body cipher. It is the one spelling of the check every +// external-CEK entry point applies, so they all report ErrInvalidCEK the same way. +func checkCEK(cek []byte) error { + if len(cek) != aesstream.KeySize { + return fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek)) } - return r, nil + return nil } // matchRecipient returns the first recipient whose kid equals want. A recipient diff --git a/fee_test.go b/fee_test.go index 8aff1f8..96c4003 100644 --- a/fee_test.go +++ b/fee_test.go @@ -89,7 +89,7 @@ func patternBytes(n int) []byte { // An Encrypt setup error (e.g. an invalid recipient) is returned directly. func encrypt(t *testing.T, plaintext []byte, recipients []fee.Recipient, opts ...fee.EncryptOption) ([]byte, error) { t.Helper() - r, err := fee.Encrypt(bytes.NewReader(plaintext), recipients, opts...) + r, _, err := fee.Encrypt(bytes.NewReader(plaintext), recipients, opts...) if err != nil { return nil, err } @@ -257,7 +257,7 @@ func TestExternalCEK(t *testing.T) { // Seal under the caller's CEK; still carry an A256KW recipient so the CEK is // recoverable in-envelope too. - enc, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, + enc, _, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, []fee.Recipient{fee.NewA256KWRecipient(a256kwKID, kek)}, fee.WithChunkSize(aesstream.MinChunkSize), ) @@ -298,7 +298,7 @@ func TestExternalCEKNoRecipients(t *testing.T) { cek := newCEK(t) plaintext := patternBytes(2*aesstream.MinChunkSize + 3) - enc, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, nil, + enc, _, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, nil, fee.WithChunkSize(aesstream.MinChunkSize), ) require.NoError(t, err) @@ -330,7 +330,7 @@ func TestExternalCEKNoRecipients(t *testing.T) { // CEK that is not 32 bytes. func TestExternalCEKInvalidLength(t *testing.T) { t.Run("encrypt", func(t *testing.T) { - _, err := fee.EncryptWithCEK(bytes.NewReader(patternBytes(10)), make([]byte, 16), + _, _, err := fee.EncryptWithCEK(bytes.NewReader(patternBytes(10)), make([]byte, 16), []fee.Recipient{fee.NewA256KWRecipient(a256kwKID, newKEK(t))}) require.ErrorIs(t, err, fee.ErrInvalidCEK) }) @@ -349,7 +349,7 @@ func TestStreamingRoundTrip(t *testing.T) { key := newX25519Key(t) plaintext := patternBytes(3*aesstream.MinChunkSize + 123) - enc, err := fee.Encrypt( + enc, _, err := fee.Encrypt( bytes.NewReader(plaintext), []fee.Recipient{fee.NewECDHESRecipient(ecdhKID, key.PublicKey())}, fee.WithChunkSize(aesstream.MinChunkSize), @@ -392,7 +392,7 @@ func TestEncryptReaderCloseReleases(t *testing.T) { key := newX25519Key(t) // Large enough that the encryption goroutine would block on the pipe if the // reader were abandoned without Close. - enc, err := fee.Encrypt( + enc, _, err := fee.Encrypt( bytes.NewReader(patternBytes(10*aesstream.MinChunkSize)), []fee.Recipient{fee.NewECDHESRecipient(ecdhKID, key.PublicKey())}, fee.WithChunkSize(aesstream.MinChunkSize), @@ -568,14 +568,14 @@ func TestDecryptWrongEnvelopeType(t *testing.T) { // TestEncryptNoRecipients confirms Encrypt rejects an empty recipient set. func TestEncryptNoRecipients(t *testing.T) { - _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), nil) + _, _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), nil) require.ErrorIs(t, err, fee.ErrNoRecipients) } // TestEncryptNilRecipient confirms a nil entry in the recipient slice is an // error rather than a panic. func TestEncryptNilRecipient(t *testing.T) { - _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), []fee.Recipient{nil}) + _, _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), []fee.Recipient{nil}) require.Error(t, err) } @@ -584,7 +584,7 @@ func TestEncryptNilRecipient(t *testing.T) { // the sentinel the body cipher itself uses — not as a malformed envelope. func TestEncryptChunkSizeOutOfRange(t *testing.T) { key := newX25519Key(t) - _, err := fee.Encrypt( + _, _, err := fee.Encrypt( bytes.NewReader(patternBytes(10)), []fee.Recipient{fee.NewECDHESRecipient(ecdhKID, key.PublicKey())}, fee.WithChunkSize(aesstream.MinChunkSize-1), @@ -739,7 +739,7 @@ func TestContentLengthMismatch(t *testing.T) { // withheld on the mismatch. plaintext := patternBytes(2*aesstream.MinChunkSize + 7) - enc, err := fee.Encrypt(bytes.NewReader(plaintext), + enc, _, err := fee.Encrypt(bytes.NewReader(plaintext), []fee.Recipient{fee.NewECDHESRecipient(ecdhKID, key.PublicKey())}, fee.WithChunkSize(aesstream.MinChunkSize), fee.WithContentLength(int64(len(plaintext)+1)), // declare one byte too many @@ -812,22 +812,22 @@ func TestDecryptStreamsIncrementally(t *testing.T) { func TestEncryptInvalidRecipient(t *testing.T) { key := newX25519Key(t) t.Run("ECDH-ES nil public key", func(t *testing.T) { - _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), + _, _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), []fee.Recipient{fee.NewECDHESRecipient(ecdhKID, nil)}) require.Error(t, err) }) t.Run("ECDH-ES empty kid", func(t *testing.T) { - _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), + _, _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), []fee.Recipient{fee.NewECDHESRecipient(nil, key.PublicKey())}) require.Error(t, err) }) t.Run("A256KW empty kid", func(t *testing.T) { - _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), + _, _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), []fee.Recipient{fee.NewA256KWRecipient(nil, newKEK(t))}) require.Error(t, err) }) t.Run("A256KW wrong KEK length", func(t *testing.T) { - _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), + _, _, err := fee.Encrypt(bytes.NewReader(patternBytes(10)), []fee.Recipient{fee.NewA256KWRecipient(a256kwKID, make([]byte, 16))}) require.Error(t, err) }) diff --git a/range.go b/range.go new file mode 100644 index 0000000..447570d --- /dev/null +++ b/range.go @@ -0,0 +1,373 @@ +package fee + +import ( + "errors" + "fmt" + "io" + + "github.com/filecoin-project/go-fee/aesstream" + "github.com/filecoin-project/go-fee/cose" +) + +// ErrSizeMismatch means the number of chunks the supplied blob size accounts for +// disagrees with the chunk count the envelope declares: the blob is truncated or +// padded, or the size came from the wrong object. +// +// The chunk count rides in the unprotected header ([WithContentLength] writes it +// when the plaintext length is known), so it is not covered by the envelope AAD +// and an attacker rewriting the blob can strip or adjust it. This check therefore +// catches operational mistakes — a stale size in a store's metadata, a partially +// written object — and is not an integrity guarantee. Whole-object integrity must +// come from the layer that supplied the size (a CID, a signed manifest). +var ErrSizeMismatch = errors.New("fee: blob size disagrees with the envelope's declared chunk count") + +// errNilBlob is reported by every range entry point that would otherwise read +// from a nil blob, whether or not it decodes an envelope first. +var errNilBlob = errors.New("fee: nil blob reader") + +const ( + // headerProbeSize is the first prefix length the envelope-header probe + // reads. A FEE envelope with a handful of recipients is a few hundred + // bytes, so one read of this size decodes essentially every real blob. + headerProbeSize = 4096 + + // maxHeaderLen bounds the probe's growth, so a blob whose prefix declares + // an enormous CBOR item cannot make the probe allocate its way up to the + // full blob size. It still admits thousands of recipients (a recipient + // entry is a couple of hundred bytes). + maxHeaderLen = 1 << 20 +) + +// RangeReader streams the decrypted plaintext of one byte range of a FEE blob. +// It is returned by [DecryptRange] and [DecryptRangeWithCEK], and reads +// ciphertext lazily: nothing beyond the envelope header is fetched until Read is +// called (nothing at all when [DecryptRangeWithCEK] is given a non-nil +// [BodyDescriptor], which reads no envelope), and then only the chunks the +// range overlaps. +// +// Any non-EOF error from Read means the plaintext emitted so far is incomplete +// and must be discarded — a tampered or reordered chunk surfaces as +// [aesstream.ErrCorrupted], and a blob that ends before the range's chunks do as +// [aesstream.ErrShortSpan]. A clean io.EOF means the whole requested range was +// emitted. +// +// A RangeReader is not safe for concurrent use. +type RangeReader struct { + sr *aesstream.SpanReader + len int64 // plaintext bytes this reader will emit + size int64 // total plaintext size of the whole object + + spanOff int64 // blob-absolute offset of the ciphertext span Read consumes + spanLen int64 // byte length of that span (0 for an empty range) +} + +// Read implements io.Reader, yielding the requested plaintext range. +func (r *RangeReader) Read(p []byte) (int, error) { + if r.sr != nil { + return r.sr.Read(p) + } + if len(p) == 0 { + return 0, nil + } + return 0, io.EOF +} + +// Len returns the number of plaintext bytes this reader will emit: the requested +// length clamped to the bytes available from the offset. It is fixed at +// construction, so an HTTP consumer can use it as the response Content-Length +// before reading anything. +func (r *RangeReader) Len() int64 { return r.len } + +// Size returns the total plaintext size of the whole object, derived from the +// blob size and the chunk size. It is the total an HTTP consumer puts after the +// slash in a Content-Range header. [BodyDescriptor.PlaintextSize] reports the +// same number from cached descriptor data, without a reader. +func (r *RangeReader) Size() int64 { return r.size } + +// CiphertextSpan returns the blob-absolute byte range [off, off+n) that Read will +// consume — the envelope header length plus the chunk-aligned ciphertext span the +// requested range overlaps. n is 0 for an empty range, in which case no +// ciphertext is read at all. +// +// Because no ciphertext is read until the first Read, a caller backed by a remote +// store can construct the reader, fetch exactly this span in a single range +// request, and serve the reads from that buffer rather than letting each chunk +// become its own request. +func (r *RangeReader) CiphertextSpan() (off, n int64) { return r.spanOff, r.spanLen } + +// DecryptRange decrypts the plaintext byte range [off, off+length) of a FEE blob +// (envelope||ciphertext, as produced by [Encrypt]) without fetching or decrypting +// the whole object. +// +// blob is random access over the stored bytes and blobSize is their exact total +// length, as the store reports it. The STREAM geometry — chunk boundaries, which +// chunk is final, the total plaintext size — is derived from blobSize, so it must +// be correct; see the accuracy note below. Only the envelope header and the +// ciphertext chunks the range overlaps are read: one small ReadAt at offset 0 to +// decode the header (rarely a second, larger one for an unusually big envelope), +// then, driven by Read, one ReadAt per overlapping chunk at contiguous ascending +// offsets covering exactly [RangeReader.CiphertextSpan]. No ciphertext is read +// before the first Read, so a caller fronting a remote store may prefetch that +// span in a single range request. +// +// unwrap locates and recovers the content-encryption key exactly as [Decrypt] +// does: a recipient-less COSE_Encrypt0 yields [ErrNoRecipientsInEnvelope] (use +// [DecryptRangeWithCEK]), no recipient kid matching unwrap yields +// [ErrNoMatchingRecipient] without attempting an unwrap, and a matched recipient +// whose wrapped CEK cannot be recovered returns the unwrap error. +// +// length is clamped to the bytes available from off — [RangeReader.Len] reports +// what will actually be emitted, so an open-ended HTTP range can pass +// math.MaxInt64. off may equal the plaintext size (an empty range), but a larger +// off, or a negative off or length, fails with an error matching +// [aesstream.ErrRange] (an HTTP consumer's 416). A blobSize that cannot be a FEE +// blob fails with [aesstream.ErrCiphertextSize], and one that contradicts the +// envelope's declared chunk count with [ErrSizeMismatch]. An envelope larger than +// 1 MiB is rejected as malformed. +// +// Every chunk the range overlaps is authenticated, so a tampered chunk surfaces +// as an error from Read rather than as corrupt plaintext. Chunks outside the range +// are never read and so never checked, and — unlike whole-object [Decrypt] — a +// range read cannot by itself detect that the stored object was truncated: an +// understated blobSize describes a shorter object whose ranges decrypt cleanly. +// The chunk-count check catches an understatement of a whole chunk or more when +// the envelope carries a count, but a size short by only part of the final chunk +// accounts for the same number of chunks and so passes it, surfacing instead as +// an authentication failure when that chunk is read. Whole-object integrity is +// properly the job of the layer that supplied blobSize. +func DecryptRange(blob io.ReaderAt, blobSize int64, unwrap RecipientUnwrapper, off, length int64) (*RangeReader, error) { + if unwrap == nil { + return nil, ErrNilUnwrapper + } + env, headerLen, err := decodeHeaderAt(blob, blobSize) + if err != nil { + return nil, err + } + if len(env.Recipients) == 0 { + return nil, ErrNoRecipientsInEnvelope + } + + match, err := matchRecipient(env.Recipients, unwrap.keyID()) + if err != nil { + return nil, err + } + cek, err := unwrap.unwrap(match) + if err != nil { + return nil, err + } + // The recovered CEK is ours; wipe it once newRangeReader has copied it into + // the body cipher (synchronously, before it returns). + defer zero(cek) + + return newRangeReader(env, blob, blobSize, headerLen, cek, off, length) +} + +// DecryptRangeWithCEK is [DecryptRange] with a caller-provided content-encryption +// key instead of one recovered from an in-envelope recipient — for when the CEK +// was obtained out of band (e.g. unwrapped by a custody service). It accepts +// either a COSE_Encrypt (tag 96) or a recipient-less COSE_Encrypt0 (tag 16); any +// recipients are ignored. cek must be 32 bytes (AES-256). +// +// If desc is nil, it decodes the envelope header from blob exactly as +// [DecryptRange] does. A caller that already cached the needed +// envelope-derived metadata can pass it as desc to skip that header read +// entirely; the only bytes then fetched from blob are the ciphertext chunks the +// range overlaps. The descriptor is cloned before use, so later caller mutation +// cannot affect the decryptor built from it. A malformed descriptor is rejected +// up front with [ErrInvalidDescriptor]; a well-formed but stale or wrong one +// fails when read as [aesstream.ErrCorrupted], not as plausible plaintext. +// +// blobSize is the size of the whole stored blob, envelope included, exactly as +// for [DecryptRange]. When desc is non-nil there is no envelope to consult, so +// this path cannot perform the chunk-count cross-check that reports +// [ErrSizeMismatch] on the envelope-backed paths. +// +// The caller retains ownership of cek: it is copied into the body cipher but +// neither retained nor wiped by this call. +func DecryptRangeWithCEK(blob io.ReaderAt, blobSize int64, cek []byte, off, length int64, desc *BodyDescriptor) (*RangeReader, error) { + if blob == nil { + return nil, errNilBlob + } + + if err := checkCEK(cek); err != nil { + return nil, err + } + + if desc != nil { + // Validate the cached descriptor and derive plaintext size without + // reading the envelope. + var d BodyDescriptor = desc.clone() + plainSize, err := d.PlaintextSize(blobSize) + if err != nil { + return nil, err + } + return spanRangeReader(blob, blobSize, d.HeaderLen, d.bodyParams(), d.aad(), plainSize, cek, off, length) + } + + env, headerLen, err := decodeHeaderAt(blob, blobSize) + if err != nil { + return nil, err + } + return newRangeReader(env, blob, blobSize, headerLen, cek, off, length) +} + +// PlaintextSize reports the total decrypted size of a FEE blob from its envelope +// header and blobSize alone. It reads only the header — no ciphertext — and needs +// no key material, so it answers a HEAD request, fills in the total of a +// Content-Range header, or resolves a suffix range ("bytes=-N" is off = size-N) +// without constructing a decryptor. +// +// It reports the same envelope, size and chunk-count errors as [DecryptRange]. +func PlaintextSize(blob io.ReaderAt, blobSize int64) (int64, error) { + env, headerLen, err := decodeHeaderAt(blob, blobSize) + if err != nil { + return 0, err + } + body, err := validateBodyParams(env) + if err != nil { + return 0, err + } + return envelopePlaintextSize(env, blobSize, headerLen, body.chunkSize) +} + +// newRangeReader is the shared core of DecryptRange and the envelope-decoding +// branch of [DecryptRangeWithCEK]: given the decoded envelope, its encoded +// length, and the content-encryption key, it validates the body parameters, +// derives the stream geometry from the blob size, and wires the overlapping +// ciphertext span into a range reader. +// +// It does not retain cek past its own return: aesstream.NewSpanReader +// internalizes the CEK (into a GCM AEAD) synchronously, so a caller may wipe cek +// as soon as this returns — even though the reader decrypts lazily on later +// reads, which work from the internalized key, never the cek slice. +func newRangeReader(env *cose.Envelope, blob io.ReaderAt, blobSize, headerLen int64, cek []byte, off, length int64) (*RangeReader, error) { + body, aad, err := buildBodyParamsWithAAD(env) + if err != nil { + return nil, err + } + + plainSize, err := envelopePlaintextSize(env, blobSize, headerLen, body.chunkSize) + if err != nil { + return nil, err + } + return spanRangeReader(blob, blobSize, headerLen, body, aad, plainSize, cek, off, length) +} + +// spanRangeReader is the geometry-and-wiring tail shared by the envelope-backed +// path ([newRangeReader]) and the cached-descriptor path (via a non-nil +// [BodyDescriptor] passed to [DecryptRangeWithCEK]): given the resolved body +// parameters and the object's plaintext size, it resolves the ciphertext span +// the range overlaps and hands exactly that span to the body cipher. +// +// The two paths differ only in how they arrive at body, aad and plainSize — +// decoded from the envelope, or supplied from a caller's cache — so keeping the +// wiring in one place is what makes them accept the same ranges and fail the +// same way. +func spanRangeReader(blob io.ReaderAt, blobSize, headerLen int64, body bodyParams, aad []byte, plainSize int64, cek []byte, off, length int64) (*RangeReader, error) { + ciphertextSize := blobSize - headerLen + + start, n, plainLen, err := aesstream.CiphertextRange(ciphertextSize, body.chunkSize, off, length) + if err != nil { + return nil, fmt.Errorf("fee: resolving ciphertext range: %w", err) + } + if n == 0 { + return &RangeReader{ + len: plainLen, + size: plainSize, + spanOff: headerLen + start, + spanLen: n, + }, nil + } + + // The span is chunk-aligned and contiguous, so the section reader hands + // aesstream exactly the bytes it will ask for and nothing else. + sr, err := aesstream.NewSpanReader( + io.NewSectionReader(blob, headerLen+start, n), + body.streamConfig(cek, aad), + ciphertextSize, off, length) + if err != nil { + return nil, fmt.Errorf("fee: initializing body cipher: %w", err) + } + + return &RangeReader{sr: sr, len: plainLen, size: plainSize, spanOff: headerLen + start, spanLen: n}, nil +} + +// envelopePlaintextSize is [plaintextSizeFrom] for a blob whose parameters came +// from its envelope rather than from a caller's cache: it adds the cross-check of +// the envelope's declared chunk count against the chunks the ciphertext actually +// holds, when a count is present, catching a blob size that describes a different +// object than the envelope does. +// +// The comparison is against [aesstream.ChunkCount] rather than a count re-derived +// from the plaintext size, because a plaintext that is an exact multiple of the +// chunk size seals to either k chunks or k+1 (with an empty final chunk) and both +// decrypt identically. Deriving from the plaintext size would recognize only the +// first form and refuse the second, which the whole-object [Decrypt] path reads +// without complaint. +func envelopePlaintextSize(env *cose.Envelope, blobSize, headerLen int64, chunkSize int) (int64, error) { + plainSize, err := plaintextSizeFrom(blobSize, headerLen, chunkSize) + if err != nil { + return 0, err + } + if !env.Headers.Unprotected.Has(labelChunkCount) { + return plainSize, nil + } + declared, ok := env.Headers.Unprotected.Int(labelChunkCount) + if !ok { + return 0, fmt.Errorf("%w: chunk-count header is present but not an integer", ErrMalformedEnvelope) + } + want, err := aesstream.ChunkCount(blobSize-headerLen, chunkSize) + if err != nil { + return 0, fmt.Errorf("fee: blob of %d ciphertext bytes: %w", blobSize-headerLen, err) + } + if declared != want { + return 0, fmt.Errorf("%w: envelope declares %d chunks, the ciphertext holds %d", + ErrSizeMismatch, declared, want) + } + return plainSize, nil +} + +// decodeHeaderAt decodes the FEE envelope at the front of blob and reports its +// exact encoded length, so the caller can locate the detached ciphertext that +// follows it. +// +// A COSE envelope is a self-delimiting CBOR item, so cose.Decode over a prefix +// reports how many bytes it consumed (as the remainder it hands back) — but the +// prefix has to be long enough to hold the whole item. This reads a small prefix +// and, only if that turns out to be too short, doubles it and retries, up to +// maxHeaderLen. Every realistic envelope decodes on the first read. +func decodeHeaderAt(blob io.ReaderAt, blobSize int64) (*cose.Envelope, int64, error) { + if blob == nil { + return nil, 0, errNilBlob + } + if blobSize < 0 { + return nil, 0, fmt.Errorf("fee: negative blob size %d", blobSize) + } + + limit := min(blobSize, int64(maxHeaderLen)) + probe := min(int64(headerProbeSize), limit) + for { + buf := make([]byte, probe) + n, rerr := blob.ReadAt(buf, 0) + if rerr != nil && rerr != io.EOF { + return nil, 0, fmt.Errorf("fee: reading envelope header: %w", rerr) + } + // A short read means the blob really ends here, whatever blobSize + // claimed, so growing the probe cannot produce more bytes. + atEnd := rerr == io.EOF || int64(n) < probe + + env, rest, derr := cose.Decode(buf[:n], cose.WithExpectedType(EnvelopeType)) + if derr == nil { + return env, int64(n - len(rest)), nil + } + // Only a prefix that stopped mid-item is worth re-reading. Every other + // decode failure — not a COSE tag, wrong typ, a duplicate header label — + // was decided on complete bytes, so a longer prefix reaches the same + // verdict. Retrying those would turn one wrong object id into a walk up to + // maxHeaderLen against the store. + if !errors.Is(derr, io.ErrUnexpectedEOF) || atEnd || probe >= limit { + return nil, 0, fmt.Errorf("fee: decoding envelope: %w", derr) + } + probe = min(probe*2, limit) + } +} diff --git a/range_test.go b/range_test.go new file mode 100644 index 0000000..12bf2b5 --- /dev/null +++ b/range_test.go @@ -0,0 +1,919 @@ +package fee_test + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "errors" + "io" + "math" + "slices" + "testing" + + "github.com/filecoin-project/go-fee" + "github.com/filecoin-project/go-fee/aesstream" + "github.com/filecoin-project/go-fee/cose" + "github.com/stretchr/testify/require" +) + +// rangeChunk is the chunk size the range tests encrypt at: the smallest the FEE +// spec allows, so a multi-chunk object stays cheap to build and every chunk +// boundary is easy to target. +const rangeChunk = aesstream.MinChunkSize + +// recordingReaderAt is an io.ReaderAt over a fixed blob that records every +// interval it is asked for, so a test can prove which bytes a range decrypt +// actually touched. +type recordingReaderAt struct { + blob *bytes.Reader + reads []readInterval + reject bool // fail instead of serving, to prove no read happens at all + t *testing.T +} + +// readInterval is one ReadAt request: the offset and the number of bytes served. +type readInterval struct{ off, n int64 } + +func newRecordingReaderAt(t *testing.T, blob []byte) *recordingReaderAt { + t.Helper() + return &recordingReaderAt{blob: bytes.NewReader(blob), t: t} +} + +func (r *recordingReaderAt) ReadAt(p []byte, off int64) (int, error) { + if r.reject { + r.t.Errorf("unexpected ReadAt(off=%d, len=%d)", off, len(p)) + return 0, errors.New("recordingReaderAt: read not expected") + } + n, err := r.blob.ReadAt(p, off) + r.reads = append(r.reads, readInterval{off: off, n: int64(n)}) + return n, err +} + +// rangeFixture is an encrypted object plus everything needed to range-decrypt it. +type rangeFixture struct { + plaintext []byte + blob []byte + unwrapper fee.RecipientUnwrapper +} + +// newRangeFixture encrypts n deterministic plaintext bytes to a single ECDH-ES +// recipient at rangeChunk, the common setup for the range tests. +func newRangeFixture(t *testing.T, n int, opts ...fee.EncryptOption) rangeFixture { + t.Helper() + priv := newX25519Key(t) + plaintext := patternBytes(n) + opts = append([]fee.EncryptOption{fee.WithChunkSize(rangeChunk)}, opts...) + blob, err := encrypt(t, plaintext, []fee.Recipient{ + fee.NewECDHESRecipient(ecdhKID, priv.PublicKey()), + }, opts...) + require.NoError(t, err) + return rangeFixture{ + plaintext: plaintext, + blob: blob, + unwrapper: fee.NewECDHESUnwrapper(ecdhKID, priv), + } +} + +// encryptWithCEK runs fee.EncryptWithCEK over plaintext and reads the streamed +// envelope||ciphertext into a single blob, the external-CEK counterpart of the +// encrypt helper. +func encryptWithCEK(t *testing.T, plaintext, cek []byte, recipients []fee.Recipient, opts ...fee.EncryptOption) ([]byte, error) { + t.Helper() + r, _, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, recipients, opts...) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +// sealTrailingEmptyFinalChunk builds a FEE blob whose ciphertext is k full +// chunks followed by an empty final chunk, declared as k+1 — the second valid +// encoding of a plaintext that is an exact multiple of the chunk size. +// +// aesstream.Writer never emits this form (it flushes a full buffer as the final +// chunk, giving k), so the body is sealed chunk by chunk here, under the +// documented per-chunk nonce and the envelope's own Enc_structure as AAD. +func sealTrailingEmptyFinalChunk(t *testing.T, plaintext, cek, baseNonce []byte, chunkSize int) []byte { + t.Helper() + require.Zero(t, len(plaintext)%chunkSize, "plaintext must be an exact multiple of the chunk size") + chunks := len(plaintext)/chunkSize + 1 // the trailing empty chunk is the extra one + + env := &cose.Envelope{Headers: cose.Headers{ + Protected: cose.Header{}. + Set(cose.HeaderLabelAlg, algChunkedStream). + Set(cose.HeaderLabelType, fee.EnvelopeType), + Unprotected: cose.Header{}. + Set(cose.HeaderLabelIV, baseNonce). + Set(labelChunkSize, int64(chunkSize)). + Set(labelChunkCount, int64(chunks)), + }} + aad, err := env.EncStructure(nil) + require.NoError(t, err) + header, err := env.Encode() + require.NoError(t, err) + + block, err := aes.NewCipher(cek) + require.NoError(t, err) + aead, err := cipher.NewGCM(block) + require.NoError(t, err) + + blob := bytes.Clone(header) + for i := range chunks { + last := i == chunks-1 + var chunk []byte + if !last { + chunk = plaintext[i*chunkSize : (i+1)*chunkSize] + } + blob = aead.Seal(blob, streamNonce(baseNonce, i, last), chunk, aad) + } + return blob +} + +// streamNonce builds the per-chunk GCM nonce of the FEE body cipher: +// baseNonce(7) || chunkIndex(4, big-endian) || lastFlag(1). +func streamNonce(baseNonce []byte, index int, last bool) []byte { + nonce := make([]byte, 0, aesstream.NonceSize) + nonce = append(nonce, baseNonce...) + nonce = binary.BigEndian.AppendUint32(nonce, uint32(index)) + if last { + return append(nonce, 0x01) + } + return append(nonce, 0x00) +} + +// hexBytes decodes a hex literal, for the handful of tests that pin behaviour +// against exact wire bytes rather than an encrypted fixture. +func hexBytes(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + require.NoError(t, err) + return b +} + +// clampLen is the plaintext length a range request of [off, off+length) actually +// yields from a size-byte object: length clamped to what is left from off. The +// subtraction comes first, so an open-ended math.MaxInt64 length cannot overflow. +func clampLen(size, off, length int64) int64 { + return min(length, size-off) +} + +// headerLenOf reports the encoded envelope length of a blob holding size +// plaintext bytes at rangeChunk, by subtracting the ciphertext the STREAM +// geometry accounts for. +func headerLenOf(blob []byte, size int64) int64 { + return int64(len(blob)) - aesstream.EncryptedSize(size, rangeChunk) +} + +// decryptRange range-decrypts [off, off+length) from blob and returns the reader +// alongside the bytes it emitted, asserting a clean stream. +func decryptRange(t *testing.T, blob []byte, u fee.RecipientUnwrapper, off, length int64) (*fee.RangeReader, []byte) { + t.Helper() + r, err := fee.DecryptRange(bytes.NewReader(blob), int64(len(blob)), u, off, length) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + return r, got +} + +// TestDecryptRangeRoundTrip is the acceptance criterion that one call with an +// envelope, unwrap material and a byte range returns exactly the requested +// plaintext — across the interesting geometries of a multi-chunk object: whole +// object, within one chunk, spanning a boundary, exactly one aligned chunk, into +// the final chunk, and single bytes at each end. It runs the whole set against +// both object shapes, since the final chunk being full rather than short is the +// corner the declared chunk count is ambiguous at. +func TestDecryptRangeRoundTrip(t *testing.T) { + t.Run("partial final chunk", func(t *testing.T) { + testRangeRoundTrip(t, 3*rangeChunk+rangeChunk/2) + }) + + // An exact multiple of the chunk size: the final chunk is full rather than + // short, the geometry corner the wire format's chunk count is ambiguous at. + t.Run("exact multiple of the chunk size", func(t *testing.T) { + testRangeRoundTrip(t, 4*rangeChunk) + }) +} + +func testRangeRoundTrip(t *testing.T, size int64) { + t.Helper() + f := newRangeFixture(t, int(size), fee.WithContentLength(size)) + + cases := []struct { + name string + off, length int64 + }{ + {"whole object", 0, size}, + {"first byte", 0, 1}, + {"last byte", size - 1, 1}, + {"within first chunk", 100, 500}, + {"within middle chunk", rangeChunk + 7, 1000}, + {"across one boundary", rangeChunk - 10, 20}, + {"across two boundaries", rangeChunk - 10, 2*rangeChunk + 20}, + {"exactly one aligned chunk", rangeChunk, rangeChunk}, + {"aligned start, unaligned end", 2 * rangeChunk, rangeChunk + 5}, + {"from the last chunk's start", 3 * rangeChunk, rangeChunk / 2}, + {"into final chunk", 3*rangeChunk - 5, 100}, + {"ends exactly on boundary", rangeChunk / 2, rangeChunk / 2}, + {"length past end clamps", size - 10, 1000}, + {"open-ended range clamps", rangeChunk, math.MaxInt64}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r, got := decryptRange(t, f.blob, f.unwrapper, tc.off, tc.length) + + wantLen := clampLen(size, tc.off, tc.length) + require.Equal(t, wantLen, r.Len(), "Len is the clamped range length") + require.Equal(t, size, r.Size(), "Size is the whole object") + require.Equal(t, f.plaintext[tc.off:tc.off+wantLen], got) + }) + } +} + +// TestDecryptRangeA256KW covers the symmetric-KEK unwrapper on the range path, so +// both RecipientUnwrapper implementations are exercised. +func TestDecryptRangeA256KW(t *testing.T) { + kek := newKEK(t) + plaintext := patternBytes(2*rangeChunk + 100) + blob, err := encrypt(t, plaintext, []fee.Recipient{ + fee.NewA256KWRecipient(a256kwKID, kek), + }, fee.WithChunkSize(rangeChunk)) + require.NoError(t, err) + + off, length := int64(rangeChunk-50), int64(200) + _, got := decryptRange(t, blob, fee.NewA256KWUnwrapper(a256kwKID, kek), off, length) + require.Equal(t, plaintext[off:off+length], got) +} + +// TestDecryptRangeMixedRecipients confirms the range path picks the recipient +// matching the unwrapper's kid out of a multi-recipient envelope, whichever +// unwrapper the caller holds. +func TestDecryptRangeMixedRecipients(t *testing.T) { + priv := newX25519Key(t) + kek := newKEK(t) + plaintext := patternBytes(2 * rangeChunk) + blob, err := encrypt(t, plaintext, []fee.Recipient{ + fee.NewECDHESRecipient(ecdhKID, priv.PublicKey()), + fee.NewA256KWRecipient(a256kwKID, kek), + }, fee.WithChunkSize(rangeChunk)) + require.NoError(t, err) + + off, length := int64(rangeChunk+11), int64(300) + for name, u := range map[string]fee.RecipientUnwrapper{ + "ecdh-es": fee.NewECDHESUnwrapper(ecdhKID, priv), + "a256kw": fee.NewA256KWUnwrapper(a256kwKID, kek), + } { + t.Run(name, func(t *testing.T) { + _, got := decryptRange(t, blob, u, off, length) + require.Equal(t, plaintext[off:off+length], got) + }) + } +} + +// TestDecryptRangeWithCEK exercises the external-CEK range path against both +// envelope forms: a COSE_Encrypt whose recipients are ignored, and a +// recipient-less COSE_Encrypt0. +func TestDecryptRangeWithCEK(t *testing.T) { + cek := newCEK(t) + plaintext := patternBytes(2*rangeChunk + 77) + off, length := int64(rangeChunk-20), int64(500) + + t.Run("tag 96 with recipients", func(t *testing.T) { + blob, err := encryptWithCEK(t, plaintext, cek, []fee.Recipient{ + fee.NewA256KWRecipient(a256kwKID, newKEK(t)), + }, fee.WithChunkSize(rangeChunk)) + require.NoError(t, err) + + r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), int64(len(blob)), cek, off, length, nil) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, plaintext[off:off+length], got) + }) + + t.Run("recipient-less tag 16", func(t *testing.T) { + blob, err := encryptWithCEK(t, plaintext, cek, nil, fee.WithChunkSize(rangeChunk)) + require.NoError(t, err) + tag, err := cose.PeekTag(blob) + require.NoError(t, err) + require.Equal(t, cose.TagCOSEEncrypt0, tag) + + r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), int64(len(blob)), cek, off, length, nil) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, plaintext[off:off+length], got) + require.Equal(t, int64(len(plaintext)), r.Size()) + }) +} + +// TestDecryptRangeReadsOnlySpan is the acceptance criterion that a range read +// fetches only the envelope header and the overlapping ciphertext chunks: it +// records every ReadAt and asserts nothing outside the header probe and the +// reported span was ever touched, and that the chunks flanking the range were +// left alone. +func TestDecryptRangeReadsOnlySpan(t *testing.T) { + const size = 8 * rangeChunk + f := newRangeFixture(t, size) + + // A range wholly inside chunk 4 of 8, so there are untouched chunks on both + // sides and the span is a small fraction of the blob. + off, length := int64(4*rangeChunk+10), int64(100) + + rec := newRecordingReaderAt(t, f.blob) + r, err := fee.DecryptRange(rec, int64(len(f.blob)), f.unwrapper, off, length) + require.NoError(t, err) + + spanOff, spanLen := r.CiphertextSpan() + headerReads := slices.Clone(rec.reads) + require.NotEmpty(t, headerReads, "the header must be read at construction") + for _, rd := range headerReads { + require.Equal(t, int64(0), rd.off, "construction reads only the header prefix at offset 0") + } + require.Less(t, spanLen, int64(len(f.blob))/2, "the span must be far smaller than the blob") + + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, f.plaintext[off:off+length], got) + + // Exactly the reported span was fetched: one chunk's worth, no more. + require.Equal(t, int64(rangeChunk+aesstream.TagSize), spanLen, "one full ciphertext chunk") + + // Every ciphertext read lies inside the reported span, and together they + // cover it exactly once. + var fetched int64 + for _, rd := range rec.reads { + if rd.off == 0 { + continue // header probe + } + require.GreaterOrEqual(t, rd.off, spanOff, "read before the span start") + require.LessOrEqual(t, rd.off+rd.n, spanOff+spanLen, "read past the span end") + fetched += rd.n + } + require.Equal(t, spanLen, fetched, "the span is fetched exactly once, in full") +} + +// TestDecryptRangeZeroLengthReadsNoCiphertext confirms an empty range is valid, +// reports the object size, and fetches no ciphertext at all — the cheap way for a +// caller to learn a size while holding unwrap material. +func TestDecryptRangeZeroLengthReadsNoCiphertext(t *testing.T) { + const size = 4 * rangeChunk + f := newRangeFixture(t, size) + + for _, tc := range []struct { + name string + off, length int64 + }{ + {"length zero at start", 0, 0}, + {"length zero in the middle", rangeChunk + 1, 0}, + {"length zero at end", size, 0}, + {"length clamps to zero at end", size, 100}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := newRecordingReaderAt(t, f.blob) + r, err := fee.DecryptRange(rec, int64(len(f.blob)), f.unwrapper, tc.off, tc.length) + require.NoError(t, err) + + _, spanLen := r.CiphertextSpan() + require.Zero(t, spanLen, "an empty range needs no ciphertext") + require.Zero(t, r.Len()) + require.Equal(t, int64(size), r.Size()) + + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Empty(t, got) + + for _, rd := range rec.reads { + require.Equal(t, int64(0), rd.off, "only the header prefix is read") + } + }) + } +} + +// TestDecryptRangePrefetchSpan demonstrates the documented prefetch pattern: the +// caller constructs the reader (header I/O only), fetches the reported span in one +// go, and serves the reads from that buffer — after which the origin is never +// touched again. +func TestDecryptRangePrefetchSpan(t *testing.T) { + const size = 6 * rangeChunk + f := newRangeFixture(t, size) + off, length := int64(2*rangeChunk+5), int64(2*rangeChunk) + + rec := newRecordingReaderAt(t, f.blob) + plan, err := fee.DecryptRange(rec, int64(len(f.blob)), f.unwrapper, off, length) + require.NoError(t, err) + spanOff, spanLen := plan.CiphertextSpan() + + // One "range request" for the whole span. + span := make([]byte, spanLen) + _, err = rec.ReadAt(span, spanOff) + require.NoError(t, err) + + // Re-decrypt against a blob view backed by the prefetched span, and prove the + // origin serves nothing further. + rec.reject = true + r, err := fee.DecryptRange(newPrefetchedBlob(f.blob[:spanOff], span, spanOff), + int64(len(f.blob)), f.unwrapper, off, length) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, f.plaintext[off:off+length], got) +} + +// prefetchedBlob is an io.ReaderAt that serves the envelope header from one buffer +// and the pre-fetched ciphertext span from another, the shape a caller gets after +// a single range request. +type prefetchedBlob struct { + header *bytes.Reader + span *bytes.Reader + spanOff int64 +} + +func newPrefetchedBlob(header, span []byte, spanOff int64) prefetchedBlob { + return prefetchedBlob{header: bytes.NewReader(header), span: bytes.NewReader(span), spanOff: spanOff} +} + +func (b prefetchedBlob) ReadAt(p []byte, off int64) (int, error) { + if off < b.spanOff { + return b.header.ReadAt(p, off) + } + return b.span.ReadAt(p, off-b.spanOff) +} + +// TestDecryptRangeTamperedChunkInRange is the acceptance criterion that a tampered +// chunk yields an error rather than corrupt plaintext: every byte position of the +// chunk the range sits in is flipped in turn, and each must fail authentication. +func TestDecryptRangeTamperedChunkInRange(t *testing.T) { + const size = 3 * rangeChunk + f := newRangeFixture(t, size) + off, length := int64(rangeChunk+10), int64(50) + + headerLen := headerLenOf(f.blob, size) + // Positions inside chunk 1's ciphertext: its first byte, a byte covering the + // requested range, and a byte of its authentication tag. + chunkStart := headerLen + int64(rangeChunk+aesstream.TagSize) + for _, pos := range []int64{ + chunkStart, + chunkStart + 10, + chunkStart + int64(rangeChunk) + aesstream.TagSize - 1, + } { + tampered := bytes.Clone(f.blob) + tampered[pos] ^= 0x01 + + r, err := fee.DecryptRange(bytes.NewReader(tampered), int64(len(tampered)), f.unwrapper, off, length) + require.NoError(t, err, "construction only reads the header, so it still succeeds") + got, err := io.ReadAll(r) + require.Error(t, err, "a tampered chunk must not decrypt") + require.ErrorIs(t, err, aesstream.ErrCorrupted) + require.NotEqual(t, f.plaintext[off:off+length], got, "no corrupt plaintext is returned") + } +} + +// TestDecryptRangeTamperOutsideRange documents the authentication scope: chunks +// the range does not overlap are never fetched, so tampering there cannot be +// detected by (and does not disturb) a range read. Whole-object integrity is the +// caller's concern, per the DecryptRange docs. +func TestDecryptRangeTamperOutsideRange(t *testing.T) { + const size = 4 * rangeChunk + f := newRangeFixture(t, size) + off, length := int64(10), int64(100) // wholly inside chunk 0 + + headerLen := headerLenOf(f.blob, size) + tampered := bytes.Clone(f.blob) + tampered[headerLen+int64(3*(rangeChunk+aesstream.TagSize))+5] ^= 0x01 // chunk 3 + + r, err := fee.DecryptRange(bytes.NewReader(tampered), int64(len(tampered)), f.unwrapper, off, length) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, f.plaintext[off:off+length], got) + + // The same blob fails a whole-object decrypt, which does see chunk 3. + full, err := fee.Decrypt(bytes.NewReader(tampered), f.unwrapper) + require.NoError(t, err) + _, err = io.ReadAll(full) + require.ErrorIs(t, err, aesstream.ErrCorrupted) +} + +// TestDecryptRangeKidMismatch is the acceptance criterion that a kid matching no +// recipient is a clear error rather than a silent failure or a panic. +func TestDecryptRangeKidMismatch(t *testing.T) { + f := newRangeFixture(t, 2*rangeChunk) + + for name, u := range map[string]fee.RecipientUnwrapper{ + "unknown kid": fee.NewECDHESUnwrapper([]byte("did:key:zNobody#key-1"), newX25519Key(t)), + "empty kid": fee.NewECDHESUnwrapper(nil, newX25519Key(t)), + "right kid, wrong wrap algorithm": fee.NewA256KWUnwrapper( + []byte("did:example:custody#absent"), newKEK(t)), + } { + t.Run(name, func(t *testing.T) { + r, err := fee.DecryptRange(bytes.NewReader(f.blob), int64(len(f.blob)), u, 0, 100) + require.ErrorIs(t, err, fee.ErrNoMatchingRecipient) + require.Nil(t, r) + }) + } +} + +// TestDecryptRangeWrongKey confirms a matched recipient whose CEK cannot be +// recovered fails at construction, with no reader handed back — parity with the +// whole-object path. +func TestDecryptRangeWrongKey(t *testing.T) { + t.Run("ecdh-es", func(t *testing.T) { + f := newRangeFixture(t, 2*rangeChunk) + wrong := fee.NewECDHESUnwrapper(ecdhKID, newX25519Key(t)) + r, err := fee.DecryptRange(bytes.NewReader(f.blob), int64(len(f.blob)), wrong, 0, 100) + require.Error(t, err) + require.Nil(t, r) + }) + + t.Run("a256kw", func(t *testing.T) { + plaintext := patternBytes(2 * rangeChunk) + blob, err := encrypt(t, plaintext, []fee.Recipient{ + fee.NewA256KWRecipient(a256kwKID, newKEK(t)), + }, fee.WithChunkSize(rangeChunk)) + require.NoError(t, err) + + wrong := fee.NewA256KWUnwrapper(a256kwKID, newKEK(t)) + r, err := fee.DecryptRange(bytes.NewReader(blob), int64(len(blob)), wrong, 0, 100) + require.Error(t, err) + require.Nil(t, r) + }) +} + +// TestDecryptRangeEncrypt0NeedsCEK confirms a recipient-less envelope steers the +// caller to DecryptRangeWithCEK rather than failing obscurely. +func TestDecryptRangeEncrypt0NeedsCEK(t *testing.T) { + cek := newCEK(t) + blob, err := encryptWithCEK(t, patternBytes(rangeChunk), cek, nil, fee.WithChunkSize(rangeChunk)) + require.NoError(t, err) + + r, err := fee.DecryptRange(bytes.NewReader(blob), int64(len(blob)), + fee.NewA256KWUnwrapper(a256kwKID, newKEK(t)), 0, 100) + require.ErrorIs(t, err, fee.ErrNoRecipientsInEnvelope) + require.Nil(t, r) +} + +// TestDecryptRangeInvalidArgs covers the argument checks that must fire before any +// I/O or key handling. +func TestDecryptRangeInvalidArgs(t *testing.T) { + f := newRangeFixture(t, rangeChunk) + size := int64(len(f.blob)) + + t.Run("nil blob", func(t *testing.T) { + _, err := fee.DecryptRange(nil, size, f.unwrapper, 0, 10) + require.Error(t, err) + _, err = fee.DecryptRangeWithCEK(nil, size, newCEK(t), 0, 10, nil) + require.Error(t, err) + }) + + t.Run("nil unwrapper", func(t *testing.T) { + _, err := fee.DecryptRange(bytes.NewReader(f.blob), size, nil, 0, 10) + require.ErrorIs(t, err, fee.ErrNilUnwrapper) + }) + + t.Run("cek wrong length", func(t *testing.T) { + _, err := fee.DecryptRangeWithCEK(bytes.NewReader(f.blob), size, make([]byte, 16), 0, 10, nil) + require.ErrorIs(t, err, fee.ErrInvalidCEK) + }) + + t.Run("negative blob size", func(t *testing.T) { + _, err := fee.DecryptRange(bytes.NewReader(f.blob), -1, f.unwrapper, 0, 10) + require.Error(t, err) + _, err = fee.PlaintextSize(bytes.NewReader(f.blob), -1) + require.Error(t, err) + }) +} + +// TestDecryptRangeBounds pins the out-of-bounds and clamping semantics an HTTP +// range consumer depends on: a bad range is reported as aesstream.ErrRange (a +// 416), an offset at the end is a legal empty read, and an overlong length clamps. +func TestDecryptRangeBounds(t *testing.T) { + const size = 2*rangeChunk + 10 + f := newRangeFixture(t, size) + blobSize := int64(len(f.blob)) + + t.Run("rejected", func(t *testing.T) { + for name, rg := range map[string]struct{ off, length int64 }{ + "negative offset": {-1, 10}, + "negative length": {0, -1}, + "offset past end": {size + 1, 10}, + } { + t.Run(name, func(t *testing.T) { + _, err := fee.DecryptRange(bytes.NewReader(f.blob), blobSize, f.unwrapper, rg.off, rg.length) + require.ErrorIs(t, err, aesstream.ErrRange) + }) + } + }) + + t.Run("offset at end is empty", func(t *testing.T) { + for _, length := range []int64{0, 100} { + r, got := decryptRange(t, f.blob, f.unwrapper, size, length) + require.Zero(t, r.Len()) + require.Empty(t, got) + require.Equal(t, int64(size), r.Size()) + } + }) + + t.Run("length clamps to the end", func(t *testing.T) { + r, got := decryptRange(t, f.blob, f.unwrapper, size-5, math.MaxInt64) + require.Equal(t, int64(5), r.Len()) + require.Equal(t, f.plaintext[size-5:], got) + }) +} + +// TestDecryptRangeGeometryCorners covers the object sizes whose chunk geometry is +// degenerate: an empty plaintext (a single empty final chunk) and an object of +// exactly one full chunk. +func TestDecryptRangeGeometryCorners(t *testing.T) { + t.Run("empty plaintext", func(t *testing.T) { + f := newRangeFixture(t, 0) + r, got := decryptRange(t, f.blob, f.unwrapper, 0, 100) + require.Zero(t, r.Len()) + require.Zero(t, r.Size()) + require.Empty(t, got) + + _, err := fee.DecryptRange(bytes.NewReader(f.blob), int64(len(f.blob)), f.unwrapper, 1, 1) + require.ErrorIs(t, err, aesstream.ErrRange) + }) + + t.Run("exactly one chunk", func(t *testing.T) { + f := newRangeFixture(t, rangeChunk) + r, got := decryptRange(t, f.blob, f.unwrapper, 0, rangeChunk) + require.Equal(t, int64(rangeChunk), r.Size()) + require.Equal(t, f.plaintext, got) + + _, tail := decryptRange(t, f.blob, f.unwrapper, rangeChunk-1, 1) + require.Equal(t, f.plaintext[rangeChunk-1:], tail) + }) + + t.Run("one byte over a chunk", func(t *testing.T) { + f := newRangeFixture(t, rangeChunk+1) + _, got := decryptRange(t, f.blob, f.unwrapper, rangeChunk, 1) + require.Equal(t, f.plaintext[rangeChunk:], got) + }) +} + +// TestDecryptRangeChunkCountMismatch confirms the advisory consistency check: when +// the envelope records a chunk count, a blob size implying a different count is +// refused up front rather than silently serving a truncated view of the object. +func TestDecryptRangeChunkCountMismatch(t *testing.T) { + const size = 4 * rangeChunk + f := newRangeFixture(t, size, fee.WithContentLength(size)) + blobSize := int64(len(f.blob)) + encChunk := int64(rangeChunk + aesstream.TagSize) + + for name, claimed := range map[string]int64{ + "one chunk short": blobSize - encChunk, + "one chunk long": blobSize + encChunk, + } { + t.Run(name, func(t *testing.T) { + _, err := fee.DecryptRange(bytes.NewReader(f.blob), claimed, f.unwrapper, 0, 100) + require.ErrorIs(t, err, fee.ErrSizeMismatch) + + _, err = fee.PlaintextSize(bytes.NewReader(f.blob), claimed) + require.ErrorIs(t, err, fee.ErrSizeMismatch) + }) + } + + t.Run("correct size still works", func(t *testing.T) { + _, got := decryptRange(t, f.blob, f.unwrapper, rangeChunk, 100) + require.Equal(t, f.plaintext[rangeChunk:rangeChunk+100], got) + }) +} + +// TestDecryptRangeTrailingEmptyFinalChunk pins that the range path accepts the +// second valid encoding of an exact-multiple plaintext: k full chunks followed by +// an empty final chunk, declared as k+1. aesstream reads that layout and so does +// whole-object decryption, so the range entry points must agree rather than +// refusing a blob the rest of the package accepts. +func TestDecryptRangeTrailingEmptyFinalChunk(t *testing.T) { + const size = 3 * rangeChunk + cek, plaintext := newCEK(t), patternBytes(size) + baseNonce := bytes.Repeat([]byte{0xa5}, aesstream.BaseNonceSize) + blob := sealTrailingEmptyFinalChunk(t, plaintext, cek, baseNonce, rangeChunk) + blobSize := int64(len(blob)) + + // The premise: whole-object decryption already reads this blob. + whole, err := fee.DecryptWithCEK(bytes.NewReader(blob), cek) + require.NoError(t, err) + got, err := io.ReadAll(whole) + require.NoError(t, err) + require.Equal(t, plaintext, got) + + t.Run("PlaintextSize agrees", func(t *testing.T) { + n, err := fee.PlaintextSize(bytes.NewReader(blob), blobSize) + require.NoError(t, err) + require.Equal(t, int64(size), n) + }) + + t.Run("range across the last full chunk", func(t *testing.T) { + off, length := int64(2*rangeChunk-10), int64(20) + r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), blobSize, cek, off, length, nil) + require.NoError(t, err) + require.Equal(t, int64(size), r.Size()) + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, plaintext[off:off+length], got) + }) + + t.Run("whole object as one range", func(t *testing.T) { + r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), blobSize, cek, 0, size, nil) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, plaintext, got) + }) +} + +// TestDecryptRangeWrongBlobSizeNoChunkCount pins the documented trust model for an +// envelope with no chunk count: a wrong blob size cannot be caught at construction, +// so it surfaces as a failure to authenticate when the affected chunks are read. +func TestDecryptRangeWrongBlobSizeNoChunkCount(t *testing.T) { + const size = 4 * rangeChunk + f := newRangeFixture(t, size) // no WithContentLength, so no chunk count + blobSize := int64(len(f.blob)) + encChunk := int64(rangeChunk + aesstream.TagSize) + + t.Run("overstated size", func(t *testing.T) { + // The geometry says the object is a chunk longer than it is, so a range + // at the claimed end either runs off the end of the blob or reads a chunk + // under the wrong index and last-chunk flag. Either way it fails rather + // than emitting plaintext. + r, err := fee.DecryptRange(bytes.NewReader(f.blob), blobSize+encChunk, f.unwrapper, size-10, 100) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.Error(t, err) + require.True(t, errors.Is(err, aesstream.ErrShortSpan) || errors.Is(err, aesstream.ErrCorrupted), + "want a short-span or authentication failure, got %v", err) + require.NotEqual(t, f.plaintext[size-10:], got) + }) + + t.Run("understated size mislabels the final chunk", func(t *testing.T) { + // One chunk short: chunk 2 is now believed final, so its nonce carries + // the last-chunk flag and authentication fails. + r, err := fee.DecryptRange(bytes.NewReader(f.blob), blobSize-encChunk, f.unwrapper, + int64(2*rangeChunk), 100) + require.NoError(t, err) + _, err = io.ReadAll(r) + require.ErrorIs(t, err, aesstream.ErrCorrupted) + }) +} + +// TestDecryptRangeInvalidBlobSize covers blob sizes that cannot describe a FEE +// stream at all, independent of any chunk count. +func TestDecryptRangeInvalidBlobSize(t *testing.T) { + f := newRangeFixture(t, rangeChunk) + blobSize := int64(len(f.blob)) + + // A blob claiming fewer bytes than the envelope plus one tag leaves a + // ciphertext too short to be a stream. + _, err := fee.DecryptRange(bytes.NewReader(f.blob), blobSize-int64(rangeChunk)-aesstream.TagSize, + f.unwrapper, 0, 10) + require.ErrorIs(t, err, aesstream.ErrCiphertextSize) +} + +// TestDecryptRangeMalformedBlob confirms a blob that is not a FEE envelope is +// rejected on its own terms — with the cose sentinel a caller can classify — +// before any key material is touched. +func TestDecryptRangeMalformedBlob(t *testing.T) { + f := newRangeFixture(t, rangeChunk) + + for name, tc := range map[string]struct { + blob []byte + want error + }{ + "empty blob": {[]byte{}, cose.ErrMalformed}, + "truncated blob": {f.blob[:3], cose.ErrMalformed}, + // A well-formed CBOR item that is not a tag: decodable, but not a COSE + // envelope. + "not a cose tag": {[]byte{0x01, 0x02, 0x03}, cose.ErrNotEncrypt}, + } { + t.Run(name, func(t *testing.T) { + _, err := fee.DecryptRange(bytes.NewReader(tc.blob), int64(len(tc.blob)), f.unwrapper, 0, 10) + require.ErrorIs(t, err, tc.want) + + _, err = fee.PlaintextSize(bytes.NewReader(tc.blob), int64(len(tc.blob))) + require.ErrorIs(t, err, tc.want) + }) + } + + t.Run("random bytes", func(t *testing.T) { + garbage := make([]byte, 512) + _, err := rand.Read(garbage) + require.NoError(t, err) + // Random bytes are rejected either as un-decodable CBOR or as a + // well-formed item that is not a COSE tag, depending on the first byte. + _, err = fee.DecryptRange(bytes.NewReader(garbage), int64(len(garbage)), f.unwrapper, 0, 10) + require.Error(t, err) + require.True(t, errors.Is(err, cose.ErrMalformed) || errors.Is(err, cose.ErrNotEncrypt), + "want a cose decode error, got %v", err) + }) + + t.Run("header truncated mid-envelope", func(t *testing.T) { + // A blob size that stops inside the envelope: the probe cannot complete a + // decode and must report it rather than looping. + _, err := fee.DecryptRange(bytes.NewReader(f.blob), 20, f.unwrapper, 0, 10) + require.ErrorIs(t, err, cose.ErrMalformed) + }) +} + +// TestDecryptRangeMalformedBlobReadsOnce pins that a blob whose leading bytes +// decode completely but are not a FEE envelope is rejected on the strength of the +// first read. Only a prefix cut short mid-item can be answered by reading more, so +// a wrong object id costs one 4 KiB read rather than a walk up to maxHeaderLen +// against the origin. +func TestDecryptRangeMalformedBlobReadsOnce(t *testing.T) { + f := newRangeFixture(t, rangeChunk) + + // Each prefix is a complete CBOR item, so no larger read can change the + // verdict; the trailing zeroes stand in for a large stored object. + for name, tc := range map[string]struct { + prefix string + want error + }{ + // A bare integer: a whole item, but not a tag. + "not a cose tag": {"01", cose.ErrNotEncrypt}, + // Tag 96 wrapping a 3-element array, where 4 are required. + "wrong array length": {"d8608340a0f6", cose.ErrMalformed}, + } { + t.Run(name, func(t *testing.T) { + blob := make([]byte, 10<<20) + copy(blob, hexBytes(t, tc.prefix)) + + rec := newRecordingReaderAt(t, blob) + _, err := fee.DecryptRange(rec, int64(len(blob)), f.unwrapper, 0, 10) + require.ErrorIs(t, err, tc.want) + require.Len(t, rec.reads, 1, "a complete but invalid prefix must not be re-read") + }) + } +} + +// TestDecryptRangeLargeEnvelope exercises the header probe's growth path: with +// enough recipients the envelope exceeds the first probe size, and the probe must +// still recover the exact header length so the ciphertext is located correctly. +func TestDecryptRangeLargeEnvelope(t *testing.T) { + const recipients = 64 + priv := newX25519Key(t) + rs := []fee.Recipient{fee.NewECDHESRecipient(ecdhKID, priv.PublicKey())} + for i := 0; i < recipients; i++ { + other, err := ecdh.X25519().GenerateKey(rand.Reader) + require.NoError(t, err) + rs = append(rs, fee.NewECDHESRecipient([]byte("did:key:filler#key-"+string(rune('a'+i%26))+string(rune('a'+i/26))), other.PublicKey())) + } + + const size = 2 * rangeChunk + plaintext := patternBytes(size) + blob, err := encrypt(t, plaintext, rs, fee.WithChunkSize(rangeChunk)) + require.NoError(t, err) + + require.Greater(t, headerLenOf(blob, size), int64(4096), "the envelope must exceed the first probe size") + + off, length := int64(rangeChunk+7), int64(300) + _, got := decryptRange(t, blob, fee.NewECDHESUnwrapper(ecdhKID, priv), off, length) + require.Equal(t, plaintext[off:off+length], got) +} + +// TestPlaintextSize confirms the header-only size query matches the real plaintext +// length across geometries, without key material. +func TestPlaintextSize(t *testing.T) { + for _, size := range []int{0, 1, rangeChunk - 1, rangeChunk, rangeChunk + 1, 3*rangeChunk + 100} { + f := newRangeFixture(t, size) + got, err := fee.PlaintextSize(bytes.NewReader(f.blob), int64(len(f.blob))) + require.NoError(t, err) + require.Equal(t, int64(size), got, "plaintext size for a %d-byte object", size) + } + + t.Run("reads only the header", func(t *testing.T) { + f := newRangeFixture(t, 8*rangeChunk) + rec := newRecordingReaderAt(t, f.blob) + _, err := fee.PlaintextSize(rec, int64(len(f.blob))) + require.NoError(t, err) + for _, rd := range rec.reads { + require.Equal(t, int64(0), rd.off) + require.LessOrEqual(t, rd.n, int64(4096)) + } + }) +} + +// TestDecryptRangeMatchesFullDecrypt cross-checks the range path against the +// whole-object path: for a set of ranges over the same blob, range decryption must +// agree byte-for-byte with the corresponding slice of a full Decrypt. +func TestDecryptRangeMatchesFullDecrypt(t *testing.T) { + const size = 5*rangeChunk + 123 + f := newRangeFixture(t, size, fee.WithContentLength(size)) + + full := decryptAll(t, f.blob, f.unwrapper) + require.Equal(t, f.plaintext, full) + + for _, rg := range []struct{ off, length int64 }{ + {0, 1}, {1, rangeChunk}, {rangeChunk - 1, 2}, {2 * rangeChunk, 3 * rangeChunk}, + {5 * rangeChunk, 123}, {size - 1, 1}, + } { + _, got := decryptRange(t, f.blob, f.unwrapper, rg.off, rg.length) + require.Equal(t, full[rg.off:rg.off+int64(len(got))], got) + } +} diff --git a/vectors/README.md b/vectors/README.md index 45b8f52..c4d0434 100644 --- a/vectors/README.md +++ b/vectors/README.md @@ -26,11 +26,14 @@ to it and this repo matches it (see [Wire format](#wire-format)). | `multi-chunk-go` | Go seals → TS decrypts (extra multi-chunk coverage) | tag 16 | | `empty-file-go` | Go seals → TS decrypts | tag 16 | | `empty-file-ts` | TS seals → Go decrypts | tag 16 | +| `exact-multiple-go` | Go seals → TS decrypts (plaintext is exactly 3 chunks) | tag 16 | -Three framing cases are covered in both directions: a single chunk, a final -partial chunk (the multi-chunk fixtures end mid-chunk), and the empty file. An -empty plaintext encodes as one empty final chunk, so its whole body is a bare -16-byte tag; `TestVectors` asserts the ciphertext length for every fixture. +Four framing cases are covered: a single chunk, a final partial chunk (the +multi-chunk fixtures end mid-chunk), the empty file, and a full final chunk +(`exact-multiple-go`, whose plaintext is exactly 3 chunks). The partial final +chunk and the empty file run in both directions. An empty plaintext encodes as +one empty final chunk, so its whole body is a bare 16-byte tag; `TestVectors` +asserts the ciphertext length for every fixture. Each `testdata//` holds `blob.bin` (`envelope‖ciphertext`), `plaintext.bin`, and `meta.json`. @@ -62,6 +65,13 @@ recipient = [ {1: alg}, {4: kid, ...}, wrappedKey ] # alg -31 or -5 `baseNonce[7] ‖ chunkIndex[4, big-endian] ‖ lastFlag[1]` (`0x01` on the final chunk), tag 16 bytes. Chunk count is `max(1, ceil(plaintextLen / chunkSize))`, so an empty plaintext still seals one (empty) final chunk. +- **Chunk count on decode** — the formula above is the producer's rule, which + both implementations follow, so `exact-multiple-go` declares 3 chunks rather + than 3 full chunks plus an empty one. A decoder must not re-derive the count + that way: a stream ending in an empty final chunk holds one chunk more than + the formula gives for the same plaintext, and both forms decrypt identically. + The declared count is authoritative, and only the ciphertext length tells the + two forms apart. - **Body AAD** — `Enc_structure = [ context, protected, "" ]`, the **same** for every chunk. `context` follows the envelope structure per RFC 9052 §5.3: `"Encrypt"` for a tag-96 envelope, `"Encrypt0"` for tag-16. AAD interop is diff --git a/vectors/testdata/exact-multiple-go/blob.bin b/vectors/testdata/exact-multiple-go/blob.bin new file mode 100644 index 0000000..6c57409 Binary files /dev/null and b/vectors/testdata/exact-multiple-go/blob.bin differ diff --git a/vectors/testdata/exact-multiple-go/meta.json b/vectors/testdata/exact-multiple-go/meta.json new file mode 100644 index 0000000..cd7803c --- /dev/null +++ b/vectors/testdata/exact-multiple-go/meta.json @@ -0,0 +1,12 @@ +{ + "name": "exact-multiple-go", + "producer": "go", + "description": "Plaintext of exactly 3 chunks (full final chunk) encrypted in Go; decrypts in foc-encryption (TS).", + "tag": 16, + "algorithm": -65793, + "typ": "application/vnd.foc-envelope+cose", + "chunk_size": 4096, + "chunk_count": 3, + "cek_hex": "1b73245d0995604fb9b7040935a46bc6db4e2b6ec9f907b8a528810925166867", + "base_nonce_hex": "baf5c82a5faa4c" +} diff --git a/vectors/testdata/exact-multiple-go/plaintext.bin b/vectors/testdata/exact-multiple-go/plaintext.bin new file mode 100644 index 0000000..0aeebd6 --- /dev/null +++ b/vectors/testdata/exact-multiple-go/plaintext.bin @@ -0,0 +1 @@ +ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ \ No newline at end of file diff --git a/vectors/vectors_test.go b/vectors/vectors_test.go index 49397fc..d6fcbb0 100644 --- a/vectors/vectors_test.go +++ b/vectors/vectors_test.go @@ -215,6 +215,16 @@ func TestGenerate(t *testing.T) { "Empty plaintext encrypted in Go (one empty final chunk, tag-only body); decrypts in foc-encryption (TS).", []byte{}) + // A plaintext that is an exact multiple of the chunk size, where the final + // chunk is full rather than short. Both implementations declare ceil(len / + // chunkSize) chunks here, so the fixture pins that the boundary agrees + // on the wire: a producer that instead appended an empty final chunk would + // declare one more, and a decoder must not derive the count from the + // plaintext length. + genGoBody(t, "exact-multiple-go", + "Plaintext of exactly 3 chunks (full final chunk) encrypted in Go; decrypts in foc-encryption (TS).", + bytes.Repeat([]byte{0x5a}, 3*vectorChunkSize)) + // AC3 — multi-recipient envelope sealed in Go (tag 96) with a real // ECDH-ES+A256KW (X25519) recipient and a real A256KW recipient. genGoMultiRecipient(t, "multi-recipient-go",