From 82aac4f075bdfd1dc2cfe7d062d99b936b1bac3f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 13:39:50 +0000 Subject: [PATCH 01/18] feat: range decryption API on top of the fee package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DecryptRange / DecryptRangeWithCEK to the root fee package, so a caller can decrypt one plaintext byte range of a stored FEE blob without fetching or decrypting the whole object — and without reaching into fee/aesstream or the COSE/wrap sub-packages to do it. The range path reuses the envelope-decode and recipient-unwrap logic that Decrypt uses (matchRecipient, RecipientUnwrapper.unwrap), which is why it lives inside the fee package rather than a sub-package, and adds only the range piece: locating the chunk-aligned ciphertext span and handing it to aesstream's range primitive instead of the whole stream. - RangeReader exposes Len (the clamped range length) and Size (the whole object's plaintext size), so an HTTP consumer can fill in Content-Length and Content-Range before any ciphertext is read, plus CiphertextSpan so a remote-backed caller can prefetch the span in a single range request. - PlaintextSize answers an object's decrypted size from the envelope header alone, with no key material — for HEAD responses and suffix ranges. - The envelope header is located by probing a small prefix with cose.Decode, which reports its exact encoded length; the probe grows only for unusually large envelopes and is bounded at 1 MiB. - When the envelope records a chunk count, it is cross-checked against the geometry the blob size implies (ErrSizeMismatch), catching a stale or wrong size before any plaintext is served. The count is unprotected metadata, so this is an operational check, not an integrity guarantee — documented as such. openStream's body-header validation is extracted into a shared validateBody helper so the whole-object and range paths accept exactly the same envelopes and report the same errors. No behavior change on the existing path. Every chunk a range overlaps is authenticated, so a tampered chunk fails rather than yielding corrupt plaintext; chunks outside the range are never fetched and so never checked, which the docs spell out along with the trust placed in the supplied blob size. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CF6S9hmvMkpTkDyRvYopeN --- README.md | 98 +++--- example_range_test.go | 63 ++++ fee.go | 72 ++-- range.go | 293 ++++++++++++++++ range_test.go | 779 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1231 insertions(+), 74 deletions(-) create mode 100644 example_range_test.go create mode 100644 range.go create mode 100644 range_test.go diff --git a/README.md b/README.md index 58d6da0..44687b3 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` plus byte-range `DecryptRange`. 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). | @@ -254,73 +254,63 @@ 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)) - 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) +// 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 + 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. + 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. ## Wire format diff --git a/example_range_test.go b/example_range_test.go new file mode 100644 index 0000000..e9b991c --- /dev/null +++ b/example_range_test.go @@ -0,0 +1,63 @@ +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..23abcee 100644 --- a/fee.go +++ b/fee.go @@ -62,12 +62,19 @@ // 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. +// // # 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 ( @@ -476,17 +483,51 @@ 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, err := validateBody(env) + if err != nil { + return nil, err + } + r, err := aesstream.NewReader(ciphertext, aesstream.Config{ + Key: cek, + BaseNonce: body.baseNonce, + AAD: body.aad, + ChunkSize: body.chunkSize, + }) + 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 decrypt the detached ciphertext +// apart from the content-encryption key. +type bodyParams struct { + baseNonce []byte + chunkSize int + aad []byte +} + +// validateBody 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 — and rebuilds the Enc_structure AAD +// that the encoder bound into every chunk. +// +// It is shared by the whole-object path ([openStream]) and the range path +// ([newRangeReader]), so both accept exactly the same envelopes and report the +// same errors for a body header they cannot honour. +func validateBody(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,12 +538,12 @@ 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) } @@ -511,19 +552,10 @@ func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader // 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{}, 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 r, nil + return bodyParams{baseNonce: baseNonce, chunkSize: int(chunkSize), aad: aad}, nil } // matchRecipient returns the first recipient whose kid equals want. A recipient diff --git a/range.go b/range.go new file mode 100644 index 0000000..f1c3aac --- /dev/null +++ b/range.go @@ -0,0 +1,293 @@ +package fee + +import ( + "errors" + "fmt" + "io" + + "github.com/filecoin-project/go-fee/aesstream" + "github.com/filecoin-project/go-fee/cose" +) + +// ErrSizeMismatch means the ciphertext length implied by the supplied blob size +// 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") + +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, 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 + 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) { return r.sr.Read(p) } + +// 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.sr.Len() } + +// Size returns the total plaintext size of the whole object, derived from the +// blob size and the envelope's chunk size. It is the total an HTTP consumer puts +// after the slash in a Content-Range header. +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 that when the envelope carries a chunk count, but +// 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). +// +// 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) (*RangeReader, error) { + if len(cek) != aesstream.KeySize { + return nil, fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek)) + } + 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 := validateBody(env) + if err != nil { + return 0, err + } + return plaintextSizeFor(env, blobSize-headerLen, body.chunkSize) +} + +// newRangeReader is the shared core of DecryptRange and 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, err := validateBody(env) + if err != nil { + return nil, err + } + + ciphertextSize := blobSize - headerLen + plainSize, err := plaintextSizeFor(env, ciphertextSize, body.chunkSize) + if err != nil { + return nil, err + } + + start, n, _, err := aesstream.CiphertextRange(ciphertextSize, body.chunkSize, off, length) + if err != nil { + return nil, fmt.Errorf("fee: resolving ciphertext range: %w", err) + } + + // 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), + aesstream.Config{ + Key: cek, + BaseNonce: body.baseNonce, + AAD: body.aad, + ChunkSize: body.chunkSize, + }, + ciphertextSize, off, length) + if err != nil { + return nil, fmt.Errorf("fee: initializing body cipher: %w", err) + } + + return &RangeReader{sr: sr, size: plainSize, spanOff: headerLen + start, spanLen: n}, nil +} + +// plaintextSizeFor derives the object's total plaintext size from its ciphertext +// length and chunk size, and cross-checks the envelope's declared chunk count +// against it when one is present — catching a blob size that describes a +// different object than the envelope does. +func plaintextSizeFor(env *cose.Envelope, ciphertextSize int64, chunkSize int) (int64, error) { + plainSize, err := aesstream.DecryptedSize(ciphertextSize, chunkSize) + if err != nil { + return 0, fmt.Errorf("fee: blob of %d ciphertext bytes: %w", ciphertextSize, 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) + } + if want := chunkCountFor(plainSize, int64(chunkSize)); declared != want { + return 0, fmt.Errorf("%w: envelope declares %d chunks, the blob size implies %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, errors.New("fee: nil blob reader") + } + if blobSize < 0 { + return nil, 0, fmt.Errorf("fee: negative blob size %d", blobSize) + } + + probe := int64(headerProbeSize) + if blobSize < probe { + probe = blobSize + } + 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 + } + // A type mismatch is decided only after a complete decode, so a longer + // prefix cannot change the answer. + if errors.Is(derr, cose.ErrUnexpectedType) || atEnd || probe >= blobSize || probe >= maxHeaderLen { + return nil, 0, fmt.Errorf("fee: decoding envelope: %w", derr) + } + probe = min(probe*2, min(blobSize, int64(maxHeaderLen))) + } +} diff --git a/range_test.go b/range_test.go new file mode 100644 index 0000000..2a4ade8 --- /dev/null +++ b/range_test.go @@ -0,0 +1,779 @@ +package fee_test + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "errors" + "io" + "math" + "sort" + "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 []byte + 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: 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 := bytes.NewReader(r.blob).ReadAt(p, off) + r.reads = append(r.reads, readInterval{off: off, n: int64(n)}) + return n, err +} + +// ciphertextBytesRead totals the bytes served from at or after headerEnd — the +// ciphertext the decrypt actually fetched, excluding the header probe. +func (r *recordingReaderAt) ciphertextBytesRead(headerEnd int64) int64 { + var total int64 + for _, rd := range r.reads { + if rd.off >= headerEnd { + total += rd.n + } + } + return total +} + +// covered reports the set of blob offsets the reads touched, as a sorted list of +// merged [off, end) intervals. +func (r *recordingReaderAt) covered() []readInterval { + if len(r.reads) == 0 { + return nil + } + sorted := append([]readInterval(nil), r.reads...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].off < sorted[j].off }) + merged := []readInterval{sorted[0]} + for _, rd := range sorted[1:] { + last := &merged[len(merged)-1] + if rd.off <= last.off+last.n { + if end := rd.off + rd.n; end > last.off+last.n { + last.n = end - last.off + } + continue + } + merged = append(merged, rd) + } + return merged +} + +// 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) +} + +// 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 short final chunk, and single bytes at each end. +func TestDecryptRangeRoundTrip(t *testing.T) { + const size = 3*rangeChunk + rangeChunk/2 // 3.5 chunks + f := newRangeFixture(t, 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}, + {"whole short final chunk", 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 := tc.length + if avail := int64(size) - tc.off; wantLen > avail { + wantLen = avail + } + require.Equal(t, wantLen, r.Len(), "Len is the clamped range length") + require.Equal(t, int64(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) + 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) + 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 := append([]readInterval(nil), 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") + + // And the whole object was never pulled in: header probe plus one chunk. + var total int64 + for _, rd := range rec.covered() { + total += rd.n + } + require.Less(t, total, int64(len(f.blob)), "the full blob must never be read") +} + +// 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 _, off := range []int64{0, rangeChunk + 1, size} { + rec := newRecordingReaderAt(t, f.blob) + r, err := fee.DecryptRange(rec, int64(len(f.blob)), f.unwrapper, off, 0) + 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(prefetchedBlob{span: span, spanOff: spanOff, header: f.blob[: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 []byte + span []byte + spanOff int64 +} + +func (b prefetchedBlob) ReadAt(p []byte, off int64) (int, error) { + if off < b.spanOff { + return bytes.NewReader(b.header).ReadAt(p, off) + } + return bytes.NewReader(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 := int64(len(f.blob)) - aesstream.EncryptedSize(size, rangeChunk) + // 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 := append([]byte(nil), 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 := int64(len(f.blob)) - aesstream.EncryptedSize(size, rangeChunk) + tampered := append([]byte(nil), 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) + 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) + 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) + }) +} + +// 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) + }) +} + +// 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) + + headerLen := int64(len(blob)) - aesstream.EncryptedSize(size, rangeChunk) + require.Greater(t, headerLen, 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) + } +} From f609a6d87d8d8ff3f33b238514b62a2295fef5ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Aug 2026 11:06:57 +0200 Subject: [PATCH 02/18] docs: empty range request Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 44687b3..527b899 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,11 @@ func serveRange(w http.ResponseWriter, f *os.File, size int64, u fee.RecipientUn // 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) From 51943e86bb3500baf0003727a0d78b0a649e915c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Aug 2026 11:07:25 +0200 Subject: [PATCH 03/18] example_range_test: support empty range Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- example_range_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/example_range_test.go b/example_range_test.go index e9b991c..8438ff6 100644 --- a/example_range_test.go +++ b/example_range_test.go @@ -48,8 +48,11 @@ func ExampleDecryptRange() { // 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()) + if r.Len() == 0 { + fmt.Printf("Content-Range: bytes */%d\n", r.Size()) + return + } 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) From bc758e0ac4ff16cde486eb6dae8c0f1bdea06969 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 17:17:09 +0000 Subject: [PATCH 04/18] feat: cacheable body material for envelope-free range decryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A range decrypt currently has to read and CBOR-decode the envelope at the front of the blob before it can touch a single ciphertext byte, because that is where the base nonce, chunk size and AAD live. For a caller fronting a remote object store that is a round trip per range request, spent re-reading a fixed prefix that never changes. BodyMaterial is what a caller needs to skip it: the envelope's encoded length, base nonce, chunk size and AAD. EncryptedBlob.Material reports it from the encrypt call (complete before any plaintext is read, so a writer can record it while the upload streams), and DecryptRangeWithMaterial consumes it on the read path, fetching only the chunks the range overlaps. Measured on a 3-chunk object, a 50-byte range goes from reads at {0,4096} + {201,4112} to just {201,4112} — the header probe disappears. Cache the AAD, not the protected header --------------------------------------- The obvious thing to cache is the protected header, since that is what the Enc_structure is built from. It does not work on its own. The Enc_structure is [context, protected, external_aad], and the context differs between a COSE_Encrypt ("Encrypt") and a recipient-less COSE_Encrypt0 ("Encrypt0") — a distinction a bare material value has no way to record. Caching the protected header alone therefore needs a companion flag for the envelope form, which means an extra field here, an extra column in any store that persists this, and a new way to get it wrong. Caching the finished AAD sidesteps all of it. encryptStream already computes the Enc_structure via Envelope.EncStructure, which resolves the context for whichever form is being written, so the cached bytes are correct for both and BodyMaterial stays form-agnostic. No flag, no reconstruction logic, and no new cose export — the bytes go straight to aesstream.Config. Nothing is lost: the protected header is the structure's second element, still recoverable from the AAD. The cost is roughly 12 bytes of CBOR framing. Callers persisting this material should store the AAD rather than the protected header for the same reason. Safety ------ A stale or corrupted cache cannot serve wrong data. BaseNonce and AAD are bound into every chunk's GCM tag, and HeaderLen and ChunkSize decide which bytes are read and under which nonce, so a drifted value fails with aesstream.ErrCorrupted rather than emitting plausible plaintext. Validate catches a partial record up front. The material is entirely non-secret — all of it is already in the clear at the front of the stored blob — and deliberately excludes the CEK. One caveat, documented on DecryptRangeWithMaterial: with no envelope to consult, the declared chunk-count cross-check that yields ErrSizeMismatch cannot run, so nothing detects a blobSize that disagrees with the stored object. A caller that records the blob's size alongside this material should compare the two before trusting a range. Encrypt and EncryptWithCEK now return *EncryptedBlob instead of io.ReadCloser. It still satisfies io.ReadCloser, so ordinary use is unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CTgvEMV8qaHdKrPswx8bHN --- example_material_test.go | 101 +++++++++++ fee.go | 55 +++++- material.go | 118 +++++++++++++ material_test.go | 360 +++++++++++++++++++++++++++++++++++++++ range.go | 58 ++++++- 5 files changed, 683 insertions(+), 9 deletions(-) create mode 100644 example_material_test.go create mode 100644 material.go create mode 100644 material_test.go diff --git a/example_material_test.go b/example_material_test.go new file mode 100644 index 0000000..348423e --- /dev/null +++ b/example_material_test.go @@ -0,0 +1,101 @@ +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 []byte + headerLen int64 + envelope int64 // bytes served from below headerLen +} + +func (c *countingBlob) ReadAt(p []byte, off int64) (int, error) { + n, err := bytes.NewReader(c.blob).ReadAt(p, off) + if off < c.headerLen { + c.envelope += min(int64(n), c.headerLen-off) + } + return n, err +} + +// ExampleDecryptRangeWithMaterial 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 +// [EncryptedBlob.Material] reports, and the reader rebuilds a decryptor from +// those columns alone. +func ExampleDecryptRangeWithMaterial() { + 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) + } + + plaintext := []byte("the quick brown fox jumps over the lazy dog") + enc, 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) + } + + // Complete before a byte is read, so a writer can record it while the upload + // is still streaming. + material := enc.Material() + + 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 material, plus + // the blob's exact size. + row := struct { + material fee.BodyMaterial + size int64 + }{material, 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: blob, headerLen: row.material.HeaderLen} + const off, length = 4, 15 + r, err := fee.DecryptRangeWithMaterial(src, row.size, row.material, cek, off, length) + 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/fee.go b/fee.go index 23abcee..90888bf 100644 --- a/fee.go +++ b/fee.go @@ -206,7 +206,11 @@ 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 returned [EncryptedBlob] is an io.ReadCloser over the blob; its +// [EncryptedBlob.Material] reports the envelope parameters a later range decrypt +// needs, for a caller that wants to cache them rather than re-read the header. +func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption) (*EncryptedBlob, error) { if len(recipients) == 0 { return nil, ErrNoRecipients } @@ -244,7 +248,11 @@ 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) { +// +// As with [Encrypt], the returned [EncryptedBlob] carries the envelope +// parameters a later range decrypt needs; see [EncryptedBlob.Material]. 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) (*EncryptedBlob, error) { if len(cek) != aesstream.KeySize { return nil, fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek)) } @@ -262,7 +270,7 @@ 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) (*EncryptedBlob, error) { if plaintext == nil { return nil, errors.New("fee: nil plaintext reader") } @@ -376,9 +384,20 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts _ = pw.CloseWithError(cerr) }() - return &encryptReader{ - body: io.MultiReader(bytes.NewReader(header), pr), - pr: pr, + // Every value the material reports is already fixed above, before any + // plaintext is read, so it is complete the moment this returns — a caller can + // record it without waiting for (or even performing) the read. + return &EncryptedBlob{ + encryptReader: encryptReader{ + body: io.MultiReader(bytes.NewReader(header), pr), + pr: pr, + }, + material: BodyMaterial{ + HeaderLen: int64(len(header)), + BaseNonce: baseNonce, + ChunkSize: cfg.chunkSize, + AAD: aad, + }, }, nil } @@ -391,10 +410,32 @@ func chunkCountFor(nPlain, chunkSize int64) int64 { return (nPlain + chunkSize - 1) / chunkSize } -// encryptReader is the io.ReadCloser returned by [Encrypt] / [EncryptWithCEK]. +// EncryptedBlob is the io.ReadCloser returned by [Encrypt] / [EncryptWithCEK]: +// a stream over the wire blob (envelope||ciphertext), plus the envelope +// parameters a later range decrypt needs. +// // 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. +type EncryptedBlob struct { + encryptReader + material BodyMaterial +} + +// Material reports the envelope parameters that [DecryptRangeWithMaterial] needs +// to decrypt a byte range of this blob without re-reading its header — for a +// caller that stores blobs remotely and keeps metadata of its own alongside +// them. +// +// The result is complete as soon as the blob is constructed: every value is +// fixed before any plaintext is read, so it can be recorded without reading (or +// even finishing) the stream. It describes a recipient-less COSE_Encrypt0 just +// as it does a COSE_Encrypt. +// +// Each call returns an independent copy; mutating it does not affect the blob. +func (b *EncryptedBlob) Material() BodyMaterial { return b.material.clone() } + +// encryptReader carries the streaming half of an [EncryptedBlob]. type encryptReader struct { body io.Reader // io.MultiReader(header, pipe reader) pr *io.PipeReader // closing it stops the encryption goroutine diff --git a/material.go b/material.go new file mode 100644 index 0000000..23e1e5d --- /dev/null +++ b/material.go @@ -0,0 +1,118 @@ +package fee + +import ( + "bytes" + "errors" + "fmt" + + "github.com/filecoin-project/go-fee/aesstream" +) + +// ErrIncompleteMaterial means a [BodyMaterial] is missing a field, or carries one +// that cannot describe a FEE body — a value that could not decrypt anything. +var ErrIncompleteMaterial = errors.New("fee: incomplete body material") + +// BodyMaterial 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 obtained from [EncryptedBlob.Material] at +// encryption time and consumed by [DecryptRangeWithMaterial]. +// +// 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 [BodyMaterial.Validate]. +type BodyMaterial 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 + // BodyMaterial 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. +// +// [DecryptRangeWithMaterial] calls it, so a bad value fails there with +// [ErrIncompleteMaterial] rather than as an authentication error further down. +func (m BodyMaterial) Validate() error { + if m.HeaderLen <= 0 { + return fmt.Errorf("%w: header length %d is not positive", ErrIncompleteMaterial, m.HeaderLen) + } + if len(m.BaseNonce) != aesstream.BaseNonceSize { + return fmt.Errorf("%w: base nonce is %d bytes, want %d", + ErrIncompleteMaterial, 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]", + ErrIncompleteMaterial, m.ChunkSize, aesstream.MinChunkSize, aesstream.MaxChunkSize) + } + if len(m.AAD) == 0 { + return fmt.Errorf("%w: missing AAD", ErrIncompleteMaterial) + } + 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 +// [DecryptRangeWithMaterial]. It reports [ErrIncompleteMaterial] for an unusable +// m, and [aesstream.ErrCiphertextSize] if blobSize cannot describe a FEE blob at +// this header length and chunk size. +func (m BodyMaterial) PlaintextSize(blobSize int64) (int64, error) { + if err := m.Validate(); err != nil { + return 0, err + } + ciphertextSize := blobSize - m.HeaderLen + if ciphertextSize < 0 { + return 0, fmt.Errorf("fee: blob size %d is shorter than its %d-byte envelope: %w", + blobSize, m.HeaderLen, aesstream.ErrCiphertextSize) + } + n, err := aesstream.DecryptedSize(ciphertextSize, m.ChunkSize) + if err != nil { + return 0, fmt.Errorf("fee: blob of %d ciphertext bytes: %w", ciphertextSize, err) + } + return n, nil +} + +// clone returns a deep copy, so a BodyMaterial 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 BodyMaterial) clone() BodyMaterial { + m.BaseNonce = bytes.Clone(m.BaseNonce) + m.AAD = bytes.Clone(m.AAD) + return m +} diff --git a/material_test.go b/material_test.go new file mode 100644 index 0000000..044cdaf --- /dev/null +++ b/material_test.go @@ -0,0 +1,360 @@ +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 BodyMaterial 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 material. 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 +} + +// material rebuilds the BodyMaterial from the persisted columns, as a reader +// would after loading the row. +func (r blobLocationRow) material() fee.BodyMaterial { + return fee.BodyMaterial{ + HeaderLen: r.headerLen, + BaseNonce: r.baseNonce, + ChunkSize: int(r.chunkSize), + AAD: r.aad, + } +} + +// encryptWithMaterial seals plaintext under cek and returns the wire blob +// together with the material captured from the encrypt call — the write half of +// the store flow. +func encryptWithMaterial(t *testing.T, plaintext, cek []byte, recipients []fee.Recipient, opts ...fee.EncryptOption) ([]byte, fee.BodyMaterial) { + t.Helper() + enc, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, recipients, opts...) + require.NoError(t, err) + // Captured before a single byte is read, which is what lets a writer record + // the row while the upload is still streaming. + mat := enc.Material() + blob, err := io.ReadAll(enc) + require.NoError(t, err) + require.NoError(t, enc.Close()) + return blob, mat +} + +// requireNoEnvelopeRead asserts that nothing below headerLen was fetched: the +// whole purpose of caching the material 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 material 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, mat := encryptWithMaterial(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: mat.HeaderLen, + baseNonce: mat.BaseNonce, + chunkSize: int64(mat.ChunkSize), + aad: mat.AAD, + size: int64(len(blob)), + } + + t.Run("material 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) + require.Equal(t, int64(len(blob)-len(rest)), mat.HeaderLen) + + iv, ok := env.Headers.Unprotected.Bytes(cose.HeaderLabelIV) + require.True(t, ok) + require.Equal(t, iv, mat.BaseNonce) + require.Equal(t, rangeChunk, mat.ChunkSize) + + aad, err := env.EncStructure(nil) + require.NoError(t, err) + require.Equal(t, aad, mat.AAD) + }) + + t.Run("plaintext size from the row alone", func(t *testing.T) { + got, err := row.material().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 mat or the envelope. + cek, err := aeskw.Unwrap(regionKEK, row.regionWrappedCEK) + require.NoError(t, err) + defer clear(cek) + + recording := newRecordingReaderAt(t, blob) + r, err := fee.DecryptRangeWithMaterial(recording, row.size, row.material(), + cek, tc.off, tc.length) + require.NoError(t, err) + + // Computed without off+length, which overflows for an open-ended range. + end := int64(size) + if tc.length < end-tc.off { + end = tc.off + tc.length + } + want := plaintext[tc.off:end] + 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) + }) + } +} + +// TestEncryptedBlobMaterialIsACopy pins that Material hands back an independent +// value each call, so a caller that adjusts one — or a store that reuses the +// buffers it read a row into — cannot reach back into the blob's own state. +func TestEncryptedBlobMaterialIsACopy(t *testing.T) { + enc, err := fee.EncryptWithCEK(bytes.NewReader(patternBytes(64)), newCEK(t), nil, + fee.WithChunkSize(rangeChunk)) + require.NoError(t, err) + defer enc.Close() + + first := enc.Material() + first.AAD[0] ^= 0xff + first.BaseNonce[0] ^= 0xff + + second := enc.Material() + require.NotEqual(t, first.AAD, second.AAD) + require.NotEqual(t, first.BaseNonce, second.BaseNonce) +} + +// TestDecryptRangeWithMaterialEncrypt0 pins that material is envelope-form +// agnostic: a recipient-less COSE_Encrypt0 yields usable material with no flag +// and no special case, which is what lets BodyMaterial cache the finished AAD +// rather than a protected header plus a context discriminator. +func TestDecryptRangeWithMaterialEncrypt0(t *testing.T) { + const size = 2 * rangeChunk + plaintext := patternBytes(size) + cek := newCEK(t) + + blob, mat := encryptWithMaterial(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, uint64(16), tag) + + recording := newRecordingReaderAt(t, blob) + r, err := fee.DecryptRangeWithMaterial(recording, int64(len(blob)), mat, cek, 10, 4000) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, plaintext[10:4010], got) + requireNoEnvelopeRead(t, recording, mat.HeaderLen) +} + +// TestDecryptRangeWithMaterialMatchesEnvelopePath 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 TestDecryptRangeWithMaterialMatchesEnvelopePath(t *testing.T) { + const size = 3*rangeChunk + 7 + tenantKey := newX25519Key(t) + plaintext := patternBytes(size) + cek := newCEK(t) + + blob, mat := encryptWithMaterial(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.DecryptRangeWithMaterial(bytes.NewReader(blob), int64(len(blob)), mat, cek, off, 500) + require.NoError(t, err) + viaMaterial, err := io.ReadAll(r) + require.NoError(t, err) + + require.Equalf(t, viaEnvelope, viaMaterial, "paths disagree at off=%d", off) + } +} + +// TestBodyMaterialValidate 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 TestBodyMaterialValidate(t *testing.T) { + good := fee.BodyMaterial{ + 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.BodyMaterial){ + "zero value": func(m *fee.BodyMaterial) { *m = fee.BodyMaterial{} }, + "no header length": func(m *fee.BodyMaterial) { m.HeaderLen = 0 }, + "negative header": func(m *fee.BodyMaterial) { m.HeaderLen = -1 }, + "no base nonce": func(m *fee.BodyMaterial) { m.BaseNonce = nil }, + "short base nonce": func(m *fee.BodyMaterial) { m.BaseNonce = make([]byte, 3) }, + "no chunk size": func(m *fee.BodyMaterial) { m.ChunkSize = 0 }, + "chunk size tiny": func(m *fee.BodyMaterial) { m.ChunkSize = aesstream.MinChunkSize - 1 }, + "chunk size huge": func(m *fee.BodyMaterial) { m.ChunkSize = aesstream.MaxChunkSize + 1 }, + "no aad": func(m *fee.BodyMaterial) { m.AAD = nil }, + "empty (not nil) aad": func(m *fee.BodyMaterial) { m.AAD = []byte{} }, + } { + t.Run(name, func(t *testing.T) { + m := good + mutate(&m) + require.ErrorIs(t, m.Validate(), fee.ErrIncompleteMaterial) + + // The range entry point rejects it up front for the same reason, + // rather than letting it fail as an authentication error later. + _, err := fee.DecryptRangeWithMaterial(bytes.NewReader([]byte("blob")), 4096, m, + make([]byte, aesstream.KeySize), 0, 10) + require.ErrorIs(t, err, fee.ErrIncompleteMaterial) + }) + } +} + +// TestDecryptRangeWithMaterialPoisoned is the safety property that makes caching +// this material 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 TestDecryptRangeWithMaterialPoisoned(t *testing.T) { + const size = 3 * rangeChunk + plaintext := patternBytes(size) + cek := newCEK(t) + blob, mat := encryptWithMaterial(t, plaintext, cek, nil, + fee.WithChunkSize(rangeChunk), fee.WithContentLength(size)) + + for name, mutate := range map[string]func(m *fee.BodyMaterial){ + "header length off by one": func(m *fee.BodyMaterial) { m.HeaderLen++ }, + "wrong base nonce": func(m *fee.BodyMaterial) { + m.BaseNonce = append([]byte(nil), m.BaseNonce...) + m.BaseNonce[0] ^= 0xff + }, + "tampered aad": func(m *fee.BodyMaterial) { + m.AAD = append([]byte(nil), m.AAD...) + m.AAD[len(m.AAD)-1] ^= 0xff + }, + "wrong chunk size": func(m *fee.BodyMaterial) { m.ChunkSize = rangeChunk * 2 }, + } { + t.Run(name, func(t *testing.T) { + poisoned := mat + mutate(&poisoned) + + r, err := fee.DecryptRangeWithMaterial(bytes.NewReader(blob), int64(len(blob)), + poisoned, cek, 0, 200) + 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) + }) + } +} + +// TestDecryptRangeWithMaterialInvalidArgs covers the argument checks that do not +// depend on the material being right. +func TestDecryptRangeWithMaterialInvalidArgs(t *testing.T) { + const size = 2 * rangeChunk + plaintext := patternBytes(size) + cek := newCEK(t) + blob, mat := encryptWithMaterial(t, plaintext, cek, nil, + fee.WithChunkSize(rangeChunk), fee.WithContentLength(size)) + blobSize := int64(len(blob)) + + t.Run("short cek", func(t *testing.T) { + _, err := fee.DecryptRangeWithMaterial(bytes.NewReader(blob), blobSize, mat, + make([]byte, 16), 0, 10) + require.ErrorIs(t, err, fee.ErrInvalidCEK) + }) + + t.Run("nil blob", func(t *testing.T) { + _, err := fee.DecryptRangeWithMaterial(nil, blobSize, mat, cek, 0, 10) + require.Error(t, err) + }) + + t.Run("blob shorter than its envelope", func(t *testing.T) { + _, err := fee.DecryptRangeWithMaterial(bytes.NewReader(blob), mat.HeaderLen-1, mat, cek, 0, 10) + require.ErrorIs(t, err, aesstream.ErrCiphertextSize) + }) + + t.Run("offset past the end", func(t *testing.T) { + _, err := fee.DecryptRangeWithMaterial(bytes.NewReader(blob), blobSize, mat, cek, size+1, 10) + require.ErrorIs(t, err, aesstream.ErrRange) + }) + + t.Run("negative offset", func(t *testing.T) { + _, err := fee.DecryptRangeWithMaterial(bytes.NewReader(blob), blobSize, mat, cek, -1, 10) + require.ErrorIs(t, err, aesstream.ErrRange) + }) +} diff --git a/range.go b/range.go index f1c3aac..71a6602 100644 --- a/range.go +++ b/range.go @@ -162,6 +162,47 @@ func DecryptRangeWithCEK(blob io.ReaderAt, blobSize int64, cek []byte, off, leng return newRangeReader(env, blob, blobSize, headerLen, cek, off, length) } +// DecryptRangeWithMaterial is [DecryptRangeWithCEK] for a caller that already +// holds the envelope's parameters, from [EncryptedBlob.Material] at encryption +// time. Unlike every other entry point here it reads no envelope at all: the +// only bytes fetched from blob are the ciphertext chunks the range overlaps, so +// a caller fronting a remote object store spends no round trip re-reading a +// header it has already seen. +// +// m must describe this blob. A value that cannot describe any FEE body is +// rejected up front with [ErrIncompleteMaterial]; one that is well-formed but +// belongs to a different object, or has drifted from the bytes on disk, is caught +// by the body cipher instead — BaseNonce and AAD are bound into every chunk's +// tag, so the read fails with [aesstream.ErrCorrupted] rather than emitting wrong +// plaintext. +// +// blobSize is the whole stored object, envelope included, exactly as for +// [DecryptRange]. Because there is no envelope to consult, the declared +// chunk-count cross-check that yields [ErrSizeMismatch] on the other paths cannot +// run here: nothing detects a blobSize that disagrees with the stored object. +// A caller that records the blob's size alongside this material should compare +// the two before trusting a range, since a size from a store that has silently +// lost bytes reads as a shorter object whose interior ranges decrypt cleanly (see +// the accuracy note on [DecryptRange]). +// +// cek must be 32 bytes (AES-256). The caller retains ownership: it is copied into +// the body cipher but neither retained nor wiped. off and length behave exactly +// as in [DecryptRange]. +func DecryptRangeWithMaterial(blob io.ReaderAt, blobSize int64, m BodyMaterial, cek []byte, off, length int64) (*RangeReader, error) { + if len(cek) != aesstream.KeySize { + return nil, fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek)) + } + if blob == nil { + return nil, errors.New("fee: nil blob reader") + } + plainSize, err := m.PlaintextSize(blobSize) // validates m, and blobSize against it + if err != nil { + return nil, err + } + body := bodyParams{baseNonce: m.BaseNonce, chunkSize: m.ChunkSize, aad: m.AAD} + return spanRangeReader(blob, blobSize, m.HeaderLen, body, plainSize, 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 @@ -196,11 +237,24 @@ func newRangeReader(env *cose.Envelope, blob io.ReaderAt, blobSize, headerLen in return nil, err } - ciphertextSize := blobSize - headerLen - plainSize, err := plaintextSizeFor(env, ciphertextSize, body.chunkSize) + plainSize, err := plaintextSizeFor(env, blobSize-headerLen, body.chunkSize) if err != nil { return nil, err } + return spanRangeReader(blob, blobSize, headerLen, body, plainSize, cek, off, length) +} + +// spanRangeReader is the geometry-and-wiring tail shared by the envelope-backed +// path ([newRangeReader]) and the cached-material path +// ([DecryptRangeWithMaterial]): 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 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, plainSize int64, cek []byte, off, length int64) (*RangeReader, error) { + ciphertextSize := blobSize - headerLen start, n, _, err := aesstream.CiphertextRange(ciphertextSize, body.chunkSize, off, length) if err != nil { From ce0ed5bb95d08be9fc6478759c1763388862cb0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Aug 2026 19:40:16 +0200 Subject: [PATCH 05/18] docs: cover cached body material in the range docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BodyMaterial path landed in the previous commit without being mentioned where a reader looks for it. Three places still described a range read as always fetching the envelope: the package overview's byte-range section, the RangeReader doc's list of constructors, and the README, which documented DecryptRange and DecryptRangeWithCEK but not the cached-material variant. The README gains a section on it, since that is where a caller deciding what to persist alongside a blob will start. It states the four values, that they are complete before any plaintext is read, why the AAD is stored rather than the protected header it contains, and the one check the path gives up (ErrSizeMismatch has no envelope to consult). Signed-off-by: Miroslav Bajtoš Assisted-by: Claude:claude-opus-5 --- README.md | 34 +++++++++++++++++++++++++++++++++- fee.go | 6 ++++++ range.go | 13 ++++++++----- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 527b899..4b6f2d5 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ import ( | Package | Purpose | |---|---| -| [`fee`](.) (root) | Composes the primitives below into a small API: whole-object `Encrypt`/`Decrypt` plus byte-range `DecryptRange`. Adds no cryptography of its own. | +| [`fee`](.) (root) | Composes the primitives below into a small API: whole-object `Encrypt`/`Decrypt`, byte-range `DecryptRange`, and the cacheable envelope parameters (`BodyMaterial`) 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). | @@ -317,6 +317,38 @@ counterpart of `DecryptRange`. Callers holding raw ciphertext spans rather than 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` returns an `*fee.EncryptedBlob`, whose +`Material()` reports those four values — envelope length, base nonce, chunk size, +and the `Enc_structure` AAD — complete before any plaintext is read, so a writer +can store them while the upload is still streaming: + +```go +blob, err := fee.Encrypt(plaintext, recipients) +// ... +m := blob.Material() // persist alongside the blob's location and size +``` + +`fee.DecryptRangeWithMaterial(blob, blobSize, m, cek, off, length)` then serves a +range with no envelope round trip at all: the only bytes fetched are the +ciphertext chunks the range overlaps. `m.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 `blob = envelope ‖ ciphertext` (COSE detached payload). The envelope is diff --git a/fee.go b/fee.go index 90888bf..b2006a9 100644 --- a/fee.go +++ b/fee.go @@ -72,6 +72,12 @@ // 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. +// [EncryptedBlob.Material] reports the envelope parameters a range decrypt needs +// as a [BodyMaterial]; persisting those and passing them to +// [DecryptRangeWithMaterial] serves a range with no envelope round trip at all, +// and [BodyMaterial.PlaintextSize] answers a HEAD from the same record. +// // # Scope // // This package sequences the primitives and adds no cryptography of its own. diff --git a/range.go b/range.go index 71a6602..b273354 100644 --- a/range.go +++ b/range.go @@ -35,9 +35,11 @@ const ( ) // 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, and then only the chunks the range overlaps. +// It is returned by [DecryptRange], [DecryptRangeWithCEK] and +// [DecryptRangeWithMaterial], and reads ciphertext lazily: nothing beyond the +// envelope header is fetched until Read is called (nothing at all on the cached +// material path, 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 @@ -64,8 +66,9 @@ func (r *RangeReader) Read(p []byte) (int, error) { return r.sr.Read(p) } func (r *RangeReader) Len() int64 { return r.sr.Len() } // Size returns the total plaintext size of the whole object, derived from the -// blob size and the envelope's chunk size. It is the total an HTTP consumer puts -// after the slash in a Content-Range header. +// blob size and the chunk size. It is the total an HTTP consumer puts after the +// slash in a Content-Range header. [BodyMaterial.PlaintextSize] reports the same +// number from cached material, without a reader. func (r *RangeReader) Size() int64 { return r.size } // CiphertextSpan returns the blob-absolute byte range [off, off+n) that Read will From c5148a40cc2fcb600f3bf641fc46d77dccee46fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Aug 2026 19:59:25 +0200 Subject: [PATCH 06/18] refactor: share stream config and size math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BodyMaterial arrived after the range API was written, so it duplicated logic the envelope path already had. Two copies mattered, because both underpin the claim that the envelope path and the cached-material path accept the same inputs and fail the same way: - the aesstream.Config literal, built field by field in openStream and again in spanRangeReader, now bodyParams.streamConfig - the plaintext-size derivation, in both BodyMaterial.PlaintextSize and plaintextSizeFor, now plaintextSizeFrom bodyParams stays a separate type. Merging it into BodyMaterial would hand the whole-object path a material with no header length to put in it: cose.DecodeReader does not report how many bytes the envelope consumed, and Decrypt has no use for the number. Its doc comment now records what BodyMaterial adds and why the two shapes differ. Validate stays off the envelope paths. validateBody checks that the iv header is present but not that it is BaseNonceSize bytes, so a short iv keeps failing in the body cipher rather than as ErrIncompleteMaterial. Internal only: the exported surface is unchanged. Signed-off-by: Miroslav Bajtoš Assisted-by: Claude:claude-opus-5 --- fee.go | 27 +++++++++++++++++++++------ material.go | 24 +++++++++++++++++++++--- range.go | 28 +++++++++++----------------- 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/fee.go b/fee.go index b2006a9..c87e3a6 100644 --- a/fee.go +++ b/fee.go @@ -534,12 +534,7 @@ func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader if err != nil { return nil, err } - r, err := aesstream.NewReader(ciphertext, aesstream.Config{ - Key: cek, - BaseNonce: body.baseNonce, - AAD: body.aad, - ChunkSize: body.chunkSize, - }) + r, err := aesstream.NewReader(ciphertext, body.streamConfig(cek)) if err != nil { return nil, fmt.Errorf("fee: initializing body cipher: %w", err) } @@ -549,12 +544,32 @@ func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader // bodyParams is the validated STREAM configuration a FEE envelope's body header // carries: everything fee/aesstream needs to decrypt the detached ciphertext // apart from the content-encryption key. +// +// [BodyMaterial] is the same parameters 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 two shapes stay +// distinct and [BodyMaterial.body] converts one way. type bodyParams struct { baseNonce []byte chunkSize int aad []byte } +// streamConfig returns the fee/aesstream configuration for decrypting a body +// with these parameters under cek. 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 []byte) aesstream.Config { + return aesstream.Config{ + Key: cek, + BaseNonce: b.baseNonce, + AAD: b.aad, + ChunkSize: b.chunkSize, + } +} + // validateBody 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 — and rebuilds the Enc_structure AAD diff --git a/material.go b/material.go index 23e1e5d..2ce1fb9 100644 --- a/material.go +++ b/material.go @@ -96,18 +96,36 @@ func (m BodyMaterial) PlaintextSize(blobSize int64) (int64, error) { if err := m.Validate(); err != nil { return 0, err } - ciphertextSize := blobSize - m.HeaderLen + 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 [BodyMaterial.PlaintextSize] and the envelope-backed paths (via +// plaintextSizeFor), 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, m.HeaderLen, aesstream.ErrCiphertextSize) + blobSize, headerLen, aesstream.ErrCiphertextSize) } - n, err := aesstream.DecryptedSize(ciphertextSize, m.ChunkSize) + 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 } +// body 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 BodyMaterial) body() bodyParams { + return bodyParams{baseNonce: m.BaseNonce, chunkSize: m.ChunkSize, aad: m.AAD} +} + // clone returns a deep copy, so a BodyMaterial 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). diff --git a/range.go b/range.go index b273354..7755e0d 100644 --- a/range.go +++ b/range.go @@ -202,8 +202,7 @@ func DecryptRangeWithMaterial(blob io.ReaderAt, blobSize int64, m BodyMaterial, if err != nil { return nil, err } - body := bodyParams{baseNonce: m.BaseNonce, chunkSize: m.ChunkSize, aad: m.AAD} - return spanRangeReader(blob, blobSize, m.HeaderLen, body, plainSize, cek, off, length) + return spanRangeReader(blob, blobSize, m.HeaderLen, m.body(), plainSize, cek, off, length) } // PlaintextSize reports the total decrypted size of a FEE blob from its envelope @@ -222,7 +221,7 @@ func PlaintextSize(blob io.ReaderAt, blobSize int64) (int64, error) { if err != nil { return 0, err } - return plaintextSizeFor(env, blobSize-headerLen, body.chunkSize) + return plaintextSizeFor(env, blobSize, headerLen, body.chunkSize) } // newRangeReader is the shared core of DecryptRange and DecryptRangeWithCEK: @@ -240,7 +239,7 @@ func newRangeReader(env *cose.Envelope, blob io.ReaderAt, blobSize, headerLen in return nil, err } - plainSize, err := plaintextSizeFor(env, blobSize-headerLen, body.chunkSize) + plainSize, err := plaintextSizeFor(env, blobSize, headerLen, body.chunkSize) if err != nil { return nil, err } @@ -268,12 +267,7 @@ func spanRangeReader(blob io.ReaderAt, blobSize, headerLen int64, body bodyParam // aesstream exactly the bytes it will ask for and nothing else. sr, err := aesstream.NewSpanReader( io.NewSectionReader(blob, headerLen+start, n), - aesstream.Config{ - Key: cek, - BaseNonce: body.baseNonce, - AAD: body.aad, - ChunkSize: body.chunkSize, - }, + body.streamConfig(cek), ciphertextSize, off, length) if err != nil { return nil, fmt.Errorf("fee: initializing body cipher: %w", err) @@ -282,14 +276,14 @@ func spanRangeReader(blob io.ReaderAt, blobSize, headerLen int64, body bodyParam return &RangeReader{sr: sr, size: plainSize, spanOff: headerLen + start, spanLen: n}, nil } -// plaintextSizeFor derives the object's total plaintext size from its ciphertext -// length and chunk size, and cross-checks the envelope's declared chunk count -// against it when one is present — catching a blob size that describes a -// different object than the envelope does. -func plaintextSizeFor(env *cose.Envelope, ciphertextSize int64, chunkSize int) (int64, error) { - plainSize, err := aesstream.DecryptedSize(ciphertextSize, chunkSize) +// plaintextSizeFor 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 derived size, when one is present, +// catching a blob size that describes a different object than the envelope does. +func plaintextSizeFor(env *cose.Envelope, blobSize, headerLen int64, chunkSize int) (int64, error) { + plainSize, err := plaintextSizeFrom(blobSize, headerLen, chunkSize) if err != nil { - return 0, fmt.Errorf("fee: blob of %d ciphertext bytes: %w", ciphertextSize, err) + return 0, err } if !env.Headers.Unprotected.Has(labelChunkCount) { return plainSize, nil From eafd79d7a961f91ccbe0a6ca8677d6aeb0fb49b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Aug 2026 20:04:40 +0200 Subject: [PATCH 07/18] test: drop unused recordingReaderAt helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ciphertextBytesRead never had a caller, so staticcheck flagged it (U1000). The two assertions about how much ciphertext a range decrypt fetched accumulate inside loops that also bounds-check every read against the reported span, which the helper cannot do, so there was nowhere for it to be used. Signed-off-by: Miroslav Bajtoš Assisted-by: Claude:claude-opus-5 --- range_test.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/range_test.go b/range_test.go index 2a4ade8..20fcd93 100644 --- a/range_test.go +++ b/range_test.go @@ -49,18 +49,6 @@ func (r *recordingReaderAt) ReadAt(p []byte, off int64) (int, error) { return n, err } -// ciphertextBytesRead totals the bytes served from at or after headerEnd — the -// ciphertext the decrypt actually fetched, excluding the header probe. -func (r *recordingReaderAt) ciphertextBytesRead(headerEnd int64) int64 { - var total int64 - for _, rd := range r.reads { - if rd.off >= headerEnd { - total += rd.n - } - } - return total -} - // covered reports the set of blob offsets the reads touched, as a sorted list of // merged [off, end) intervals. func (r *recordingReaderAt) covered() []readInterval { From fd040ccce7dd355098481970121556781db1b842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Aug 2026 20:42:30 +0200 Subject: [PATCH 08/18] fix: stop the header probe on final decode errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decodeHeaderAt grew its probe on every cose.Decode failure except a typ mismatch, so a blob that is not a FEE envelope was re-read at 4 KiB, 8 KiB, ... up to 1 MiB before returning the error the very first read had already settled. Against a remote store, a wrong object id cost nine requests and ~2 MiB of fetches for a purely client-side mistake. Only a prefix that stopped mid-item can be answered by reading more. cose.Decode now also wraps io.ErrUnexpectedEOF in that case, so the probe can tell "read more bytes" from "these bytes are complete and wrong" and grow only for the former. That subsumes the ErrUnexpectedType special case rather than adding a second one beside it. Assisted-by: Claude:claude-opus-5 Signed-off-by: Miroslav Bajtoš --- cose/cose.go | 5 +++++ cose/decode.go | 20 ++++++++++++++++++-- cose/decode_test.go | 35 +++++++++++++++++++++++++++++++++++ range.go | 17 +++++++++-------- range_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 10 deletions(-) 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/range.go b/range.go index 7755e0d..4f8f524 100644 --- a/range.go +++ b/range.go @@ -316,10 +316,8 @@ func decodeHeaderAt(blob io.ReaderAt, blobSize int64) (*cose.Envelope, int64, er return nil, 0, fmt.Errorf("fee: negative blob size %d", blobSize) } - probe := int64(headerProbeSize) - if blobSize < probe { - probe = blobSize - } + limit := min(blobSize, int64(maxHeaderLen)) + probe := min(int64(headerProbeSize), limit) for { buf := make([]byte, probe) n, rerr := blob.ReadAt(buf, 0) @@ -334,11 +332,14 @@ func decodeHeaderAt(blob io.ReaderAt, blobSize int64) (*cose.Envelope, int64, er if derr == nil { return env, int64(n - len(rest)), nil } - // A type mismatch is decided only after a complete decode, so a longer - // prefix cannot change the answer. - if errors.Is(derr, cose.ErrUnexpectedType) || atEnd || probe >= blobSize || probe >= maxHeaderLen { + // 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, min(blobSize, int64(maxHeaderLen))) + probe = min(probe*2, limit) } } diff --git a/range_test.go b/range_test.go index 20fcd93..36329ba 100644 --- a/range_test.go +++ b/range_test.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/ecdh" "crypto/rand" + "encoding/hex" "errors" "io" "math" @@ -109,6 +110,15 @@ func encryptWithCEK(t *testing.T, plaintext, cek []byte, recipients []fee.Recipi return io.ReadAll(r) } +// 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 +} + // 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) { @@ -699,6 +709,37 @@ func TestDecryptRangeMalformedBlob(t *testing.T) { }) } +// 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. From 9e75ae234e50621e512c838578be90f16f20f91a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Aug 2026 20:48:08 +0200 Subject: [PATCH 09/18] refactor: cut duplication in the range and material code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanups from a reuse/simplification review of the branch. No behaviour change. - checkCEK replaces four copies of the ErrInvalidCEK length branch, and errNilBlob the two independently built "nil blob reader" strings. - plaintextSizeFor becomes envelopePlaintextSize: it sat beside plaintextSizeFrom and differed by one preposition. - encryptReader folds into EncryptedBlob, its only user. - Tests share clampLen and headerLenOf instead of spelling the clamp two ways and the header length three times, use bytes.Clone/slices.Clone and cose.TagCOSEEncrypt0 over the older idioms, and compare BodyMaterial in one assertion so a new field cannot go unchecked. - recordingReaderAt.covered() goes: the assertion it fed was weaker than the span check two lines above it. - The test doubles hold one *bytes.Reader rather than allocating per ReadAt; countingBlob is godoc-visible, so callers copy its shape. - ExampleDecryptRange drops an unreachable empty-range branch, which the README handler still covers. Assisted-by: Claude:claude-opus-5 Signed-off-by: Miroslav Bajtoš --- example_material_test.go | 6 +-- example_range_test.go | 4 -- fee.go | 46 +++++++++++----------- material.go | 2 +- material_test.go | 28 +++++++------- range.go | 31 ++++++++------- range_test.go | 83 ++++++++++++++++------------------------ 7 files changed, 93 insertions(+), 107 deletions(-) diff --git a/example_material_test.go b/example_material_test.go index 348423e..92980a8 100644 --- a/example_material_test.go +++ b/example_material_test.go @@ -15,13 +15,13 @@ import ( // 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 []byte + blob *bytes.Reader headerLen int64 envelope int64 // bytes served from below headerLen } func (c *countingBlob) ReadAt(p []byte, off int64) (int, error) { - n, err := bytes.NewReader(c.blob).ReadAt(p, off) + n, err := c.blob.ReadAt(p, off) if off < c.headerLen { c.envelope += min(int64(n), c.headerLen-off) } @@ -76,7 +76,7 @@ func ExampleDecryptRangeWithMaterial() { // 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: blob, headerLen: row.material.HeaderLen} + src := &countingBlob{blob: bytes.NewReader(blob), headerLen: row.material.HeaderLen} const off, length = 4, 15 r, err := fee.DecryptRangeWithMaterial(src, row.size, row.material, cek, off, length) if err != nil { diff --git a/example_range_test.go b/example_range_test.go index 8438ff6..14dedba 100644 --- a/example_range_test.go +++ b/example_range_test.go @@ -48,10 +48,6 @@ func ExampleDecryptRange() { // 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()) - if r.Len() == 0 { - fmt.Printf("Content-Range: bytes */%d\n", r.Size()) - return - } fmt.Printf("Content-Range: bytes %d-%d/%d\n", off, off+r.Len()-1, r.Size()) got, err := io.ReadAll(r) if err != nil { diff --git a/fee.go b/fee.go index c87e3a6..71b2b64 100644 --- a/fee.go +++ b/fee.go @@ -259,8 +259,8 @@ func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption) // parameters a later range decrypt needs; see [EncryptedBlob.Material]. 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) (*EncryptedBlob, error) { - if len(cek) != aesstream.KeySize { - return nil, fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek)) + if err := checkCEK(cek); err != nil { + return nil, err } return encryptStream(plaintext, cek, recipients, opts...) } @@ -390,14 +390,9 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts _ = pw.CloseWithError(cerr) }() - // Every value the material reports is already fixed above, before any - // plaintext is read, so it is complete the moment this returns — a caller can - // record it without waiting for (or even performing) the read. return &EncryptedBlob{ - encryptReader: encryptReader{ - body: io.MultiReader(bytes.NewReader(header), pr), - pr: pr, - }, + body: io.MultiReader(bytes.NewReader(header), pr), + pr: pr, material: BodyMaterial{ HeaderLen: int64(len(header)), BaseNonce: baseNonce, @@ -424,10 +419,17 @@ func chunkCountFor(nPlain, chunkSize int64) int64 { // the background encryption goroutine by closing the pipe, so it is safe to // abandon a partial read. type EncryptedBlob struct { - encryptReader + body io.Reader // io.MultiReader(header, pipe reader) + pr *io.PipeReader // closing it stops the encryption goroutine material BodyMaterial } +// Read implements io.Reader over the wire blob. +func (b *EncryptedBlob) Read(p []byte) (int, error) { return b.body.Read(p) } + +// Close stops the background encryption goroutine. +func (b *EncryptedBlob) Close() error { return b.pr.Close() } + // Material reports the envelope parameters that [DecryptRangeWithMaterial] needs // to decrypt a byte range of this blob without re-reading its header — for a // caller that stores blobs remotely and keeps metadata of its own alongside @@ -441,16 +443,6 @@ type EncryptedBlob struct { // Each call returns an independent copy; mutating it does not affect the blob. func (b *EncryptedBlob) Material() BodyMaterial { return b.material.clone() } -// encryptReader carries the streaming half of an [EncryptedBlob]. -type encryptReader struct { - body io.Reader // io.MultiReader(header, pipe reader) - pr *io.PipeReader // closing it stops the encryption goroutine -} - -func (e *encryptReader) Read(p []byte) (int, error) { return e.body.Read(p) } - -func (e *encryptReader) Close() error { return e.pr.Close() } - // Decrypt recovers the plaintext from a FEE COSE_Encrypt (tag 96) envelope read // from src. // @@ -510,8 +502,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 { @@ -620,6 +612,16 @@ func validateBody(env *cose.Envelope) (bodyParams, error) { return bodyParams{baseNonce: baseNonce, chunkSize: int(chunkSize), aad: 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 nil +} + // matchRecipient returns the first recipient whose kid equals want. A recipient // without a kid header never matches. An empty want, or no match, yields // ErrNoMatchingRecipient. diff --git a/material.go b/material.go index 2ce1fb9..8455103 100644 --- a/material.go +++ b/material.go @@ -104,7 +104,7 @@ func (m BodyMaterial) PlaintextSize(blobSize int64) (int64, error) { // plaintext bytes each. // // It is shared by [BodyMaterial.PlaintextSize] and the envelope-backed paths (via -// plaintextSizeFor), so a blob size that cannot describe a FEE body is reported +// 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 diff --git a/material_test.go b/material_test.go index 044cdaf..9c5d89b 100644 --- a/material_test.go +++ b/material_test.go @@ -115,16 +115,19 @@ func TestIngotWriteReadFlow(t *testing.T) { // 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) - require.Equal(t, int64(len(blob)-len(rest)), mat.HeaderLen) - iv, ok := env.Headers.Unprotected.Bytes(cose.HeaderLabelIV) require.True(t, ok) - require.Equal(t, iv, mat.BaseNonce) - require.Equal(t, rangeChunk, mat.ChunkSize) - aad, err := env.EncStructure(nil) require.NoError(t, err) - require.Equal(t, aad, mat.AAD) + + // One comparison over the whole value, so a field added to BodyMaterial + // cannot go unchecked here. + require.Equal(t, fee.BodyMaterial{ + HeaderLen: int64(len(blob) - len(rest)), + BaseNonce: iv, + ChunkSize: rangeChunk, + AAD: aad, + }, mat) }) t.Run("plaintext size from the row alone", func(t *testing.T) { @@ -156,12 +159,7 @@ func TestIngotWriteReadFlow(t *testing.T) { cek, tc.off, tc.length) require.NoError(t, err) - // Computed without off+length, which overflows for an open-ended range. - end := int64(size) - if tc.length < end-tc.off { - end = tc.off + tc.length - } - want := plaintext[tc.off:end] + 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()) @@ -207,7 +205,7 @@ func TestDecryptRangeWithMaterialEncrypt0(t *testing.T) { // It really is the recipient-less form. tag, err := cose.PeekTag(blob) require.NoError(t, err) - require.Equal(t, uint64(16), tag) + require.Equal(t, cose.TagCOSEEncrypt0, tag) recording := newRecordingReaderAt(t, blob) r, err := fee.DecryptRangeWithMaterial(recording, int64(len(blob)), mat, cek, 10, 4000) @@ -297,11 +295,11 @@ func TestDecryptRangeWithMaterialPoisoned(t *testing.T) { for name, mutate := range map[string]func(m *fee.BodyMaterial){ "header length off by one": func(m *fee.BodyMaterial) { m.HeaderLen++ }, "wrong base nonce": func(m *fee.BodyMaterial) { - m.BaseNonce = append([]byte(nil), m.BaseNonce...) + m.BaseNonce = bytes.Clone(m.BaseNonce) m.BaseNonce[0] ^= 0xff }, "tampered aad": func(m *fee.BodyMaterial) { - m.AAD = append([]byte(nil), m.AAD...) + m.AAD = bytes.Clone(m.AAD) m.AAD[len(m.AAD)-1] ^= 0xff }, "wrong chunk size": func(m *fee.BodyMaterial) { m.ChunkSize = rangeChunk * 2 }, diff --git a/range.go b/range.go index 4f8f524..06c8532 100644 --- a/range.go +++ b/range.go @@ -21,6 +21,10 @@ import ( // 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 @@ -155,8 +159,8 @@ func DecryptRange(blob io.ReaderAt, blobSize int64, unwrap RecipientUnwrapper, o // 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) (*RangeReader, error) { - 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, headerLen, err := decodeHeaderAt(blob, blobSize) if err != nil { @@ -192,11 +196,11 @@ func DecryptRangeWithCEK(blob io.ReaderAt, blobSize int64, cek []byte, off, leng // the body cipher but neither retained nor wiped. off and length behave exactly // as in [DecryptRange]. func DecryptRangeWithMaterial(blob io.ReaderAt, blobSize int64, m BodyMaterial, cek []byte, off, length int64) (*RangeReader, error) { - if len(cek) != aesstream.KeySize { - return nil, fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek)) + if err := checkCEK(cek); err != nil { + return nil, err } if blob == nil { - return nil, errors.New("fee: nil blob reader") + return nil, errNilBlob } plainSize, err := m.PlaintextSize(blobSize) // validates m, and blobSize against it if err != nil { @@ -221,7 +225,7 @@ func PlaintextSize(blob io.ReaderAt, blobSize int64) (int64, error) { if err != nil { return 0, err } - return plaintextSizeFor(env, blobSize, headerLen, body.chunkSize) + return envelopePlaintextSize(env, blobSize, headerLen, body.chunkSize) } // newRangeReader is the shared core of DecryptRange and DecryptRangeWithCEK: @@ -239,7 +243,7 @@ func newRangeReader(env *cose.Envelope, blob io.ReaderAt, blobSize, headerLen in return nil, err } - plainSize, err := plaintextSizeFor(env, blobSize, headerLen, body.chunkSize) + plainSize, err := envelopePlaintextSize(env, blobSize, headerLen, body.chunkSize) if err != nil { return nil, err } @@ -276,11 +280,12 @@ func spanRangeReader(blob io.ReaderAt, blobSize, headerLen int64, body bodyParam return &RangeReader{sr: sr, size: plainSize, spanOff: headerLen + start, spanLen: n}, nil } -// plaintextSizeFor 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 derived size, when one is present, -// catching a blob size that describes a different object than the envelope does. -func plaintextSizeFor(env *cose.Envelope, blobSize, headerLen int64, chunkSize int) (int64, error) { +// 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 derived size, when one is +// present, catching a blob size that describes a different object than the +// envelope does. +func envelopePlaintextSize(env *cose.Envelope, blobSize, headerLen int64, chunkSize int) (int64, error) { plainSize, err := plaintextSizeFrom(blobSize, headerLen, chunkSize) if err != nil { return 0, err @@ -310,7 +315,7 @@ func plaintextSizeFor(env *cose.Envelope, blobSize, headerLen int64, chunkSize i // 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, errors.New("fee: nil blob reader") + return nil, 0, errNilBlob } if blobSize < 0 { return nil, 0, fmt.Errorf("fee: negative blob size %d", blobSize) diff --git a/range_test.go b/range_test.go index 36329ba..f3975e3 100644 --- a/range_test.go +++ b/range_test.go @@ -8,7 +8,7 @@ import ( "errors" "io" "math" - "sort" + "slices" "testing" "github.com/filecoin-project/go-fee" @@ -26,7 +26,7 @@ const rangeChunk = aesstream.MinChunkSize // interval it is asked for, so a test can prove which bytes a range decrypt // actually touched. type recordingReaderAt struct { - blob []byte + blob *bytes.Reader reads []readInterval reject bool // fail instead of serving, to prove no read happens at all t *testing.T @@ -37,7 +37,7 @@ type readInterval struct{ off, n int64 } func newRecordingReaderAt(t *testing.T, blob []byte) *recordingReaderAt { t.Helper() - return &recordingReaderAt{blob: blob, t: t} + return &recordingReaderAt{blob: bytes.NewReader(blob), t: t} } func (r *recordingReaderAt) ReadAt(p []byte, off int64) (int, error) { @@ -45,33 +45,11 @@ func (r *recordingReaderAt) ReadAt(p []byte, off int64) (int, error) { r.t.Errorf("unexpected ReadAt(off=%d, len=%d)", off, len(p)) return 0, errors.New("recordingReaderAt: read not expected") } - n, err := bytes.NewReader(r.blob).ReadAt(p, off) + n, err := r.blob.ReadAt(p, off) r.reads = append(r.reads, readInterval{off: off, n: int64(n)}) return n, err } -// covered reports the set of blob offsets the reads touched, as a sorted list of -// merged [off, end) intervals. -func (r *recordingReaderAt) covered() []readInterval { - if len(r.reads) == 0 { - return nil - } - sorted := append([]readInterval(nil), r.reads...) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].off < sorted[j].off }) - merged := []readInterval{sorted[0]} - for _, rd := range sorted[1:] { - last := &merged[len(merged)-1] - if rd.off <= last.off+last.n { - if end := rd.off + rd.n; end > last.off+last.n { - last.n = end - last.off - } - continue - } - merged = append(merged, rd) - } - return merged -} - // rangeFixture is an encrypted object plus everything needed to range-decrypt it. type rangeFixture struct { plaintext []byte @@ -119,6 +97,20 @@ func hexBytes(t *testing.T, s string) []byte { 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) { @@ -162,10 +154,7 @@ func TestDecryptRangeRoundTrip(t *testing.T) { t.Run(tc.name, func(t *testing.T) { r, got := decryptRange(t, f.blob, f.unwrapper, tc.off, tc.length) - wantLen := tc.length - if avail := int64(size) - tc.off; wantLen > avail { - wantLen = avail - } + wantLen := clampLen(size, tc.off, tc.length) require.Equal(t, wantLen, r.Len(), "Len is the clamped range length") require.Equal(t, int64(size), r.Size(), "Size is the whole object") require.Equal(t, f.plaintext[tc.off:tc.off+wantLen], got) @@ -268,7 +257,7 @@ func TestDecryptRangeReadsOnlySpan(t *testing.T) { require.NoError(t, err) spanOff, spanLen := r.CiphertextSpan() - headerReads := append([]readInterval(nil), rec.reads...) + 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") @@ -294,13 +283,6 @@ func TestDecryptRangeReadsOnlySpan(t *testing.T) { fetched += rd.n } require.Equal(t, spanLen, fetched, "the span is fetched exactly once, in full") - - // And the whole object was never pulled in: header probe plus one chunk. - var total int64 - for _, rd := range rec.covered() { - total += rd.n - } - require.Less(t, total, int64(len(f.blob)), "the full blob must never be read") } // TestDecryptRangeZeroLengthReadsNoCiphertext confirms an empty range is valid, @@ -352,7 +334,7 @@ func TestDecryptRangePrefetchSpan(t *testing.T) { // 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(prefetchedBlob{span: span, spanOff: spanOff, header: f.blob[:spanOff]}, + 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) @@ -364,16 +346,20 @@ func TestDecryptRangePrefetchSpan(t *testing.T) { // and the pre-fetched ciphertext span from another, the shape a caller gets after // a single range request. type prefetchedBlob struct { - header []byte - span []byte + 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 bytes.NewReader(b.header).ReadAt(p, off) + return b.header.ReadAt(p, off) } - return bytes.NewReader(b.span).ReadAt(p, off-b.spanOff) + return b.span.ReadAt(p, off-b.spanOff) } // TestDecryptRangeTamperedChunkInRange is the acceptance criterion that a tampered @@ -384,7 +370,7 @@ func TestDecryptRangeTamperedChunkInRange(t *testing.T) { f := newRangeFixture(t, size) off, length := int64(rangeChunk+10), int64(50) - headerLen := int64(len(f.blob)) - aesstream.EncryptedSize(size, rangeChunk) + 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) @@ -393,7 +379,7 @@ func TestDecryptRangeTamperedChunkInRange(t *testing.T) { chunkStart + 10, chunkStart + int64(rangeChunk) + aesstream.TagSize - 1, } { - tampered := append([]byte(nil), f.blob...) + tampered := bytes.Clone(f.blob) tampered[pos] ^= 0x01 r, err := fee.DecryptRange(bytes.NewReader(tampered), int64(len(tampered)), f.unwrapper, off, length) @@ -414,8 +400,8 @@ func TestDecryptRangeTamperOutsideRange(t *testing.T) { f := newRangeFixture(t, size) off, length := int64(10), int64(100) // wholly inside chunk 0 - headerLen := int64(len(f.blob)) - aesstream.EncryptedSize(size, rangeChunk) - tampered := append([]byte(nil), f.blob...) + 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) @@ -758,8 +744,7 @@ func TestDecryptRangeLargeEnvelope(t *testing.T) { blob, err := encrypt(t, plaintext, rs, fee.WithChunkSize(rangeChunk)) require.NoError(t, err) - headerLen := int64(len(blob)) - aesstream.EncryptedSize(size, rangeChunk) - require.Greater(t, headerLen, int64(4096), "the envelope must exceed the first probe size") + 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) From b7388ca8dc4ecc4ad19a7c7ac6dbb23990b134c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Aug 2026 20:48:41 +0200 Subject: [PATCH 10/18] refactor!: return the body material beside the blob reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encrypt and EncryptWithCEK now return (io.ReadCloser, BodyMaterial, error), and EncryptedBlob with its Material method is gone. The concrete *EncryptedBlob return made a typed nil reachable: a wrapper declared as (io.ReadCloser, error) could `return fee.Encrypt(...)`, and on an error path the interface came back non-nil holding a nil pointer, so a caller's `if rc != nil { rc.Close() }` panicked. Returning the interface makes that unconstructible rather than merely documented, and BodyMaterial is a value type whose zero value is inert. The material was always complete before any plaintext was read, so handing it back directly says so where a method plus a doc comment had to assert it. clone() now runs once at construction instead of per Material call. TestEncryptedBlobMaterialIsACopy is replaced by TestEncryptMaterialDoesNotAliasTheStream, which pins the property that still matters: mutate the returned material before reading a byte, and the blob still decrypts under the pristine values. The old test only compared two Material calls against each other. BREAKING CHANGE: Encrypt and EncryptWithCEK gained a second result and no longer return *EncryptedBlob, which is removed along with its Material method. Assisted-by: Claude:claude-opus-5 Signed-off-by: Miroslav Bajtoš --- README.md | 21 ++++--- example_material_test.go | 12 ++-- example_range_test.go | 2 +- fee.go | 115 ++++++++++++++++++--------------------- fee_test.go | 28 +++++----- material.go | 2 +- material_test.go | 50 +++++++++++------ range.go | 6 +- range_test.go | 2 +- 9 files changed, 121 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 4b6f2d5..0ba653e 100644 --- a/README.md +++ b/README.md @@ -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 } @@ -321,15 +321,14 @@ directly. 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` returns an `*fee.EncryptedBlob`, whose -`Material()` reports those four values — envelope length, base nonce, chunk size, -and the `Enc_structure` AAD — complete before any plaintext is read, so a writer -can store them while the upload is still streaming: +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 -blob, err := fee.Encrypt(plaintext, recipients) -// ... -m := blob.Material() // persist alongside the blob's location and size +// m goes alongside the blob's location and size. +r, m, err := fee.Encrypt(plaintext, recipients) ``` `fee.DecryptRangeWithMaterial(blob, blobSize, m, cek, off, length)` then serves a diff --git a/example_material_test.go b/example_material_test.go index 92980a8..dfbf5a6 100644 --- a/example_material_test.go +++ b/example_material_test.go @@ -31,8 +31,8 @@ func (c *countingBlob) ReadAt(p []byte, off int64) (int, error) { // ExampleDecryptRangeWithMaterial 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 -// [EncryptedBlob.Material] reports, and the reader rebuilds a decryptor from -// those columns alone. +// [EncryptWithCEK] reports, and the reader rebuilds a decryptor from those +// columns alone. func ExampleDecryptRangeWithMaterial() { priv, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { @@ -47,18 +47,16 @@ func ExampleDecryptRangeWithMaterial() { log.Fatal(err) } + // The material 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, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, + enc, material, 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) } - // Complete before a byte is read, so a writer can record it while the upload - // is still streaming. - material := enc.Material() - blob, err := io.ReadAll(enc) if err != nil { log.Fatal(err) diff --git a/example_range_test.go b/example_range_test.go index 14dedba..49239a9 100644 --- a/example_range_test.go +++ b/example_range_test.go @@ -22,7 +22,7 @@ func ExampleDecryptRange() { 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), + enc, _, err := fee.Encrypt(bytes.NewReader(plaintext), []fee.Recipient{fee.NewECDHESRecipient(kid, priv.PublicKey())}, fee.WithContentLength(int64(len(plaintext)))) if err != nil { diff --git a/fee.go b/fee.go index 71b2b64..dfb73fd 100644 --- a/fee.go +++ b/fee.go @@ -73,8 +73,8 @@ // use the underlying primitives in fee/aesstream directly. // // A store that keeps metadata beside its blobs can drop the header read too. -// [EncryptedBlob.Material] reports the envelope parameters a range decrypt needs -// as a [BodyMaterial]; persisting those and passing them to +// [Encrypt] reports the envelope parameters a range decrypt needs as a +// [BodyMaterial]; persisting those and passing them to // [DecryptRangeWithMaterial] serves a range with no envelope round trip at all, // and [BodyMaterial.PlaintextSize] answers a HEAD from the same record. // @@ -213,16 +213,18 @@ func WithContentLength(n int64) EncryptOption { // 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. // -// The returned [EncryptedBlob] is an io.ReadCloser over the blob; its -// [EncryptedBlob.Material] reports the envelope parameters a later range decrypt -// needs, for a caller that wants to cache them rather than re-read the header. -func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption) (*EncryptedBlob, 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 +// [BodyMaterial]. 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, BodyMaterial, error) { if len(recipients) == 0 { - return nil, ErrNoRecipients + return nil, BodyMaterial{}, 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, BodyMaterial{}, 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 @@ -255,12 +257,12 @@ 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. // -// As with [Encrypt], the returned [EncryptedBlob] carries the envelope -// parameters a later range decrypt needs; see [EncryptedBlob.Material]. 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) (*EncryptedBlob, error) { +// As with [Encrypt], the second result carries the envelope parameters a later +// range decrypt needs; see [BodyMaterial]. 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, BodyMaterial, error) { if err := checkCEK(cek); err != nil { - return nil, err + return nil, BodyMaterial{}, err } return encryptStream(plaintext, cek, recipients, opts...) } @@ -276,16 +278,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) (*EncryptedBlob, error) { +func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, BodyMaterial, error) { if plaintext == nil { - return nil, errors.New("fee: nil plaintext reader") + return nil, BodyMaterial{}, 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, BodyMaterial{}, 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, BodyMaterial{}, fmt.Errorf("fee: recipient %d: %w", i, err) } } @@ -301,12 +303,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, BodyMaterial{}, 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, BodyMaterial{}, fmt.Errorf("fee: generating base nonce: %w", err) } // The body header is fixed before encryption: the algorithm and envelope @@ -334,7 +336,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, BodyMaterial{}, werr } entries[i] = entry } @@ -347,11 +349,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, BodyMaterial{}, 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, BodyMaterial{}, fmt.Errorf("fee: encoding envelope: %w", err) } // The body cipher streams into a pipe that the returned reader drains. Create @@ -368,7 +370,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, BodyMaterial{}, fmt.Errorf("fee: initializing body cipher: %w", err) } declaredLen := cfg.contentLength @@ -390,18 +392,37 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts _ = pw.CloseWithError(cerr) }() - return &EncryptedBlob{ + // Every value the material 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. + material := BodyMaterial{ + HeaderLen: int64(len(header)), + BaseNonce: baseNonce, + ChunkSize: cfg.chunkSize, + AAD: aad, + }.clone() + return &encryptReader{ body: io.MultiReader(bytes.NewReader(header), pr), pr: pr, - material: BodyMaterial{ - HeaderLen: int64(len(header)), - BaseNonce: baseNonce, - ChunkSize: cfg.chunkSize, - AAD: aad, - }, - }, nil + }, material, nil } +// 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 +} + +func (e *encryptReader) Read(p []byte) (int, error) { return e.body.Read(p) } + +func (e *encryptReader) Close() error { return e.pr.Close() } + // chunkCountFor reports how many STREAM chunks a plaintext of nPlain bytes // produces at the given chunk size. Empty input is one (empty) final chunk. func chunkCountFor(nPlain, chunkSize int64) int64 { @@ -411,38 +432,6 @@ func chunkCountFor(nPlain, chunkSize int64) int64 { return (nPlain + chunkSize - 1) / chunkSize } -// EncryptedBlob is the io.ReadCloser returned by [Encrypt] / [EncryptWithCEK]: -// a stream over the wire blob (envelope||ciphertext), plus the envelope -// parameters a later range decrypt needs. -// -// 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. -type EncryptedBlob struct { - body io.Reader // io.MultiReader(header, pipe reader) - pr *io.PipeReader // closing it stops the encryption goroutine - material BodyMaterial -} - -// Read implements io.Reader over the wire blob. -func (b *EncryptedBlob) Read(p []byte) (int, error) { return b.body.Read(p) } - -// Close stops the background encryption goroutine. -func (b *EncryptedBlob) Close() error { return b.pr.Close() } - -// Material reports the envelope parameters that [DecryptRangeWithMaterial] needs -// to decrypt a byte range of this blob without re-reading its header — for a -// caller that stores blobs remotely and keeps metadata of its own alongside -// them. -// -// The result is complete as soon as the blob is constructed: every value is -// fixed before any plaintext is read, so it can be recorded without reading (or -// even finishing) the stream. It describes a recipient-less COSE_Encrypt0 just -// as it does a COSE_Encrypt. -// -// Each call returns an independent copy; mutating it does not affect the blob. -func (b *EncryptedBlob) Material() BodyMaterial { return b.material.clone() } - // Decrypt recovers the plaintext from a FEE COSE_Encrypt (tag 96) envelope read // from src. // diff --git a/fee_test.go b/fee_test.go index de555fe..81d6c98 100644 --- a/fee_test.go +++ b/fee_test.go @@ -88,7 +88,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 } @@ -186,7 +186,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), ) @@ -227,7 +227,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) @@ -259,7 +259,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) }) @@ -278,7 +278,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), @@ -321,7 +321,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), @@ -497,14 +497,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) } @@ -513,7 +513,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), @@ -647,7 +647,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 @@ -720,22 +720,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/material.go b/material.go index 8455103..bd4593f 100644 --- a/material.go +++ b/material.go @@ -14,7 +14,7 @@ var ErrIncompleteMaterial = errors.New("fee: incomplete body material") // BodyMaterial 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 obtained from [EncryptedBlob.Material] at +// envelope header at all. It is returned by [Encrypt] / [EncryptWithCEK] at // encryption time and consumed by [DecryptRangeWithMaterial]. // // It exists for stores that keep their own metadata alongside the blob: the diff --git a/material_test.go b/material_test.go index 9c5d89b..090067e 100644 --- a/material_test.go +++ b/material_test.go @@ -54,11 +54,10 @@ func (r blobLocationRow) material() fee.BodyMaterial { // the store flow. func encryptWithMaterial(t *testing.T, plaintext, cek []byte, recipients []fee.Recipient, opts ...fee.EncryptOption) ([]byte, fee.BodyMaterial) { t.Helper() - enc, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, recipients, opts...) + // The material arrives before a single byte is read, which is what lets a + // writer record the row while the upload is still streaming. + enc, mat, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, recipients, opts...) require.NoError(t, err) - // Captured before a single byte is read, which is what lets a writer record - // the row while the upload is still streaming. - mat := enc.Material() blob, err := io.ReadAll(enc) require.NoError(t, err) require.NoError(t, enc.Close()) @@ -172,22 +171,41 @@ func TestIngotWriteReadFlow(t *testing.T) { } } -// TestEncryptedBlobMaterialIsACopy pins that Material hands back an independent -// value each call, so a caller that adjusts one — or a store that reuses the -// buffers it read a row into — cannot reach back into the blob's own state. -func TestEncryptedBlobMaterialIsACopy(t *testing.T) { - enc, err := fee.EncryptWithCEK(bytes.NewReader(patternBytes(64)), newCEK(t), nil, +// TestEncryptMaterialDoesNotAliasTheStream pins that the material 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 TestEncryptMaterialDoesNotAliasTheStream(t *testing.T) { + cek := newCEK(t) + plaintext := patternBytes(2 * rangeChunk) + + rc, mat, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, nil, fee.WithChunkSize(rangeChunk)) require.NoError(t, err) - defer enc.Close() + 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.BodyMaterial{ + HeaderLen: mat.HeaderLen, + BaseNonce: bytes.Clone(mat.BaseNonce), + ChunkSize: mat.ChunkSize, + AAD: bytes.Clone(mat.AAD), + } + mat.BaseNonce[0] ^= 0xff + mat.AAD[0] ^= 0xff - first := enc.Material() - first.AAD[0] ^= 0xff - first.BaseNonce[0] ^= 0xff + blob, err := io.ReadAll(rc) + require.NoError(t, err) - second := enc.Material() - require.NotEqual(t, first.AAD, second.AAD) - require.NotEqual(t, first.BaseNonce, second.BaseNonce) + // The blob still decrypts under the pristine values, so the mutation never + // reached the cipher or the encoded header. + r, err := fee.DecryptRangeWithMaterial(bytes.NewReader(blob), int64(len(blob)), + kept, cek, 0, int64(len(plaintext))) + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, plaintext, got) } // TestDecryptRangeWithMaterialEncrypt0 pins that material is envelope-form diff --git a/range.go b/range.go index 06c8532..957a528 100644 --- a/range.go +++ b/range.go @@ -170,9 +170,9 @@ func DecryptRangeWithCEK(blob io.ReaderAt, blobSize int64, cek []byte, off, leng } // DecryptRangeWithMaterial is [DecryptRangeWithCEK] for a caller that already -// holds the envelope's parameters, from [EncryptedBlob.Material] at encryption -// time. Unlike every other entry point here it reads no envelope at all: the -// only bytes fetched from blob are the ciphertext chunks the range overlaps, so +// holds the envelope's parameters, as [Encrypt] reports them at encryption time. +// Unlike every other entry point here it reads no envelope at all: the only +// bytes fetched from blob are the ciphertext chunks the range overlaps, so // a caller fronting a remote object store spends no round trip re-reading a // header it has already seen. // diff --git a/range_test.go b/range_test.go index f3975e3..2c5d8ac 100644 --- a/range_test.go +++ b/range_test.go @@ -80,7 +80,7 @@ func newRangeFixture(t *testing.T, n int, opts ...fee.EncryptOption) rangeFixtur // 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...) + r, _, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, recipients, opts...) if err != nil { return nil, err } From 6c417aefb7d7d9b2cec5fd40ab1898283db6a420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Aug 2026 20:58:30 +0200 Subject: [PATCH 11/18] refactor: address review nits on material example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - name the example's stored size field blobSize, so it reads as the blob's size rather than the plaintext's - keep chunkCountFor above encryptReader to cut diff churn Assisted-by: Claude:claude-opus-5[1m] Signed-off-by: Miroslav Bajtoš --- example_material_test.go | 4 ++-- fee.go | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/example_material_test.go b/example_material_test.go index dfbf5a6..c919ee3 100644 --- a/example_material_test.go +++ b/example_material_test.go @@ -69,14 +69,14 @@ func ExampleDecryptRangeWithMaterial() { // the blob's exact size. row := struct { material fee.BodyMaterial - size int64 + blobSize int64 }{material, 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.material.HeaderLen} const off, length = 4, 15 - r, err := fee.DecryptRangeWithMaterial(src, row.size, row.material, cek, off, length) + r, err := fee.DecryptRangeWithMaterial(src, row.blobSize, row.material, cek, off, length) if err != nil { log.Fatal(err) } diff --git a/fee.go b/fee.go index dfb73fd..9845104 100644 --- a/fee.go +++ b/fee.go @@ -408,6 +408,15 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts }, material, 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. +func chunkCountFor(nPlain, chunkSize int64) int64 { + if nPlain <= 0 { + return 1 + } + return (nPlain + chunkSize - 1) / chunkSize +} + // 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. @@ -423,15 +432,6 @@ func (e *encryptReader) Read(p []byte) (int, error) { return e.body.Read(p) } func (e *encryptReader) Close() error { return e.pr.Close() } -// chunkCountFor reports how many STREAM chunks a plaintext of nPlain bytes -// produces at the given chunk size. Empty input is one (empty) final chunk. -func chunkCountFor(nPlain, chunkSize int64) int64 { - if nPlain <= 0 { - return 1 - } - return (nPlain + chunkSize - 1) / chunkSize -} - // Decrypt recovers the plaintext from a FEE COSE_Encrypt (tag 96) envelope read // from src. // From c6364c97345517790a812f6666f6a32a8b558788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Aug 2026 21:09:54 +0200 Subject: [PATCH 12/18] fix: accept a trailing empty final chunk in the count check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plaintext of exactly k*chunkSize bytes has two valid encodings: k chunks whose last one is full, or k full chunks followed by an empty final chunk. Both decrypt to the same bytes, aesstream reads either (the "full + empty final" row in TestChunkLayout pins it), and whole-object Decrypt accepts either. envelopePlaintextSize derived the expected chunk count with chunkCountFor, which models only the first form, so a blob in the second form declaring k+1 chunks failed with ErrSizeMismatch. DecryptRange and PlaintextSize were stricter than Decrypt on the same object, which is not a distinction this API means to draw. Compare against the count aesstream derives from the ciphertext layout instead, via a new exported ChunkCount. chunkCountFor stays as the producer's rule for writing the header, matching the reference implementation, and its doc now says so. The check keeps its purpose: a size wrong by a whole chunk or more still trips ErrSizeMismatch. A size short by only part of the final chunk now accounts for the same number of chunks and passes, surfacing instead as an authentication failure when that chunk is read -- the model the docs already state for blobs carrying no chunk count. The two lengths are indistinguishable without reading the chunks. Also adds the exact-multiple boundary to the cross-implementation vectors, which had no fixture for it: exact-multiple-go is 3 full chunks, and the pinned foc-encryption decrypts it. Assisted-by: Claude:claude-opus-5 Signed-off-by: Miroslav Bajtoš --- README.md | 7 + aesstream/spanreader.go | 23 ++++ aesstream/spanreader_test.go | 48 +++++++ fee.go | 6 + range.go | 30 +++-- range_test.go | 126 +++++++++++++++++- vectors/README.md | 7 + vectors/testdata/exact-multiple-go/blob.bin | Bin 0 -> 12408 bytes vectors/testdata/exact-multiple-go/meta.json | 12 ++ .../testdata/exact-multiple-go/plaintext.bin | 1 + vectors/vectors_test.go | 10 ++ 11 files changed, 257 insertions(+), 13 deletions(-) create mode 100644 vectors/testdata/exact-multiple-go/blob.bin create mode 100644 vectors/testdata/exact-multiple-go/meta.json create mode 100644 vectors/testdata/exact-multiple-go/plaintext.bin diff --git a/README.md b/README.md index 0ba653e..c95c635 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,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. diff --git a/aesstream/spanreader.go b/aesstream/spanreader.go index 8bff776..d9ec109 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 diff --git a/aesstream/spanreader_test.go b/aesstream/spanreader_test.go index ca4d0e6..140d4a3 100644 --- a/aesstream/spanreader_test.go +++ b/aesstream/spanreader_test.go @@ -497,3 +497,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/fee.go b/fee.go index 9845104..1afdc4f 100644 --- a/fee.go +++ b/fee.go @@ -410,6 +410,12 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts // 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 diff --git a/range.go b/range.go index 957a528..7edeb61 100644 --- a/range.go +++ b/range.go @@ -9,7 +9,7 @@ import ( "github.com/filecoin-project/go-fee/cose" ) -// ErrSizeMismatch means the ciphertext length implied by the supplied blob size +// 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. // @@ -121,8 +121,11 @@ func (r *RangeReader) CiphertextSpan() (off, n int64) { return r.spanOff, r.span // 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 that when the envelope carries a chunk count, but -// whole-object integrity is properly the job of the layer that supplied blobSize. +// 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 @@ -282,9 +285,16 @@ func spanRangeReader(blob io.ReaderAt, blobSize, headerLen int64, body bodyParam // 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 derived size, when one is -// present, catching a blob size that describes a different object than the -// envelope does. +// 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 { @@ -297,8 +307,12 @@ func envelopePlaintextSize(env *cose.Envelope, blobSize, headerLen int64, chunkS if !ok { return 0, fmt.Errorf("%w: chunk-count header is present but not an integer", ErrMalformedEnvelope) } - if want := chunkCountFor(plainSize, int64(chunkSize)); declared != want { - return 0, fmt.Errorf("%w: envelope declares %d chunks, the blob size implies %d", + 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 diff --git a/range_test.go b/range_test.go index 2c5d8ac..b4450c4 100644 --- a/range_test.go +++ b/range_test.go @@ -2,8 +2,11 @@ package fee_test import ( "bytes" + "crypto/aes" + "crypto/cipher" "crypto/ecdh" "crypto/rand" + "encoding/binary" "encoding/hex" "errors" "io" @@ -88,6 +91,61 @@ func encryptWithCEK(t *testing.T, plaintext, cek []byte, recipients []fee.Recipi 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 { @@ -126,10 +184,24 @@ func decryptRange(t *testing.T, blob []byte, u fee.RecipientUnwrapper, off, leng // 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 short final chunk, and single bytes at each end. +// 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) { - const size = 3*rangeChunk + rangeChunk/2 // 3.5 chunks - f := newRangeFixture(t, size) + 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 @@ -144,7 +216,7 @@ func TestDecryptRangeRoundTrip(t *testing.T) { {"across two boundaries", rangeChunk - 10, 2*rangeChunk + 20}, {"exactly one aligned chunk", rangeChunk, rangeChunk}, {"aligned start, unaligned end", 2 * rangeChunk, rangeChunk + 5}, - {"whole short final chunk", 3 * rangeChunk, rangeChunk / 2}, + {"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}, @@ -156,7 +228,7 @@ func TestDecryptRangeRoundTrip(t *testing.T) { wantLen := clampLen(size, tc.off, tc.length) require.Equal(t, wantLen, r.Len(), "Len is the clamped range length") - require.Equal(t, int64(size), r.Size(), "Size is the whole object") + require.Equal(t, size, r.Size(), "Size is the whole object") require.Equal(t, f.plaintext[tc.off:tc.off+wantLen], got) }) } @@ -603,6 +675,50 @@ func TestDecryptRangeChunkCountMismatch(t *testing.T) { }) } +// 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) + 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) + 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. diff --git a/vectors/README.md b/vectors/README.md index ad03229..f6d1801 100644 --- a/vectors/README.md +++ b/vectors/README.md @@ -24,6 +24,7 @@ to it and this repo matches it (see [Wire format](#wire-format)). | `multi-chunk-ts` | TS seals → Go decrypts | tag 16 (COSE_Encrypt0) | | `multi-recipient-go` | Go seals → TS parses recipients + decrypts body | tag 96 (COSE_Encrypt) | | `multi-chunk-go` | Go seals → TS decrypts (extra multi-chunk coverage) | tag 16 | +| `exact-multiple-go` | Go seals → TS decrypts (plaintext is exactly 3 chunks) | tag 16 | Each `testdata//` holds `blob.bin` (`envelope‖ciphertext`), `plaintext.bin`, and `meta.json`. @@ -54,6 +55,12 @@ recipient = [ {1: alg}, {4: kid, ...}, wrappedKey ] # alg -31 or -5 - **Body cipher** — chunked AES-256-GCM-STREAM, alg `-65793`. Per-chunk nonce is `baseNonce[7] ‖ chunkIndex[4, big-endian] ‖ lastFlag[1]` (`0x01` on the final chunk), tag 16 bytes. +- **Chunking** — a producer writes `ceil(len / chunkSize)` chunks, minimum 1, + with the remainder in the final chunk; empty input is one empty chunk. Both + implementations follow that rule, so `exact-multiple-go` declares 3 chunks + rather than 3 full chunks plus an empty one. A *decoder* also accepts a + trailing empty final chunk, so the declared count is authoritative and must + not be re-derived from the plaintext length. - **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 0000000000000000000000000000000000000000..6c57409c46ce274610fc11d69319e0a6a81e38e3 GIT binary patch literal 12408 zcmV-;Fo)03gIFt~0XhHy0RRwqAz^TEY-wX*bZKvHFLrKZE@p3IEoE+YWo&P7Wh-NE zb7i9iN4oXMDqpHhIsgFx{TUDdIsgFx{sZpqh$YH<1r-Zy-f#xlHb|< z-^BWY*|qh7k$+F&rUY;fNMxm_o0>|3r|MR6YS70 z0V+KSXlZr7)(h-x*pmzvp|S#iu5>$WSGKLL@f8AN#~hcMjo&Xb_UZ0 zx9vg!ZEce>SaWmoU@q{qDAS98M)4?c=Mx~!BV#Ed4X}ai# zo=g_fiOnr5ln|$(E2ynI@1kZ=pfTT!`pJdbWe+7hPf4U2;)irE`a zi^Aa`HTKZ&~ zNIy&S{LY`S2k7Sw^C_z@+Fh4eYEWhNLgX@ zj*Jvo(no${K`>7{`s*VLPL+{9n@`C5BLDL-63xA=ToFkejuE;8jF$RusA_7A0S$ws z5SWhS>I?H2w*ttF|C(s}W}7wWRlG5bzi(FVB`Z5T^w^_ta`}sGpBaDaqdsU3n;Rtf zgG%?Z%D=mc?|Gz~?Ft4M3BJGlBB6iqhh8*LngsLi%B2?y2Bnqel4~m8Z)9oX5S4SU ze-np@QVP}Q={0X3W9`Q5%CuXx>m^7|(8Z=H@79IapRX~L7$`{kjP!Al7`y80n8Z~B zD6m^L@)pkBaqgfQeCIw8#a;3l+$O?Bo2;+50?iCuQFa_wLyZMa79J|tRdusXq(aW< z5fu$9%NV-kf>dmEswmp`xZh%~1aMLMmW#1toi92K3ZNjAHl&>*OlcADR{Xd6nB37F zf&Dcw>aG2*mO>dj@S=NcGU$eZD^oHr?R{L#nPYLGF)vr1?IN+ZXalgwd;6vmU5IHg zQG>~15sTIYTPR3hUg(h#6H*XBAG7z`ol3PrJh|by(wLIu#VOeJFH{5&ogk=VTy?cK z)~Jn>@Di<=PENSuK~?ufc{Ire{f2`=uX)+B%b*)I1V|cA9StbqF1!iAchd1X`5G@i zi}&nYm1&!veL55YRztNGYI!;5XpGNQ!NTX^?q>wOy9?d-pM8+1)Tac^iAlBSc3)s@ z7evT<&Bv9|Z%3j-=5rl9%1`-T^}38kI43kVCZTDamhp1yrqxuj;iEh$#RhKNld^p4 z`pRa%(+*U3Ip|D+aGmdOBXDOP8eE!mDjfR3)*|D@)z39*Esj6~_^C8qP({3b?EJ(? zNE$!%Cdmi)9AyM8a>{qS?p1c8UH5a9Bn%b3CLu!2CB`8PADTGy4WEo*5klFiB0Wrl z*ggT%bde5!YHU8N8%;#-4o>_57sFrMF3Bq&S^I9CC>SSmv`m*uW;mwXb$IFMWR1P= zVy$M5pg&W-ccmX5D8y_f5_aj=X=fC3lYevM3r1a)Gvh3~XWG>5L@%wfUy3&xELQgO zO|D9l1hWc0MnXo4wdg3;s|N+-l%QI4>%#`2#vK6+m8AvKN`Z!uiQ!ayFm5L@JY_Qq zL>Q76!mk)(;j5NXIJQsSFoYyl8#`5Q_XF|spjh*t=3@AasA%{|X<2_3|Bud`Z3nzb z5e})lRZXDIuq#Yy%<VG+8bAjxr)^2)b*W?e2=1Q$}Zbku1NxSar;{n2AAFX>cBQYa?t;v z>-fEB09`^xWlj70HCND?7`f_vC&-425FrV=s&p0+G~t)(?QFCjS-p}ID{9?fC0MF^ z2KG>ZM zx>at=0x_}Fcf4)yCLS)B9Yx-eAkzVwFHNE3Y`M17B$gS-xc+=@e{N(0=^>P!i%&yo z{*Oy(0)RFft}L{=DpLPE#o^fJsouAzr6Imz)DXObWQ(U(sO!XVT*(U>U^tk+Dj!bn zbl@j@G%EU+Fng}IlPWQ)hyKh9wAnknP|5?}{kCC*1)=(B%C0etSSucntG=p^@mWfStDFgCXo>&Bly2m-i7@0po+x zJ8jsvMdIIs0aEqdcwP@kZ)teSaa(u{mc<1ERm#Eg*mMTu0f1Vxy`i&R{dSThYv8X1 zZ|Sal_+o-SU_$bXXI03g>Bh#8@~0J$s&Vbhi?c-&q^1W7pjVPW_nJE zB8K}IL^IQ%AGE^qKJ>g&(W$TB>O@(dqmuw8w84Dsgt_e;N(cwUJr=nlLPux7+Qr!^ zt(@{(th|A}GOIX4eDgxig~4GDg5Ttcr1g1`PMxKC4Z6 z&;$vvlG+O{$kpxY!k)o z4d=w4dgJ;y{QmJ6P4C<+i%sdX5o{2uqdC737G`@<))*ohug|mlD)I!k=nh)qm2W%7-mC>h%7~=}cDC39jEq zu3)Q;Jh2Ng$zl~AwesQN7J^?FiRH2fN8u%NK<--DqZK?2?BYVCislEg?+-r-l_e9< zxfwpNP6Xhi@!9421o4r-3}7`9TWeYC_r1JFs~023X~zJ+DtMNnC~I~etY0EQe~1bz z@+n`(+KF2iruyitOu^VcORiABTyN@C_M8@&j2pLXeimD?>#CLm8PC3I?QHhDMWa@I zf+g>nZR3KU+FA!-MCdbNG<~GzDG5fe;h#;xkD8hTI0zZlkP`fHOCUGz-cr#hCt}~q z_?BB~h47$_ScwOO-v{#}F zbPt9b3H%?O|8fo)O+-PVXBA(;guy~9P>6^JjM)lJDrd&PW#$55H_@-jzG^V8<;09FPJqTCFuD2dN29;;)iC zsNauf^3-L>J)W)bG~0S?k4b<%OmgDjFj-}cl`N2KkVLB<9002Sq_yNT7XgaF^3vf0 zrZwR$gKQp)|8X+`o@vLx$(nUp8i5TL5Te7=>;{YIHX@} z+GuOsBT4t%T=d&Ke1R2@imyQLeiolPlhN!rH@|_+rn^MgtT%xItZa>`uSueElbkog z%#g|bF}4z{X+3KbKPEH+Xj`ly0ZGA&Xxv1>GdDfkw{1J&1D+(>wS_H1oAr2>4Qn3@anrf9I&lY$k)?Lw&VS)(s3-9XV75_8BGec;y^2l=hwv=QAp+8_iY>omLGC6|QNC=5Q0MYxMxi-x zt2V#bp8VN2rBe4Gmt(d0m~B1&PrD@_hI~W75V;v6dn|`aKn^?b` zTVEypsRxqOvMU0|aLaUz3f5>obV2Xrs=)SB(X&9U5b~HV6kcUsR|l^G4^M1RNWil2 zD%OD9%1xhihg}-C<5GVAf>8Wfr&M94#m6!P)#?Br%Ka&DxP)}}sb`DsY*M65JJr4@ zR$S~=UFq$R2M7f;k0|26_H3uEfY6Yw`km4HV%)1(Blr>&5gg1Ku;?-8(J(uMJ@_us zvtfxsA8Jwdylqh*%|ai%dOk<}LY|a~{79uIJP^VdA5(=bx9DSxvLC6k*xzd1h(g)Q z!);)acFE#FGwJ=(Z60TDL?zej*&EmgGq<*BV@q4)O3jSeT2ucoJy_lX3tQ(J_^%Zg z&_%$w`g*zhwIqxhtAeH=dUxRcgif!FW;Rdf#i<~R(3gm3KlOFDQ@vzJtj2GarisPa zLJAl;r2r#|kZyi@g!n79HL-iiWuRzUv8Jb;#`e*X=OnnT$_m7h_r!b8-r>PRKSIwL zrI;8K4_}$3{UOIl`#DQO+H87&&rH)F6RgLl+;rFtB+AjHVkEEXKA~-tbF(X?E}WqX zS&xY;1dAi*z3BMNd5x~ZDieShy&bAFX^0AJBj~X``spmvFV|ll%FA>r*{ABG)R8e{ zDL$J+$A<%;Z+BlKu_s0hBO8(#0Cz^C4YY$;q#>LKhmwX z@93xAJP8?m{xDqJR83|VsS;b^=qM_S-||8jgq+miGcppa0)gx zR(crBHL*`x+*4Ih_(82gx(?kHbrYjlx$cODqik%DUWV4`ga5;u=v%aZ%w zUeUlL#CF8n?vC@(hM+T!R=6jVZV$(N^EMJ+$j7Kx1a-}|F~Lh_y>wJO#H&h9QgvO= zsUi$`=bX6{d6hYrk?4e+WZ4X|#Myfk!KN<&i83(Bb6*v#a!XT~g+Qz4 zqCjG-kNf0-B=e5C#_5AP>V>P1w5AF|SCmnjYH zJJS3yA+pJALYMc}OM2~*Y90)7DZta<|B5PpF|ajz*CB{0)#zOU1d7U7bwrwE#!nV zmm=eTb&+9_M8)habC)oHU04966eQ<~&GzMP;0+pp;duen=En0lJ4)t7S~i%sWL6)+(B2&s>N<`rjjkj z!&@CjXI4q60kC6l_YQ6rJO2u}g#|EoT^={0Y(GnfPBEmHvvnZ+4#hbN!m&7U%ji|A zo@yN)vv+m|?QIgr#ksrb)mzqh5ig=G=58I;aMxNEX={coCO%wS|4i+oh1cD`;}oq@ z;ZgdZn|wW}Wds$rP{zx+5Yo-K%^?Fp8?J!&BE79@r%n#wchR6FU%z_gL&IrFDi}`h z&`}U2lakKLtOe#|aVyMcY6`oep;gfhT|!+`d37+Oy=v_0uV-ofSL=pNkJ&ih7Ige& z)I|rfVMtI+7@h`@gkt*1tNFgi8UImHpK5r#^cHo6hai@zDN+S5JKc&coY5o7rSK2{ zTVGG$PNR(M54!6Pi2A8f7t{L>KY#)71ACL1%ziOj{gq=frG=#?xc~HT?-mWS0U^lE zp+%W+gt(~W>nD*IO;{lp;M4Yj^ZPVQJQlVYQ}2aQ^;dId#DX`&7Y$oKud-CoRlR$$ zZ22JCtZz}>27vMWe9Y-XUYY0it=nc` zVACQqmx}B_!5s_XCl#a=D$rR0jSqhKO{UyP9=g7tN95KVsgQyFz9IU6dcelVXx(pg z@99A9EoRs#=;C`C7^N^Im0P)Som9(HNY}QbCN*woP)aTO*38HrDrffoE@7-U{=s7u znKgEj?@3b2EXgEcaUYTzehXQs6K(lCa&yITAK20?_lwCk!kz2A`VzIoj{BLdONX6z zAi6unF@}>kBB*OmLxMv@dA0!S@_m5C1~)hg`*{wE44hH20uBs2Kh`&|6L^A(Pe+YV z@vF|FfWGqTdS!hZompII1HAR6Q*}q^6~*@o@9zwcExLp`aJnX~=Xl6pPF#U#L2Q&3 z*kx!y#bSp@I04XQTp_Y`h-~l2Bw8r!lT}Yi@+Uh&2b3CR6#U<$- zP|nXKy8c|%MJ(t^JPmy&vM81)4%`GuF=?Jyo+Y_Vp5O)&OmNezo!Pws-+F1&J3Ta0 z2ndJPRMQqjIdmD$>#1B~(lFMCb?l@@Tj|sPksmIJn9O7sjUb$Z4riGs5hn7ppb{6uAS-=-_9pXZC_XH>V(e9l9;7XP=k zXEJL6HJcljLEQx-?fN!j%5M!l(}cn^y1{Nk4@}{;c*x|xR1)L3?uYsoW|vRQ@5BM{ z-3T6f5F~!3WcE}BbaYbn#V~ynT&5}WASnwj=FuEn6>fnh{-H|0cnSB`wbJM&I*iP_ zpM!qzqBfaN>)#ERr6ZVar0vAws}uTeK3&!I1%C_pOJ-GWs-Mt{_UJdWM;;TvJ&@-tast50U0wKmgrZj5)DF`G(T_ ziMWNGy)|s{t`Vv_`Oxq+-+jAi1Y#6f@ES|7zXS|669qdb@F}l$GoG4y@a0?X(HN#5 z`IC-*%yn@Z!ajJ!zuhx$ZNt@?HH05H=gfiM_OiC2=65I}us_nfx+vRd0U`ianBCwy# zer)NFM}Z!~6a!lREff0Y@06|GGwuUf`rK7c>$nb+LiU=lB%z5BVnDCQ)Y*RlUb(r- zK#_fKLUE?OW0`h$!AGt<&s&j~)+#;4<%gokmRSN@IEG(PKv!&wNw0oEwJC3AUDPzVS^OYr*OqkiKgAihqh z2tE^hku3eD{Gi&YPCmT}(12G9%RgNnk0{}=i)xKGWWRX}s4CDxj)nLS#rWu7y;yR8 zAsgUE$MNWK@P~tGVh^ly&R#yZD`Q1CG@S|YH?dKIkQH*H0JI@CE^{>l1%`|W4=x5J zyvjR;v*VW*wpje23`~-$&AWjUtSJ@h?nev%k>Q+;hRb6Z2zbw2dSzq*GT#;MV8O;1 z=8Jto=l7M`H!2`U1#0D|FTVg)k$#GeD%G+OEFmRkVC3XNig*;ahNj;^^(d}ymoB! zbY-Mq8+w|wCU`J>R=*W8|Eu-wD;4G7k)~y}@@@&%^)ZesegN_aWpMK>addK)(L^y; zwjsh%P*_44psR7_I;p7xyi^!oRebLOWIFMZdXSpgZVy)lgwU9y{+I@t=WZ+O#m~@A zXrCID3C*^3FgCa1`#$Qa`v8)$o ztLQhBX3(1OOWe}U;X^$W2)Z;{xJOT%g=5WTE@Q`IwoAYq|4mP>edM7g)Z@jw>S2zkKTePm1G3Y{{V=~M1!o917h_Ej` zCEbPqCISr@Zp{nV|4)JQ>e=#-7FFKVWw1MiBcy;S_LD9V_ivxfvll(lnW-M}S2fwngcx&BBuTBccBllY7il&W(me18hL{X6vD+4|L;jBSo{@o>9m=K1uMF z4DKv#w?XdDy8`s*3bpJ9TuyrgH*42G02wsA`B2qzK<}#M}{P@g(j#_+}#FIZKD8;@5pd!72^pexO!fkVcH7ePj_ z?_m)^{J_KX?gZ40??0vzxu+b41U7YEKHLtLNDd3 zhitHLlGI69rt3o-hIM?%n27JE<-Be;M}%BSqod!2Q33rekJ7Ayae+@Sb`qe5QN^|U zJbzHGy6F75+q%6LR{>=D)PlJ;dk~dJHkF{csEuhjStSHxoI#+a1XK9riJlU!SS;fN zJhY!(G59|ifKvIkeU*;Z%BuTP2v!2b>Oj#h{o`>;VKfhFb4YY`7qs7!=u=kKO;B}} zK1=zd(=&t>S$TKoS5{i(&EA~oTDJm$vF$3_VV?>fm7oelira%*6&0rAxkEK}|Nb_v zMpvf|9w-gdGw=glXtn8U+E0-`bYkp**W&p@8` zs!gC}dCO}JBS>34%=kJBybw%NA@*`wi1Hw;LO&Bkr8@VS8?f6}k_=%Ox$nBsP zfBJF?W&dY_cRMC^PNSO0xh^hisd{J&cq#Em<0vro7mRM*5xweQDi{2mY@0j){@g9L zabYEa(|v{^&d<;aps4}G87Bw*ou#4OHM+6ACkZG~Ve6|d%9TSgS3b!1rO98RAx=nX z9>v$^MwDVLLo2Ff?8!U0cFfO1<@oKMW+6Wh|NB5fL;}7V7(zxH2jlFpIf6K*YI5V2 zlEXCPqC*)~^H*`m*0)z}7`Ox_2)|6SG}dy@NZkvMu?D?7*3)h%BWG5U0QicFf`NYs}!TzeDS@G7ya$ z4XA85q7OD+KbQ9h`e$QczDS|I?4P_#fAneL4rQa=5c^iaH~3IloqN*q&8tIWizG7> z+$+sIQC*D_v`?8X{i`RmBH4rg(VqUpv%0iib0WE7Ze7NBK^wJJ9r>{p`e7pNuH*1- z$@l;aw6RXPQ~An#&@!0{$A@l<1;zQPd946B_epY-P#+tn*v0EyWTS3CzSL~9^c$L4 z;Gi`brjky}(7gceWx-hWu$zwoWQn9LhHg$e{z|B1W)jIi3AU;Qj*pBeUP;d-g@{;> zIIZR!=bNB;<7+D_?=R9&cj==W?g^<4=A-)@_ds`;^5AYBf=PQ zC>@ml7&`xmXHtJ{>#^IU{K^^||KE03b^$$F{@ocX*iygb0yy1Hj3EpPRvshFk7z|dyTX&h+Z zLv+jQY0xDA^!9g<=~K#N)8-Cud@E7T(FlJ^*SKqGo+3Wq{rK$p$fXCwl5&RR3!JX8 z$;SZ(zlGMezWSh9_EjeF5Mth6%58pBMN(wFRe%@iwzq}X!+Cl@7ita?i8E+d#}Fpc zpI2FI2~tzt{CA5&J6jDi58~P?^vb*aHY@3tWQ_5 z>2Z#JHviLPPjY*9O`@5gvMebo#s~}rIUEAzu;sxX;OL5Ra#>`??J(i5$F)4Upo3s}s(IMh>M4f= zqpyMpa~Yz+_+aUpDdb%i@ou|?vA*8^U&gZ;{O;)vngpjLIG5>E6O&%~>_T>tMo<^YdFX`Z^&Y8_e-ffz5OZqkRj6S2F2l9O(w3y(}ydm9D@mcsTQnomO zE!1J_6ko1)stQw5#*E97vHz`Py}^FmyAIm<$r5X-p%`S1OWWVuhr*L4C^dpkdf(Jzk@TRlu0(MfK-5$KDj>* z9w*(QkOGI-{)!+r*v)27`NcZ&L;i3pB*R32^g*&S0)IQSq9HgNsmJy<8 z9NZgAA9zM$pL(G1=EX!2%{B=>|I^CTg}YYj@tteI(q*z2#A4itMWDKk|F<%ls#1C~ zthw`E!x`vAOM`pX_bESg0W1!Nm;V{!o@WMep$T4=N&|I-dm1zEE+6?y%H$UD+@c-O z&-LURgB+##IqI>(0xc=xuvpPmP&A@hZWK91u;5}v44B0N3H*sg`_4Mcdwh`(b71cJ z3F4#OAfg9Gm!)}FqrT@IoNVawBKc~)!$p!@7LH>&9k*D+!;lUOC3hGfCv<{33v9i#sW7r4Tz9KZdXKAU|E$eDuvP_wLE;ct zM`HvBpJG)#eh>Mn$CLc5)+2L@Xf!ea?ugka@dUHETc*ZGDKCj-OQSRLGFB-2^mTs*}{L z$V!6&iEi;{G5%p>qH)P;>YU7h<9BA&*O}8Lt_1d`HqgP!o+Fm*unVNOm$yB6WW& z#*EM`ipa-4`y~Xlj0Rr`Xc{j=?0|tlygp?~7%1!m_tg7?x8p8eT>m&Hb{+d7-nW1! zYr$aG#4TJZnf9Z2Ic|nfLi)1-+?IIyCdUK>-jWt}W333H5|3tw=CMgTS)v7-GlJ!J z^Y{4b0L^O9SMOw1Dp4Web^jMCldR9XnZxtYPBrQ$`fit85zomlbUvMLZ@F)ND=65k zv<(0jkr>MF!MYRG%{J-Zx}wm&$``*v^ZNB#+=K*eNb)t1LpXQS=hNj@pQZ)=CBDP@ zNfq}zE3Qq7PYbI?l2~toJBa*BIe3M|;Sq<9G+pC+Fdxp6R9xvp%xGDj)xXdfaJsPuNu!+&|l?&Q&VGfHHB!akfJB7ZFH)fn2zhJ~^)CadDd8(P4r1^of91THByu z?og%C)TcVh6-Fqh<uf+{YP>Z${uz?*^QTLMf{cRVgCvNq65w?0W% z&8=VzJAyfg72@#3i>ltm3G1VWdH%pTEOa7S-ZuKpn#CwvE`VJEsZJE&8#+3QpEdbh z5L4c;X{q}uHlxBq+x3|(I>H{oJJl;DO-?9hhGB6DmABo0F4eA_dQd^eyy1irmOE+I zh?rrDIYqu5{&CfvntOeof8$h+Gs9buEvetMqN*6UxELP~X%_Q!#(Lp9i%~$HwJ$q3|l*300v35nq{}F=}zG$OMm$yyKvO5(D_=mtANw1dDR0FH|c%9%PmFzpn{H zE~GxQwKw!|T$O)n_^e&i^<-_KUQEW`=zk8gIVmVx{>FAIg~y< z_mtx{Fb^>~{v_^{R{fEE?$HL6MD?mIQ=SK$%qj`8?Paj~+c!kU(b6|Y6@w0uik}Sd zx;>g$>8Mqo{E+~F93X*&+E1-Xftcbn?Uk$iL0aNI;I&?ir#VX!M!{jFMG>a%a%vXXgF z$hpt!9&S)1etluWE8Be{^xz>m*RQmr*hVG^BZL`2OfD-I8We)A51R=`s055HwY< z0IbOX1i3Oktb4AZ7c8GTuw-19LcKe;DQ{~~Qwa`&#p)V5I5osnsrhx}YvtjQQ1`!0 z?5>(a_bX+Cvbc3byI(hY(pqHp+0p4(0?_vL}YS=68G5Y)t}`R-15H#^RF`_Ckb*mZ^Asa1k->|;g8?*V309(35(2JZ;zt_PQHW2YOU8K~ zWV-awsmf^C_SgQ4I!#EEvFfzyx`3LYi5HGhn?!28`}E)sq(6zseD3GJkv2ZULMhln z&26dz=L@u(MM9b|&VdZFDj*5Q;NFK>(Bwh2Nr*fnrHPOG-xCL#3-HtCImdnNRUAz~ z41iJOP0^cp?9LB^lqQX8wDlxRyJ6UxiZ>_jgZ%2=w&5R;RZs9WdOh^#TGLp&SJe`97zWtL3m1Q$&OWUFkfW9*kzw97-eHzx8AW; z#$V8ASP$z%L}yxAgR8M4*0uM(g4NE`nA4hmkE@A4-pbeg6rK{_>#dIAr{)m7UfqYO3H-=TCN$qlk*P~rR>@wVSLEsk1XEm{z8c}T&FpgH4O8RL zv@3Cp1 zu$ebqHI_DhMO{EhPSc^=~9G5cpe2Wjg<+t@$bCczuN})@CgT+$Vy3DBqi`&a>6Llg^p2#$4z=zxx{oJ zDv=q0o3OXdJ6ZQiXM=`jOi?S2TaAz}KkVl1B!AVQ7dw~O#HkdB-Thka&9&M@{}@Hi z$CwwfGJJYy3HB>3qT>(hL|S#7-a{Ya;tZT~$B@*r%8J{+29El47|CZOz8dUee|RCD zWxh3$t~_!5NT+~pMrRBk5j6*45ZZT^3LZO(AybJ`tE5e1?<$6A^zTujZd`=8e0#o8odU7Z zX_)GL9=TKwur4G>$BG)X?9@x-1yo4e&i6qBXW}d%K!zl&4l{Y$&1(cHSI$uFq#v;o z2pX4D*aZi}BH3;4;FDAg+_G=5n$X7g4+awp6h%_9e%3J=9ns}k0j?+JVvd=e;V?78 zZs4E1Cu{yS>7(9+?cZ;u1J1IVy;z%YcU`3+0A$X(@&q0-{8Pfk0Ay1-h qQA<7pYKrgcxlP-e-P8T#ANi;LHNVF1Ga6DQ*{;)p^L*HPa%Jh^rbb!- literal 0 HcmV?d00001 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 4412c3e..ea18e53 100644 --- a/vectors/vectors_test.go +++ b/vectors/vectors_test.go @@ -189,6 +189,16 @@ func TestGenerate(t *testing.T) { "Multi-chunk file encrypted in Go (spans several STREAM chunks); decrypts in foc-encryption (TS).", bytes.Repeat([]byte("multi-chunk-go/FIL-473 "), 700)) // ~15 KiB > chunk size + // 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", From ee349b8d6ee007862c4ab162183c82bd9eaaee5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Aug 2026 21:23:12 +0200 Subject: [PATCH 13/18] docs: reconcile the vectors fixture notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge left two overlaps in vectors/README.md. The producer's chunk-count formula was stated twice, once on the body cipher bullet and once on the chunking bullet added for the trailing empty final chunk. Keep it on the body cipher bullet and narrow the other to what it alone says: a decoder must not re-derive the count that way, because a stream ending in an empty final chunk holds one more chunk than the formula gives for the same plaintext. The framing-case count said three; exact-multiple-go makes four. While correcting it, the "in both directions" claim turned out to cover only two of the cases: single-chunk-go and exact-multiple-go run Go to TS only, so the sentence now names the partial final chunk and the empty file rather than claiming every case. Assisted-by: Claude:claude-opus-5 Signed-off-by: Miroslav Bajtoš --- vectors/README.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/vectors/README.md b/vectors/README.md index b5d77d0..c4d0434 100644 --- a/vectors/README.md +++ b/vectors/README.md @@ -28,10 +28,12 @@ to it and this repo matches it (see [Wire format](#wire-format)). | `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`. @@ -63,12 +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. -- **Chunking** — a producer writes `ceil(len / chunkSize)` chunks, minimum 1, - with the remainder in the final chunk; empty input is one empty chunk. Both - implementations follow that rule, so `exact-multiple-go` declares 3 chunks - rather than 3 full chunks plus an empty one. A *decoder* also accepts a - trailing empty final chunk, so the declared count is authoritative and must - not be re-derived from the plaintext length. +- **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 From 5cbd1e2052c2e6e938ce031908b4f4559e363c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 10 Aug 2026 15:25:10 +0200 Subject: [PATCH 14/18] fix: avoid allocations for empty range readers Short-circuit zero-length ranges before building the span reader or allocating chunk-sized buffers. This keeps empty HTTP-style range requests cheap in both fee and aesstream. Assisted-by: Copilot:gpt-5.4 --- aesstream/spanreader.go | 35 +++++++++++++++++++++------------ aesstream/spanreader_test.go | 11 ++++++++--- range.go | 25 ++++++++++++++++++++---- range_test.go | 38 +++++++++++++++++++++++------------- 4 files changed, 76 insertions(+), 33 deletions(-) diff --git a/aesstream/spanreader.go b/aesstream/spanreader.go index d9ec109..ca33fa4 100644 --- a/aesstream/spanreader.go +++ b/aesstream/spanreader.go @@ -264,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) @@ -284,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, @@ -299,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 140d4a3..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") }) } diff --git a/range.go b/range.go index 7edeb61..f76970f 100644 --- a/range.go +++ b/range.go @@ -54,6 +54,7 @@ const ( // 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 @@ -61,13 +62,21 @@ type RangeReader struct { } // Read implements io.Reader, yielding the requested plaintext range. -func (r *RangeReader) Read(p []byte) (int, error) { return r.sr.Read(p) } +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.sr.Len() } +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 @@ -265,10 +274,18 @@ func newRangeReader(env *cose.Envelope, blob io.ReaderAt, blobSize, headerLen in func spanRangeReader(blob io.ReaderAt, blobSize, headerLen int64, body bodyParams, plainSize int64, cek []byte, off, length int64) (*RangeReader, error) { ciphertextSize := blobSize - headerLen - start, n, _, err := aesstream.CiphertextRange(ciphertextSize, body.chunkSize, off, length) + 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. @@ -280,7 +297,7 @@ func spanRangeReader(blob io.ReaderAt, blobSize, headerLen int64, body bodyParam return nil, fmt.Errorf("fee: initializing body cipher: %w", err) } - return &RangeReader{sr: sr, size: plainSize, spanOff: headerLen + start, spanLen: n}, nil + return &RangeReader{sr: sr, len: plainLen, size: plainSize, spanOff: headerLen + start, spanLen: n}, nil } // envelopePlaintextSize is [plaintextSizeFrom] for a blob whose parameters came diff --git a/range_test.go b/range_test.go index b4450c4..b19e00f 100644 --- a/range_test.go +++ b/range_test.go @@ -364,23 +364,33 @@ func TestDecryptRangeZeroLengthReadsNoCiphertext(t *testing.T) { const size = 4 * rangeChunk f := newRangeFixture(t, size) - for _, off := range []int64{0, rangeChunk + 1, size} { - rec := newRecordingReaderAt(t, f.blob) - r, err := fee.DecryptRange(rec, int64(len(f.blob)), f.unwrapper, off, 0) - require.NoError(t, err) + 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()) + _, 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) + 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") - } + for _, rd := range rec.reads { + require.Equal(t, int64(0), rd.off, "only the header prefix is read") + } + }) } } From 9f740aa6f3b20dcf55243d2ec0044f6f23daa970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 10 Aug 2026 15:36:53 +0200 Subject: [PATCH 15/18] refactor: rename body material to descriptor Use descriptor terminology for the cached, non-secret envelope\nmetadata returned by Encrypt and consumed by range decryption.\nThis matches the review feedback and avoids implying that the\nvalue carries key material.\n\nAssisted-by: GPT-5.4:gpt-5.4 --- README.md | 10 +- material.go => descriptor.go | 42 ++--- material_test.go => descriptor_test.go | 173 +++++++++--------- ...rial_test.go => example_descriptor_test.go | 20 +- fee.go | 50 ++--- range.go | 26 +-- 6 files changed, 161 insertions(+), 160 deletions(-) rename material.go => descriptor.go (77%) rename material_test.go => descriptor_test.go (60%) rename example_material_test.go => example_descriptor_test.go (80%) diff --git a/README.md b/README.md index 7f1d933..cd29e73 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ import ( | Package | Purpose | |---|---| -| [`fee`](.) (root) | Composes the primitives below into a small API: whole-object `Encrypt`/`Decrypt`, byte-range `DecryptRange`, and the cacheable envelope parameters (`BodyMaterial`) that let a range read skip the header. Adds no cryptography of its own. | +| [`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). | @@ -327,13 +327,13 @@ They are complete before any plaintext is read, so a writer can store them while the upload is still streaming: ```go -// m goes alongside the blob's location and size. -r, m, err := fee.Encrypt(plaintext, recipients) +// d goes alongside the blob's location and size. +r, d, err := fee.Encrypt(plaintext, recipients) ``` -`fee.DecryptRangeWithMaterial(blob, blobSize, m, cek, off, length)` then serves a +`fee.DecryptRangeWithDescriptor(blob, blobSize, d, cek, off, length)` then serves a range with no envelope round trip at all: the only bytes fetched are the -ciphertext chunks the range overlaps. `m.PlaintextSize(blobSize)` answers a `HEAD` +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 diff --git a/material.go b/descriptor.go similarity index 77% rename from material.go rename to descriptor.go index bd4593f..280886a 100644 --- a/material.go +++ b/descriptor.go @@ -8,14 +8,14 @@ import ( "github.com/filecoin-project/go-fee/aesstream" ) -// ErrIncompleteMaterial means a [BodyMaterial] is missing a field, or carries one -// that cannot describe a FEE body — a value that could not decrypt anything. -var ErrIncompleteMaterial = errors.New("fee: incomplete body material") +// ErrIncompleteDescriptor means a [BodyDescriptor] is missing a field, or carries +// one that cannot describe a FEE body — a value that could not decrypt anything. +var ErrIncompleteDescriptor = errors.New("fee: incomplete body descriptor") -// BodyMaterial is everything a range decrypt needs from a FEE envelope, so a +// 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 [DecryptRangeWithMaterial]. +// encryption time and consumed by [DecryptRangeWithDescriptor]. // // 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 @@ -35,8 +35,8 @@ var ErrIncompleteMaterial = errors.New("fee: incomplete body material") // plausible-looking plaintext. The worst case is an unreadable object, not an // incorrect one. // -// The zero value is not usable; see [BodyMaterial.Validate]. -type BodyMaterial struct { +// 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 @@ -53,7 +53,7 @@ type BodyMaterial struct { // 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 - // BodyMaterial identical for both envelope forms. The protected header + // BodyDescriptor identical for both envelope forms. The protected header // remains recoverable from it: it is the structure's second element. AAD []byte } @@ -63,22 +63,22 @@ type BodyMaterial struct { // material — a partially populated record would produce a row that no later // range read could use. // -// [DecryptRangeWithMaterial] calls it, so a bad value fails there with -// [ErrIncompleteMaterial] rather than as an authentication error further down. -func (m BodyMaterial) Validate() error { +// [DecryptRangeWithDescriptor] calls it, so a bad value fails there with +// [ErrIncompleteDescriptor] 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", ErrIncompleteMaterial, m.HeaderLen) + return fmt.Errorf("%w: header length %d is not positive", ErrIncompleteDescriptor, m.HeaderLen) } if len(m.BaseNonce) != aesstream.BaseNonceSize { return fmt.Errorf("%w: base nonce is %d bytes, want %d", - ErrIncompleteMaterial, len(m.BaseNonce), aesstream.BaseNonceSize) + ErrIncompleteDescriptor, 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]", - ErrIncompleteMaterial, m.ChunkSize, aesstream.MinChunkSize, aesstream.MaxChunkSize) + ErrIncompleteDescriptor, m.ChunkSize, aesstream.MinChunkSize, aesstream.MaxChunkSize) } if len(m.AAD) == 0 { - return fmt.Errorf("%w: missing AAD", ErrIncompleteMaterial) + return fmt.Errorf("%w: missing AAD", ErrIncompleteDescriptor) } return nil } @@ -89,10 +89,10 @@ func (m BodyMaterial) Validate() error { // suffix range ("bytes=-N" is off = size-N) from cached metadata alone. // // blobSize is the whole stored object, envelope included, exactly as passed to -// [DecryptRangeWithMaterial]. It reports [ErrIncompleteMaterial] for an unusable +// [DecryptRangeWithDescriptor]. It reports [ErrIncompleteDescriptor] for an unusable // m, and [aesstream.ErrCiphertextSize] if blobSize cannot describe a FEE blob at // this header length and chunk size. -func (m BodyMaterial) PlaintextSize(blobSize int64) (int64, error) { +func (m BodyDescriptor) PlaintextSize(blobSize int64) (int64, error) { if err := m.Validate(); err != nil { return 0, err } @@ -103,7 +103,7 @@ func (m BodyMaterial) PlaintextSize(blobSize int64) (int64, error) { // whose envelope occupies headerLen bytes and whose STREAM chunks carry chunkSize // plaintext bytes each. // -// It is shared by [BodyMaterial.PlaintextSize] and the envelope-backed paths (via +// 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) { @@ -122,14 +122,14 @@ func plaintextSizeFrom(blobSize, headerLen int64, chunkSize int) (int64, error) // body 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 BodyMaterial) body() bodyParams { +func (m BodyDescriptor) body() bodyParams { return bodyParams{baseNonce: m.BaseNonce, chunkSize: m.ChunkSize, aad: m.AAD} } -// clone returns a deep copy, so a BodyMaterial handed to a caller shares no +// 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 BodyMaterial) clone() BodyMaterial { +func (m BodyDescriptor) clone() BodyDescriptor { m.BaseNonce = bytes.Clone(m.BaseNonce) m.AAD = bytes.Clone(m.AAD) return m diff --git a/material_test.go b/descriptor_test.go similarity index 60% rename from material_test.go rename to descriptor_test.go index 090067e..4a4e3c1 100644 --- a/material_test.go +++ b/descriptor_test.go @@ -14,7 +14,7 @@ import ( ) // blobLocationRow is the shape a metadata store persists per encrypted blob, -// modelled on the consumer this API exists for: the four BodyMaterial columns, +// 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. // @@ -27,7 +27,7 @@ type blobLocationRow struct { regionKeyVersion string tenantRecipientKID string - // Body material. chunkSize is int64 rather than int because a SQL bigint is + // 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 @@ -38,10 +38,10 @@ type blobLocationRow struct { size int64 } -// material rebuilds the BodyMaterial from the persisted columns, as a reader +// descriptor rebuilds the BodyDescriptor from the persisted columns, as a reader // would after loading the row. -func (r blobLocationRow) material() fee.BodyMaterial { - return fee.BodyMaterial{ +func (r blobLocationRow) descriptor() fee.BodyDescriptor { + return fee.BodyDescriptor{ HeaderLen: r.headerLen, BaseNonce: r.baseNonce, ChunkSize: int(r.chunkSize), @@ -49,28 +49,28 @@ func (r blobLocationRow) material() fee.BodyMaterial { } } -// encryptWithMaterial seals plaintext under cek and returns the wire blob -// together with the material captured from the encrypt call — the write half of +// 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 encryptWithMaterial(t *testing.T, plaintext, cek []byte, recipients []fee.Recipient, opts ...fee.EncryptOption) ([]byte, fee.BodyMaterial) { +func encryptWithDescriptor(t *testing.T, plaintext, cek []byte, recipients []fee.Recipient, opts ...fee.EncryptOption) ([]byte, fee.BodyDescriptor) { t.Helper() - // The material arrives before a single byte is read, which is what lets a + // 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, mat, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, recipients, opts...) + 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, mat + return blob, desc } // requireNoEnvelopeRead asserts that nothing below headerLen was fetched: the -// whole purpose of caching the material is that the envelope is never read again. +// 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 material was not used", + "read at offset %d (%d bytes) fell inside the %d-byte envelope; the cached descriptor was not used", rd.off, rd.n, headerLen) } } @@ -91,7 +91,7 @@ func TestIngotWriteReadFlow(t *testing.T) { regionWrapped, err := aeskw.Wrap(regionKEK, cek) require.NoError(t, err) - blob, mat := encryptWithMaterial(t, plaintext, cek, + blob, desc := encryptWithDescriptor(t, plaintext, cek, []fee.Recipient{fee.NewECDHESRecipient(ecdhKID, tenantKey.PublicKey())}, fee.WithChunkSize(rangeChunk), fee.WithContentLength(size)) @@ -102,14 +102,14 @@ func TestIngotWriteReadFlow(t *testing.T) { regionWrappedCEK: regionWrapped, regionKeyVersion: "region-key-v1", tenantRecipientKID: string(ecdhKID), - headerLen: mat.HeaderLen, - baseNonce: mat.BaseNonce, - chunkSize: int64(mat.ChunkSize), - aad: mat.AAD, + headerLen: desc.HeaderLen, + baseNonce: desc.BaseNonce, + chunkSize: int64(desc.ChunkSize), + aad: desc.AAD, size: int64(len(blob)), } - t.Run("material describes the stored bytes", func(t *testing.T) { + 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)) @@ -119,18 +119,18 @@ func TestIngotWriteReadFlow(t *testing.T) { aad, err := env.EncStructure(nil) require.NoError(t, err) - // One comparison over the whole value, so a field added to BodyMaterial + // One comparison over the whole value, so a field added to BodyDescriptor // cannot go unchecked here. - require.Equal(t, fee.BodyMaterial{ + require.Equal(t, fee.BodyDescriptor{ HeaderLen: int64(len(blob) - len(rest)), BaseNonce: iv, ChunkSize: rangeChunk, AAD: aad, - }, mat) + }, desc) }) t.Run("plaintext size from the row alone", func(t *testing.T) { - got, err := row.material().PlaintextSize(row.size) + got, err := row.descriptor().PlaintextSize(row.size) require.NoError(t, err) require.Equal(t, int64(size), got) }) @@ -148,13 +148,13 @@ func TestIngotWriteReadFlow(t *testing.T) { "empty range at eof": {size, 0}, } { t.Run(name, func(t *testing.T) { - // Everything from here reads the row, never mat or the envelope. + // 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) - r, err := fee.DecryptRangeWithMaterial(recording, row.size, row.material(), + r, err := fee.DecryptRangeWithDescriptor(recording, row.size, row.descriptor(), cek, tc.off, tc.length) require.NoError(t, err) @@ -171,36 +171,36 @@ func TestIngotWriteReadFlow(t *testing.T) { } } -// TestEncryptMaterialDoesNotAliasTheStream pins that the material handed back +// 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 TestEncryptMaterialDoesNotAliasTheStream(t *testing.T) { +func TestEncryptDescriptorDoesNotAliasTheStream(t *testing.T) { cek := newCEK(t) plaintext := patternBytes(2 * rangeChunk) - rc, mat, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, nil, + 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.BodyMaterial{ - HeaderLen: mat.HeaderLen, - BaseNonce: bytes.Clone(mat.BaseNonce), - ChunkSize: mat.ChunkSize, - AAD: bytes.Clone(mat.AAD), + kept := fee.BodyDescriptor{ + HeaderLen: desc.HeaderLen, + BaseNonce: bytes.Clone(desc.BaseNonce), + ChunkSize: desc.ChunkSize, + AAD: bytes.Clone(desc.AAD), } - mat.BaseNonce[0] ^= 0xff - mat.AAD[0] ^= 0xff + 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.DecryptRangeWithMaterial(bytes.NewReader(blob), int64(len(blob)), + r, err := fee.DecryptRangeWithDescriptor(bytes.NewReader(blob), int64(len(blob)), kept, cek, 0, int64(len(plaintext))) require.NoError(t, err) got, err := io.ReadAll(r) @@ -208,16 +208,17 @@ func TestEncryptMaterialDoesNotAliasTheStream(t *testing.T) { require.Equal(t, plaintext, got) } -// TestDecryptRangeWithMaterialEncrypt0 pins that material is envelope-form -// agnostic: a recipient-less COSE_Encrypt0 yields usable material with no flag -// and no special case, which is what lets BodyMaterial cache the finished AAD +// TestDecryptRangeWithDescriptorEncrypt0 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 TestDecryptRangeWithMaterialEncrypt0(t *testing.T) { +func TestDecryptRangeWithDescriptorEncrypt0(t *testing.T) { const size = 2 * rangeChunk plaintext := patternBytes(size) cek := newCEK(t) - blob, mat := encryptWithMaterial(t, plaintext, cek, nil, + blob, desc := encryptWithDescriptor(t, plaintext, cek, nil, fee.WithChunkSize(rangeChunk), fee.WithContentLength(size)) // It really is the recipient-less form. @@ -226,24 +227,24 @@ func TestDecryptRangeWithMaterialEncrypt0(t *testing.T) { require.Equal(t, cose.TagCOSEEncrypt0, tag) recording := newRecordingReaderAt(t, blob) - r, err := fee.DecryptRangeWithMaterial(recording, int64(len(blob)), mat, cek, 10, 4000) + r, err := fee.DecryptRangeWithDescriptor(recording, int64(len(blob)), desc, cek, 10, 4000) require.NoError(t, err) got, err := io.ReadAll(r) require.NoError(t, err) require.Equal(t, plaintext[10:4010], got) - requireNoEnvelopeRead(t, recording, mat.HeaderLen) + requireNoEnvelopeRead(t, recording, desc.HeaderLen) } -// TestDecryptRangeWithMaterialMatchesEnvelopePath asserts the cached path and the +// TestDecryptRangeWithDescriptorMatchesEnvelopePath 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 TestDecryptRangeWithMaterialMatchesEnvelopePath(t *testing.T) { +func TestDecryptRangeWithDescriptorMatchesEnvelopePath(t *testing.T) { const size = 3*rangeChunk + 7 tenantKey := newX25519Key(t) plaintext := patternBytes(size) cek := newCEK(t) - blob, mat := encryptWithMaterial(t, plaintext, cek, + blob, desc := encryptWithDescriptor(t, plaintext, cek, []fee.Recipient{fee.NewECDHESRecipient(ecdhKID, tenantKey.PublicKey())}, fee.WithChunkSize(rangeChunk), fee.WithContentLength(size)) unwrapper := fee.NewECDHESUnwrapper(ecdhKID, tenantKey) @@ -251,20 +252,20 @@ func TestDecryptRangeWithMaterialMatchesEnvelopePath(t *testing.T) { for _, off := range []int64{0, 1, rangeChunk - 1, rangeChunk, 2 * rangeChunk, size - 7} { _, viaEnvelope := decryptRange(t, blob, unwrapper, off, 500) - r, err := fee.DecryptRangeWithMaterial(bytes.NewReader(blob), int64(len(blob)), mat, cek, off, 500) + r, err := fee.DecryptRangeWithDescriptor(bytes.NewReader(blob), int64(len(blob)), desc, cek, off, 500) require.NoError(t, err) - viaMaterial, err := io.ReadAll(r) + viaDescriptor, err := io.ReadAll(r) require.NoError(t, err) - require.Equalf(t, viaEnvelope, viaMaterial, "paths disagree at off=%d", off) + require.Equalf(t, viaEnvelope, viaDescriptor, "paths disagree at off=%d", off) } } -// TestBodyMaterialValidate covers the all-or-nothing rule a store mirrors before +// 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 TestBodyMaterialValidate(t *testing.T) { - good := fee.BodyMaterial{ +func TestBodyDescriptorValidate(t *testing.T) { + good := fee.BodyDescriptor{ HeaderLen: 128, BaseNonce: make([]byte, aesstream.BaseNonceSize), ChunkSize: rangeChunk, @@ -272,61 +273,61 @@ func TestBodyMaterialValidate(t *testing.T) { } require.NoError(t, good.Validate()) - for name, mutate := range map[string]func(*fee.BodyMaterial){ - "zero value": func(m *fee.BodyMaterial) { *m = fee.BodyMaterial{} }, - "no header length": func(m *fee.BodyMaterial) { m.HeaderLen = 0 }, - "negative header": func(m *fee.BodyMaterial) { m.HeaderLen = -1 }, - "no base nonce": func(m *fee.BodyMaterial) { m.BaseNonce = nil }, - "short base nonce": func(m *fee.BodyMaterial) { m.BaseNonce = make([]byte, 3) }, - "no chunk size": func(m *fee.BodyMaterial) { m.ChunkSize = 0 }, - "chunk size tiny": func(m *fee.BodyMaterial) { m.ChunkSize = aesstream.MinChunkSize - 1 }, - "chunk size huge": func(m *fee.BodyMaterial) { m.ChunkSize = aesstream.MaxChunkSize + 1 }, - "no aad": func(m *fee.BodyMaterial) { m.AAD = nil }, - "empty (not nil) aad": func(m *fee.BodyMaterial) { m.AAD = []byte{} }, + 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.ErrIncompleteMaterial) + require.ErrorIs(t, m.Validate(), fee.ErrIncompleteDescriptor) // The range entry point rejects it up front for the same reason, // rather than letting it fail as an authentication error later. - _, err := fee.DecryptRangeWithMaterial(bytes.NewReader([]byte("blob")), 4096, m, + _, err := fee.DecryptRangeWithDescriptor(bytes.NewReader([]byte("blob")), 4096, m, make([]byte, aesstream.KeySize), 0, 10) - require.ErrorIs(t, err, fee.ErrIncompleteMaterial) + require.ErrorIs(t, err, fee.ErrIncompleteDescriptor) }) } } -// TestDecryptRangeWithMaterialPoisoned is the safety property that makes caching -// this material acceptable: a row that has drifted from the bytes on disk fails +// TestDecryptRangeWithDescriptorPoisoned 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 TestDecryptRangeWithMaterialPoisoned(t *testing.T) { +func TestDecryptRangeWithDescriptorPoisoned(t *testing.T) { const size = 3 * rangeChunk plaintext := patternBytes(size) cek := newCEK(t) - blob, mat := encryptWithMaterial(t, plaintext, cek, nil, + blob, desc := encryptWithDescriptor(t, plaintext, cek, nil, fee.WithChunkSize(rangeChunk), fee.WithContentLength(size)) - for name, mutate := range map[string]func(m *fee.BodyMaterial){ - "header length off by one": func(m *fee.BodyMaterial) { m.HeaderLen++ }, - "wrong base nonce": func(m *fee.BodyMaterial) { + 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.BodyMaterial) { + "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.BodyMaterial) { m.ChunkSize = rangeChunk * 2 }, + "wrong chunk size": func(m *fee.BodyDescriptor) { m.ChunkSize = rangeChunk * 2 }, } { t.Run(name, func(t *testing.T) { - poisoned := mat + poisoned := desc mutate(&poisoned) - r, err := fee.DecryptRangeWithMaterial(bytes.NewReader(blob), int64(len(blob)), + r, err := fee.DecryptRangeWithDescriptor(bytes.NewReader(blob), int64(len(blob)), poisoned, cek, 0, 200) if err != nil { return // rejected at construction, which is a fine outcome @@ -338,39 +339,39 @@ func TestDecryptRangeWithMaterialPoisoned(t *testing.T) { } } -// TestDecryptRangeWithMaterialInvalidArgs covers the argument checks that do not -// depend on the material being right. -func TestDecryptRangeWithMaterialInvalidArgs(t *testing.T) { +// TestDecryptRangeWithDescriptorInvalidArgs covers the argument checks that do not +// depend on the descriptor being right. +func TestDecryptRangeWithDescriptorInvalidArgs(t *testing.T) { const size = 2 * rangeChunk plaintext := patternBytes(size) cek := newCEK(t) - blob, mat := encryptWithMaterial(t, plaintext, cek, nil, + 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.DecryptRangeWithMaterial(bytes.NewReader(blob), blobSize, mat, + _, err := fee.DecryptRangeWithDescriptor(bytes.NewReader(blob), blobSize, desc, make([]byte, 16), 0, 10) require.ErrorIs(t, err, fee.ErrInvalidCEK) }) t.Run("nil blob", func(t *testing.T) { - _, err := fee.DecryptRangeWithMaterial(nil, blobSize, mat, cek, 0, 10) + _, err := fee.DecryptRangeWithDescriptor(nil, blobSize, desc, cek, 0, 10) require.Error(t, err) }) t.Run("blob shorter than its envelope", func(t *testing.T) { - _, err := fee.DecryptRangeWithMaterial(bytes.NewReader(blob), mat.HeaderLen-1, mat, cek, 0, 10) + _, err := fee.DecryptRangeWithDescriptor(bytes.NewReader(blob), desc.HeaderLen-1, desc, cek, 0, 10) require.ErrorIs(t, err, aesstream.ErrCiphertextSize) }) t.Run("offset past the end", func(t *testing.T) { - _, err := fee.DecryptRangeWithMaterial(bytes.NewReader(blob), blobSize, mat, cek, size+1, 10) + _, err := fee.DecryptRangeWithDescriptor(bytes.NewReader(blob), blobSize, desc, cek, size+1, 10) require.ErrorIs(t, err, aesstream.ErrRange) }) t.Run("negative offset", func(t *testing.T) { - _, err := fee.DecryptRangeWithMaterial(bytes.NewReader(blob), blobSize, mat, cek, -1, 10) + _, err := fee.DecryptRangeWithDescriptor(bytes.NewReader(blob), blobSize, desc, cek, -1, 10) require.ErrorIs(t, err, aesstream.ErrRange) }) } diff --git a/example_material_test.go b/example_descriptor_test.go similarity index 80% rename from example_material_test.go rename to example_descriptor_test.go index c919ee3..435ea83 100644 --- a/example_material_test.go +++ b/example_descriptor_test.go @@ -28,12 +28,12 @@ func (c *countingBlob) ReadAt(p []byte, off int64) (int, error) { return n, err } -// ExampleDecryptRangeWithMaterial stores an object once and then serves a byte +// ExampleDecryptRangeWithDescriptor 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 ExampleDecryptRangeWithMaterial() { +func ExampleDecryptRangeWithDescriptor() { priv, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { log.Fatal(err) @@ -47,10 +47,10 @@ func ExampleDecryptRangeWithMaterial() { log.Fatal(err) } - // The material is complete before a byte is read, so a writer can record it + // 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, material, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, + enc, descriptor, err := fee.EncryptWithCEK(bytes.NewReader(plaintext), cek, []fee.Recipient{fee.NewECDHESRecipient(kid, priv.PublicKey())}, fee.WithContentLength(int64(len(plaintext)))) if err != nil { @@ -65,18 +65,18 @@ func ExampleDecryptRangeWithMaterial() { log.Fatal(err) } - // What a store persists alongside the blob's location: the material, plus + // What a store persists alongside the blob's location: the descriptor, plus // the blob's exact size. row := struct { - material fee.BodyMaterial - blobSize int64 - }{material, int64(len(blob))} + 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.material.HeaderLen} + src := &countingBlob{blob: bytes.NewReader(blob), headerLen: row.descriptor.HeaderLen} const off, length = 4, 15 - r, err := fee.DecryptRangeWithMaterial(src, row.blobSize, row.material, cek, off, length) + r, err := fee.DecryptRangeWithDescriptor(src, row.blobSize, row.descriptor, cek, off, length) if err != nil { log.Fatal(err) } diff --git a/fee.go b/fee.go index 1afdc4f..c4db1e8 100644 --- a/fee.go +++ b/fee.go @@ -74,9 +74,9 @@ // // 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 -// [BodyMaterial]; persisting those and passing them to -// [DecryptRangeWithMaterial] serves a range with no envelope round trip at all, -// and [BodyMaterial.PlaintextSize] answers a HEAD from the same record. +// [BodyDescriptor]; persisting those and passing them to +// [DecryptRangeWithDescriptor] serves a range with no envelope round trip at all, +// and [BodyDescriptor.PlaintextSize] answers a HEAD from the same record. // // # Scope // @@ -215,16 +215,16 @@ func WithContentLength(n int64) EncryptOption { // // 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 -// [BodyMaterial]. It is complete on return, before any plaintext is read, so a +// [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, BodyMaterial, error) { +func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, BodyDescriptor, error) { if len(recipients) == 0 { - return nil, BodyMaterial{}, ErrNoRecipients + return nil, BodyDescriptor{}, ErrNoRecipients } cek := make([]byte, aesstream.KeySize) if _, err := rand.Read(cek); err != nil { - return nil, BodyMaterial{}, 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 @@ -258,11 +258,11 @@ func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption) // wrapped to any recipients) but neither retained nor wiped by this call. // // As with [Encrypt], the second result carries the envelope parameters a later -// range decrypt needs; see [BodyMaterial]. They are reported for a +// 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, BodyMaterial, error) { +func EncryptWithCEK(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, BodyDescriptor, error) { if err := checkCEK(cek); err != nil { - return nil, BodyMaterial{}, err + return nil, BodyDescriptor{}, err } return encryptStream(plaintext, cek, recipients, opts...) } @@ -278,16 +278,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, BodyMaterial, error) { +func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, BodyDescriptor, error) { if plaintext == nil { - return nil, BodyMaterial{}, 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, BodyMaterial{}, 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, BodyMaterial{}, fmt.Errorf("fee: recipient %d: %w", i, err) + return nil, BodyDescriptor{}, fmt.Errorf("fee: recipient %d: %w", i, err) } } @@ -303,12 +303,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, BodyMaterial{}, 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, BodyMaterial{}, 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 @@ -336,7 +336,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, BodyMaterial{}, werr + return nil, BodyDescriptor{}, werr } entries[i] = entry } @@ -349,11 +349,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, BodyMaterial{}, 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, BodyMaterial{}, 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 @@ -370,7 +370,7 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts if err != nil { _ = pw.Close() _ = pr.Close() - return nil, BodyMaterial{}, fmt.Errorf("fee: initializing body cipher: %w", err) + return nil, BodyDescriptor{}, fmt.Errorf("fee: initializing body cipher: %w", err) } declaredLen := cfg.contentLength @@ -392,11 +392,11 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts _ = pw.CloseWithError(cerr) }() - // Every value the material reports is fixed above, before any plaintext is + // 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. - material := BodyMaterial{ + descriptor := BodyDescriptor{ HeaderLen: int64(len(header)), BaseNonce: baseNonce, ChunkSize: cfg.chunkSize, @@ -405,7 +405,7 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts return &encryptReader{ body: io.MultiReader(bytes.NewReader(header), pr), pr: pr, - }, material, nil + }, descriptor, nil } // chunkCountFor reports how many STREAM chunks a plaintext of nPlain bytes @@ -532,11 +532,11 @@ func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader // carries: everything fee/aesstream needs to decrypt the detached ciphertext // apart from the content-encryption key. // -// [BodyMaterial] is the same parameters plus the envelope's encoded length — +// [BodyDescriptor] is the same parameters 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 two shapes stay -// distinct and [BodyMaterial.body] converts one way. +// distinct and [BodyDescriptor.body] converts one way. type bodyParams struct { baseNonce []byte chunkSize int diff --git a/range.go b/range.go index f76970f..8c27887 100644 --- a/range.go +++ b/range.go @@ -40,9 +40,9 @@ const ( // RangeReader streams the decrypted plaintext of one byte range of a FEE blob. // It is returned by [DecryptRange], [DecryptRangeWithCEK] and -// [DecryptRangeWithMaterial], and reads ciphertext lazily: nothing beyond the +// [DecryptRangeWithDescriptor], and reads ciphertext lazily: nothing beyond the // envelope header is fetched until Read is called (nothing at all on the cached -// material path, which reads no envelope), and then only the chunks the range +// descriptor path, 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 @@ -80,8 +80,8 @@ 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. [BodyMaterial.PlaintextSize] reports the same -// number from cached material, without a reader. +// 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 @@ -181,15 +181,15 @@ func DecryptRangeWithCEK(blob io.ReaderAt, blobSize int64, cek []byte, off, leng return newRangeReader(env, blob, blobSize, headerLen, cek, off, length) } -// DecryptRangeWithMaterial is [DecryptRangeWithCEK] for a caller that already +// DecryptRangeWithDescriptor is [DecryptRangeWithCEK] for a caller that already // holds the envelope's parameters, as [Encrypt] reports them at encryption time. // Unlike every other entry point here it reads no envelope at all: the only // bytes fetched from blob are the ciphertext chunks the range overlaps, so // a caller fronting a remote object store spends no round trip re-reading a // header it has already seen. // -// m must describe this blob. A value that cannot describe any FEE body is -// rejected up front with [ErrIncompleteMaterial]; one that is well-formed but +// d must describe this blob. A value that cannot describe any FEE body is +// rejected up front with [ErrIncompleteDescriptor]; one that is well-formed but // belongs to a different object, or has drifted from the bytes on disk, is caught // by the body cipher instead — BaseNonce and AAD are bound into every chunk's // tag, so the read fails with [aesstream.ErrCorrupted] rather than emitting wrong @@ -199,7 +199,7 @@ func DecryptRangeWithCEK(blob io.ReaderAt, blobSize int64, cek []byte, off, leng // [DecryptRange]. Because there is no envelope to consult, the declared // chunk-count cross-check that yields [ErrSizeMismatch] on the other paths cannot // run here: nothing detects a blobSize that disagrees with the stored object. -// A caller that records the blob's size alongside this material should compare +// A caller that records the blob's size alongside this descriptor should compare // the two before trusting a range, since a size from a store that has silently // lost bytes reads as a shorter object whose interior ranges decrypt cleanly (see // the accuracy note on [DecryptRange]). @@ -207,18 +207,18 @@ func DecryptRangeWithCEK(blob io.ReaderAt, blobSize int64, cek []byte, off, leng // cek must be 32 bytes (AES-256). The caller retains ownership: it is copied into // the body cipher but neither retained nor wiped. off and length behave exactly // as in [DecryptRange]. -func DecryptRangeWithMaterial(blob io.ReaderAt, blobSize int64, m BodyMaterial, cek []byte, off, length int64) (*RangeReader, error) { +func DecryptRangeWithDescriptor(blob io.ReaderAt, blobSize int64, d BodyDescriptor, cek []byte, off, length int64) (*RangeReader, error) { if err := checkCEK(cek); err != nil { return nil, err } if blob == nil { return nil, errNilBlob } - plainSize, err := m.PlaintextSize(blobSize) // validates m, and blobSize against it + plainSize, err := d.PlaintextSize(blobSize) // validates d, and blobSize against it if err != nil { return nil, err } - return spanRangeReader(blob, blobSize, m.HeaderLen, m.body(), plainSize, cek, off, length) + return spanRangeReader(blob, blobSize, d.HeaderLen, d.body(), plainSize, cek, off, length) } // PlaintextSize reports the total decrypted size of a FEE blob from its envelope @@ -263,8 +263,8 @@ func newRangeReader(env *cose.Envelope, blob io.ReaderAt, blobSize, headerLen in } // spanRangeReader is the geometry-and-wiring tail shared by the envelope-backed -// path ([newRangeReader]) and the cached-material path -// ([DecryptRangeWithMaterial]): given the resolved body parameters and the +// path ([newRangeReader]) and the cached-descriptor path +// ([DecryptRangeWithDescriptor]): 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. // From eeff7ba84eefc6ca03cd7142a87882ffc8d46837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 10 Aug 2026 16:17:07 +0200 Subject: [PATCH 16/18] refactor: fold descriptor into range decrypt Make the external-CEK range API cover both envelope-decoding and cached-descriptor reads through one entry point. This keeps the range reader behavior, examples, and docs aligned while preserving the no-header-read path for cached metadata. Assisted-by: Copilot:gpt-5.4 --- README.md | 7 +-- descriptor.go | 13 ++--- descriptor_test.go | 65 +++++++++++++------------ example_descriptor_test.go | 10 ++-- fee.go | 5 +- range.go | 97 +++++++++++++++++--------------------- range_test.go | 12 ++--- 7 files changed, 101 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index cd29e73..97756ce 100644 --- a/README.md +++ b/README.md @@ -331,10 +331,11 @@ the upload is still streaming: r, d, err := fee.Encrypt(plaintext, recipients) ``` -`fee.DecryptRangeWithDescriptor(blob, blobSize, d, cek, off, length)` then serves a +`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. +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 diff --git a/descriptor.go b/descriptor.go index 280886a..65cb2b2 100644 --- a/descriptor.go +++ b/descriptor.go @@ -15,7 +15,7 @@ var ErrIncompleteDescriptor = errors.New("fee: incomplete 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 [DecryptRangeWithDescriptor]. +// 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 @@ -63,8 +63,9 @@ type BodyDescriptor struct { // material — a partially populated record would produce a row that no later // range read could use. // -// [DecryptRangeWithDescriptor] calls it, so a bad value fails there with -// [ErrIncompleteDescriptor] rather than as an authentication error further down. +// [DecryptRangeWithCEK] calls it when desc is non-nil, so a bad value fails +// there with [ErrIncompleteDescriptor] 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", ErrIncompleteDescriptor, m.HeaderLen) @@ -89,9 +90,9 @@ func (m BodyDescriptor) Validate() error { // suffix range ("bytes=-N" is off = size-N) from cached metadata alone. // // blobSize is the whole stored object, envelope included, exactly as passed to -// [DecryptRangeWithDescriptor]. It reports [ErrIncompleteDescriptor] for an unusable -// m, and [aesstream.ErrCiphertextSize] if blobSize cannot describe a FEE blob at -// this header length and chunk size. +// [DecryptRangeWithCEK] when desc is non-nil. It reports [ErrIncompleteDescriptor] +// 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 diff --git a/descriptor_test.go b/descriptor_test.go index 4a4e3c1..f866b90 100644 --- a/descriptor_test.go +++ b/descriptor_test.go @@ -154,8 +154,8 @@ func TestIngotWriteReadFlow(t *testing.T) { defer clear(cek) recording := newRecordingReaderAt(t, blob) - r, err := fee.DecryptRangeWithDescriptor(recording, row.size, row.descriptor(), - cek, tc.off, tc.length) + 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)] @@ -200,20 +200,19 @@ func TestEncryptDescriptorDoesNotAliasTheStream(t *testing.T) { // The blob still decrypts under the pristine values, so the mutation never // reached the cipher or the encoded header. - r, err := fee.DecryptRangeWithDescriptor(bytes.NewReader(blob), int64(len(blob)), - kept, cek, 0, int64(len(plaintext))) + 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) } -// TestDecryptRangeWithDescriptorEncrypt0 pins that the descriptor is +// 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 TestDecryptRangeWithDescriptorEncrypt0(t *testing.T) { +// 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) @@ -227,7 +226,7 @@ func TestDecryptRangeWithDescriptorEncrypt0(t *testing.T) { require.Equal(t, cose.TagCOSEEncrypt0, tag) recording := newRecordingReaderAt(t, blob) - r, err := fee.DecryptRangeWithDescriptor(recording, int64(len(blob)), desc, cek, 10, 4000) + 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) @@ -235,10 +234,11 @@ func TestDecryptRangeWithDescriptorEncrypt0(t *testing.T) { requireNoEnvelopeRead(t, recording, desc.HeaderLen) } -// TestDecryptRangeWithDescriptorMatchesEnvelopePath 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 TestDecryptRangeWithDescriptorMatchesEnvelopePath(t *testing.T) { +// 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) @@ -252,7 +252,7 @@ func TestDecryptRangeWithDescriptorMatchesEnvelopePath(t *testing.T) { for _, off := range []int64{0, 1, rangeChunk - 1, rangeChunk, 2 * rangeChunk, size - 7} { _, viaEnvelope := decryptRange(t, blob, unwrapper, off, 500) - r, err := fee.DecryptRangeWithDescriptor(bytes.NewReader(blob), int64(len(blob)), desc, cek, 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) @@ -292,19 +292,20 @@ func TestBodyDescriptorValidate(t *testing.T) { // The range entry point rejects it up front for the same reason, // rather than letting it fail as an authentication error later. - _, err := fee.DecryptRangeWithDescriptor(bytes.NewReader([]byte("blob")), 4096, m, - make([]byte, aesstream.KeySize), 0, 10) + _, err := fee.DecryptRangeWithCEK(bytes.NewReader([]byte("blob")), 4096, + make([]byte, aesstream.KeySize), 0, 10, &m) require.ErrorIs(t, err, fee.ErrIncompleteDescriptor) }) } } -// TestDecryptRangeWithDescriptorPoisoned 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 TestDecryptRangeWithDescriptorPoisoned(t *testing.T) { +// 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) @@ -327,8 +328,7 @@ func TestDecryptRangeWithDescriptorPoisoned(t *testing.T) { poisoned := desc mutate(&poisoned) - r, err := fee.DecryptRangeWithDescriptor(bytes.NewReader(blob), int64(len(blob)), - poisoned, cek, 0, 200) + 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 } @@ -339,9 +339,9 @@ func TestDecryptRangeWithDescriptorPoisoned(t *testing.T) { } } -// TestDecryptRangeWithDescriptorInvalidArgs covers the argument checks that do not -// depend on the descriptor being right. -func TestDecryptRangeWithDescriptorInvalidArgs(t *testing.T) { +// 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) @@ -350,28 +350,27 @@ func TestDecryptRangeWithDescriptorInvalidArgs(t *testing.T) { blobSize := int64(len(blob)) t.Run("short cek", func(t *testing.T) { - _, err := fee.DecryptRangeWithDescriptor(bytes.NewReader(blob), blobSize, desc, - make([]byte, 16), 0, 10) + _, 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.DecryptRangeWithDescriptor(nil, blobSize, desc, cek, 0, 10) + _, 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.DecryptRangeWithDescriptor(bytes.NewReader(blob), desc.HeaderLen-1, desc, cek, 0, 10) + _, 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.DecryptRangeWithDescriptor(bytes.NewReader(blob), blobSize, desc, cek, size+1, 10) + _, 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.DecryptRangeWithDescriptor(bytes.NewReader(blob), blobSize, desc, cek, -1, 10) + _, 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 index 435ea83..98b5132 100644 --- a/example_descriptor_test.go +++ b/example_descriptor_test.go @@ -28,12 +28,12 @@ func (c *countingBlob) ReadAt(p []byte, off int64) (int, error) { return n, err } -// ExampleDecryptRangeWithDescriptor 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 +// 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 ExampleDecryptRangeWithDescriptor() { +func ExampleDecryptRangeWithCEK_withBodyDescriptor() { priv, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { log.Fatal(err) @@ -76,7 +76,7 @@ func ExampleDecryptRangeWithDescriptor() { // 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.DecryptRangeWithDescriptor(src, row.blobSize, row.descriptor, cek, off, length) + r, err := fee.DecryptRangeWithCEK(src, row.blobSize, cek, off, length, &row.descriptor) if err != nil { log.Fatal(err) } diff --git a/fee.go b/fee.go index c4db1e8..1697c72 100644 --- a/fee.go +++ b/fee.go @@ -75,8 +75,9 @@ // 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 -// [DecryptRangeWithDescriptor] serves a range with no envelope round trip at all, -// and [BodyDescriptor.PlaintextSize] answers a HEAD from the same record. +// [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 // diff --git a/range.go b/range.go index 8c27887..170a843 100644 --- a/range.go +++ b/range.go @@ -39,11 +39,11 @@ const ( ) // RangeReader streams the decrypted plaintext of one byte range of a FEE blob. -// It is returned by [DecryptRange], [DecryptRangeWithCEK] and -// [DecryptRangeWithDescriptor], and reads ciphertext lazily: nothing beyond the -// envelope header is fetched until Read is called (nothing at all on the cached -// descriptor path, which reads no envelope), and then only the chunks the range -// overlaps. +// 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 @@ -168,57 +168,47 @@ func DecryptRange(blob io.ReaderAt, blobSize int64, unwrap RecipientUnwrapper, o // 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 [ErrIncompleteDescriptor]; 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) (*RangeReader, error) { - if err := checkCEK(cek); err != nil { - return nil, err - } - env, headerLen, err := decodeHeaderAt(blob, blobSize) - if err != nil { - return nil, err +func DecryptRangeWithCEK(blob io.ReaderAt, blobSize int64, cek []byte, off, length int64, desc *BodyDescriptor) (*RangeReader, error) { + if blob == nil { + return nil, errNilBlob } - return newRangeReader(env, blob, blobSize, headerLen, cek, off, length) -} -// DecryptRangeWithDescriptor is [DecryptRangeWithCEK] for a caller that already -// holds the envelope's parameters, as [Encrypt] reports them at encryption time. -// Unlike every other entry point here it reads no envelope at all: the only -// bytes fetched from blob are the ciphertext chunks the range overlaps, so -// a caller fronting a remote object store spends no round trip re-reading a -// header it has already seen. -// -// d must describe this blob. A value that cannot describe any FEE body is -// rejected up front with [ErrIncompleteDescriptor]; one that is well-formed but -// belongs to a different object, or has drifted from the bytes on disk, is caught -// by the body cipher instead — BaseNonce and AAD are bound into every chunk's -// tag, so the read fails with [aesstream.ErrCorrupted] rather than emitting wrong -// plaintext. -// -// blobSize is the whole stored object, envelope included, exactly as for -// [DecryptRange]. Because there is no envelope to consult, the declared -// chunk-count cross-check that yields [ErrSizeMismatch] on the other paths cannot -// run here: nothing detects a blobSize that disagrees with the stored object. -// A caller that records the blob's size alongside this descriptor should compare -// the two before trusting a range, since a size from a store that has silently -// lost bytes reads as a shorter object whose interior ranges decrypt cleanly (see -// the accuracy note on [DecryptRange]). -// -// cek must be 32 bytes (AES-256). The caller retains ownership: it is copied into -// the body cipher but neither retained nor wiped. off and length behave exactly -// as in [DecryptRange]. -func DecryptRangeWithDescriptor(blob io.ReaderAt, blobSize int64, d BodyDescriptor, cek []byte, off, length int64) (*RangeReader, error) { if err := checkCEK(cek); err != nil { return nil, err } - if blob == nil { - return nil, errNilBlob + + 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.body(), plainSize, cek, off, length) } - plainSize, err := d.PlaintextSize(blobSize) // validates d, and blobSize against it + + env, headerLen, err := decodeHeaderAt(blob, blobSize) if err != nil { return nil, err } - return spanRangeReader(blob, blobSize, d.HeaderLen, d.body(), plainSize, cek, off, length) + return newRangeReader(env, blob, blobSize, headerLen, cek, off, length) } // PlaintextSize reports the total decrypted size of a FEE blob from its envelope @@ -240,10 +230,11 @@ func PlaintextSize(blob io.ReaderAt, blobSize int64) (int64, error) { return envelopePlaintextSize(env, blobSize, headerLen, body.chunkSize) } -// newRangeReader is the shared core of DecryptRange and 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. +// 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 @@ -263,10 +254,10 @@ func newRangeReader(env *cose.Envelope, blob io.ReaderAt, blobSize, headerLen in } // spanRangeReader is the geometry-and-wiring tail shared by the envelope-backed -// path ([newRangeReader]) and the cached-descriptor path -// ([DecryptRangeWithDescriptor]): 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. +// 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 and plainSize — decoded // from the envelope, or supplied from a caller's cache — so keeping the wiring in diff --git a/range_test.go b/range_test.go index b19e00f..12bf2b5 100644 --- a/range_test.go +++ b/range_test.go @@ -288,7 +288,7 @@ func TestDecryptRangeWithCEK(t *testing.T) { }, fee.WithChunkSize(rangeChunk)) require.NoError(t, err) - r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), int64(len(blob)), cek, off, length) + 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) @@ -302,7 +302,7 @@ func TestDecryptRangeWithCEK(t *testing.T) { require.NoError(t, err) require.Equal(t, cose.TagCOSEEncrypt0, tag) - r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), int64(len(blob)), cek, off, length) + 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) @@ -566,7 +566,7 @@ func TestDecryptRangeInvalidArgs(t *testing.T) { 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) + _, err = fee.DecryptRangeWithCEK(nil, size, newCEK(t), 0, 10, nil) require.Error(t, err) }) @@ -576,7 +576,7 @@ func TestDecryptRangeInvalidArgs(t *testing.T) { }) t.Run("cek wrong length", func(t *testing.T) { - _, err := fee.DecryptRangeWithCEK(bytes.NewReader(f.blob), size, make([]byte, 16), 0, 10) + _, err := fee.DecryptRangeWithCEK(bytes.NewReader(f.blob), size, make([]byte, 16), 0, 10, nil) require.ErrorIs(t, err, fee.ErrInvalidCEK) }) @@ -712,7 +712,7 @@ func TestDecryptRangeTrailingEmptyFinalChunk(t *testing.T) { 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) + 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) @@ -721,7 +721,7 @@ func TestDecryptRangeTrailingEmptyFinalChunk(t *testing.T) { }) t.Run("whole object as one range", func(t *testing.T) { - r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), blobSize, cek, 0, size) + 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) From 46a967bc7735d2d2b4cc1c5a0162c0dd6883f221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 10 Aug 2026 16:52:26 +0200 Subject: [PATCH 17/18] refactor: split body params from AAD Keep header-only validation separate from AAD reconstruction so PlaintextSize avoids building Enc_structure bytes it does not use. Return validated body parameters and rebuilt AAD separately in the body-cipher paths, and split the cached descriptor accessors to match. Assisted-by: Copilot:gpt-5.4 --- descriptor.go | 15 ++++++++----- fee.go | 60 ++++++++++++++++++++++++++++++--------------------- range.go | 19 ++++++++-------- 3 files changed, 56 insertions(+), 38 deletions(-) diff --git a/descriptor.go b/descriptor.go index 65cb2b2..ac6fd66 100644 --- a/descriptor.go +++ b/descriptor.go @@ -120,11 +120,16 @@ func plaintextSizeFrom(blobSize, headerLen int64, chunkSize int) (int64, error) return n, nil } -// body 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) body() bodyParams { - return bodyParams{baseNonce: m.BaseNonce, chunkSize: m.ChunkSize, aad: m.AAD} +// 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 diff --git a/fee.go b/fee.go index 1697c72..5ff878d 100644 --- a/fee.go +++ b/fee.go @@ -518,11 +518,11 @@ 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, err := validateBody(env) + body, aad, err := buildBodyParamsWithAAD(env) if err != nil { return nil, err } - r, err := aesstream.NewReader(ciphertext, body.streamConfig(cek)) + r, err := aesstream.NewReader(ciphertext, body.streamConfig(cek, aad)) if err != nil { return nil, fmt.Errorf("fee: initializing body cipher: %w", err) } @@ -530,43 +530,44 @@ func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader } // bodyParams is the validated STREAM configuration a FEE envelope's body header -// carries: everything fee/aesstream needs to decrypt the detached ciphertext -// apart from the content-encryption key. +// carries: everything fee/aesstream needs to size and locate the detached +// ciphertext apart from the content-encryption key and Enc_structure AAD. // -// [BodyDescriptor] is the same parameters plus the envelope's encoded length — +// [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 two shapes stay -// distinct and [BodyDescriptor.body] converts one way. +// 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 - aad []byte } // streamConfig returns the fee/aesstream configuration for decrypting a body -// with these parameters under cek. 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 []byte) aesstream.Config { +// 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: b.aad, + AAD: aad, ChunkSize: b.chunkSize, } } -// validateBody 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 — and rebuilds the Enc_structure AAD -// that the encoder bound into every chunk. +// 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 ([openStream]) and the range path -// ([newRangeReader]), so both accept exactly the same envelopes and report the -// same errors for a body header they cannot honour. -func validateBody(env *cose.Envelope) (bodyParams, error) { +// 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 bodyParams{}, fmt.Errorf("%w: body algorithm header missing or not an integer", ErrUnsupportedBodyAlg) @@ -597,15 +598,26 @@ func validateBody(env *cose.Envelope) (bodyParams, error) { 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 bodyParams{}, fmt.Errorf("fee: building envelope AAD: %w", err) + return bodyParams{}, nil, fmt.Errorf("fee: building envelope AAD: %w", err) } - return bodyParams{baseNonce: baseNonce, chunkSize: int(chunkSize), aad: aad}, nil + return body, aad, nil } // checkCEK reports whether a caller-provided content-encryption key is the right diff --git a/range.go b/range.go index 170a843..8ea3b57 100644 --- a/range.go +++ b/range.go @@ -201,7 +201,7 @@ func DecryptRangeWithCEK(blob io.ReaderAt, blobSize int64, cek []byte, off, leng if err != nil { return nil, err } - return spanRangeReader(blob, blobSize, d.HeaderLen, d.body(), plainSize, cek, off, length) + return spanRangeReader(blob, blobSize, d.HeaderLen, d.bodyParams(), d.aad(), plainSize, cek, off, length) } env, headerLen, err := decodeHeaderAt(blob, blobSize) @@ -223,7 +223,7 @@ func PlaintextSize(blob io.ReaderAt, blobSize int64) (int64, error) { if err != nil { return 0, err } - body, err := validateBody(env) + body, err := validateBodyParams(env) if err != nil { return 0, err } @@ -241,7 +241,7 @@ func PlaintextSize(blob io.ReaderAt, blobSize int64) (int64, error) { // 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, err := validateBody(env) + body, aad, err := buildBodyParamsWithAAD(env) if err != nil { return nil, err } @@ -250,7 +250,7 @@ func newRangeReader(env *cose.Envelope, blob io.ReaderAt, blobSize, headerLen in if err != nil { return nil, err } - return spanRangeReader(blob, blobSize, headerLen, body, plainSize, cek, off, length) + return spanRangeReader(blob, blobSize, headerLen, body, aad, plainSize, cek, off, length) } // spanRangeReader is the geometry-and-wiring tail shared by the envelope-backed @@ -259,10 +259,11 @@ func newRangeReader(env *cose.Envelope, blob io.ReaderAt, blobSize, headerLen in // 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 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, plainSize int64, cek []byte, off, length int64) (*RangeReader, error) { +// 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) @@ -282,7 +283,7 @@ func spanRangeReader(blob io.ReaderAt, blobSize, headerLen int64, body bodyParam // aesstream exactly the bytes it will ask for and nothing else. sr, err := aesstream.NewSpanReader( io.NewSectionReader(blob, headerLen+start, n), - body.streamConfig(cek), + body.streamConfig(cek, aad), ciphertextSize, off, length) if err != nil { return nil, fmt.Errorf("fee: initializing body cipher: %w", err) From 68ea0fa2797d0db6cee01bdd29ce8c04f155b044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 18 Aug 2026 19:29:59 +0200 Subject: [PATCH 18/18] refactor: rename error to ErrInvalidDescriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Claude:claude-fable-5 Signed-off-by: Miroslav Bajtoš " --- descriptor.go | 16 ++++++++-------- descriptor_test.go | 4 ++-- range.go | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/descriptor.go b/descriptor.go index ac6fd66..2d79eb5 100644 --- a/descriptor.go +++ b/descriptor.go @@ -8,9 +8,9 @@ import ( "github.com/filecoin-project/go-fee/aesstream" ) -// ErrIncompleteDescriptor means a [BodyDescriptor] is missing a field, or carries +// 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 ErrIncompleteDescriptor = errors.New("fee: incomplete body descriptor") +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 @@ -64,22 +64,22 @@ type BodyDescriptor struct { // range read could use. // // [DecryptRangeWithCEK] calls it when desc is non-nil, so a bad value fails -// there with [ErrIncompleteDescriptor] rather than as an authentication error +// 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", ErrIncompleteDescriptor, m.HeaderLen) + 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", - ErrIncompleteDescriptor, len(m.BaseNonce), aesstream.BaseNonceSize) + 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]", - ErrIncompleteDescriptor, m.ChunkSize, aesstream.MinChunkSize, aesstream.MaxChunkSize) + ErrInvalidDescriptor, m.ChunkSize, aesstream.MinChunkSize, aesstream.MaxChunkSize) } if len(m.AAD) == 0 { - return fmt.Errorf("%w: missing AAD", ErrIncompleteDescriptor) + return fmt.Errorf("%w: missing AAD", ErrInvalidDescriptor) } return nil } @@ -90,7 +90,7 @@ func (m BodyDescriptor) Validate() error { // 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 [ErrIncompleteDescriptor] +// [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) { diff --git a/descriptor_test.go b/descriptor_test.go index f866b90..aa60ebc 100644 --- a/descriptor_test.go +++ b/descriptor_test.go @@ -288,13 +288,13 @@ func TestBodyDescriptorValidate(t *testing.T) { t.Run(name, func(t *testing.T) { m := good mutate(&m) - require.ErrorIs(t, m.Validate(), fee.ErrIncompleteDescriptor) + 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.ErrIncompleteDescriptor) + require.ErrorIs(t, err, fee.ErrInvalidDescriptor) }) } } diff --git a/range.go b/range.go index 8ea3b57..447570d 100644 --- a/range.go +++ b/range.go @@ -174,7 +174,7 @@ func DecryptRange(blob io.ReaderAt, blobSize int64, unwrap RecipientUnwrapper, o // 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 [ErrIncompleteDescriptor]; a well-formed but stale or wrong one +// 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