Skip to content

Commit c6364c9

Browse files
committed
fix: accept a trailing empty final chunk in the count check
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š <oss@bajtos.net>
1 parent 6c417ae commit c6364c9

11 files changed

Lines changed: 257 additions & 13 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,13 @@ recipient = [ {1: alg}, {4: kid, ...}, wrappedKey ] # alg -31 or -5
370370
- **Body AAD** — the COSE `Enc_structure` over the protected header, identical
371371
for every chunk, so the algorithm, envelope type and any protected metadata
372372
are authenticated into the ciphertext.
373+
- **Chunking** — a producer writes `ceil(len / chunkSize)` chunks, minimum 1,
374+
with the remainder in the final chunk; empty input is one empty chunk. A
375+
decoder also accepts a stream that ends with an empty final chunk, so a
376+
plaintext of exactly `k × chunkSize` bytes is a valid stream of either `k`
377+
chunks or `k+1`. Only the ciphertext length distinguishes them
378+
(`aesstream.ChunkCount`), which is why a declared count must never be checked
379+
against a count re-derived from the plaintext length.
373380
- **Chunk count** (label −65791) — advisory metadata for range/seek consumers,
374381
emitted only when the plaintext length is known; not required to decrypt.
375382

aesstream/spanreader.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,29 @@ func DecryptedSize(ciphertextLen int64, chunkSize int) (int64, error) {
102102
return plaintextLen, err
103103
}
104104

105+
// ChunkCount returns how many chunks a complete ciphertext of ciphertextLen
106+
// bytes contains at the given chunk size. A zero chunkSize selects
107+
// DefaultChunkSize; any other value must be in [MinChunkSize, MaxChunkSize]
108+
// (else ErrChunkSize), the same rule as Config.ChunkSize. It returns
109+
// ErrCiphertextSize if ciphertextLen is not a structurally valid stream length.
110+
//
111+
// The count is not derivable from the plaintext length, which is why this is
112+
// worth asking for: the final chunk may be full, partial, or empty, so a
113+
// plaintext of exactly k*chunkSize bytes is a valid stream of either k chunks
114+
// (the last one full) or k+1 (the last one empty). Both are read the same way
115+
// and yield the same plaintext, and only the ciphertext length tells them apart.
116+
// A caller checking a stream against a separately recorded chunk count should
117+
// compare against this rather than against ceil(plaintextLen/chunkSize), which
118+
// only describes the first form.
119+
func ChunkCount(ciphertextLen int64, chunkSize int) (int64, error) {
120+
chunkSize, err := resolveChunkSize(chunkSize)
121+
if err != nil {
122+
return 0, err
123+
}
124+
numChunks, _, _, err := chunkLayout(ciphertextLen, chunkSize)
125+
return numChunks, err
126+
}
127+
105128
// CiphertextRange returns the single contiguous ciphertext byte range
106129
// [start, start+n) that must be read to serve the plaintext range
107130
// [off, off+length) of a stream whose complete ciphertext is

aesstream/spanreader_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,3 +497,51 @@ func TestDecryptedSize_InvertsEncryptedSize(t *testing.T) {
497497
require.Equalf(t, n, got, "DecryptedSize(EncryptedSize(%d))", n)
498498
}
499499
}
500+
501+
// TestChunkCount pins the exported chunk count over the boundary ciphertext
502+
// lengths, including the two encodings of an exact-multiple plaintext: the same
503+
// plaintext length is one chunk more when the stream ends with an empty final
504+
// chunk, which is why callers must not re-derive the count from a plaintext size.
505+
func TestChunkCount(t *testing.T) {
506+
const cs = aesstream.MinChunkSize
507+
enc := int64(cs) + aesstream.TagSize
508+
509+
cases := map[string]struct {
510+
ciphertextLen int64
511+
want int64
512+
}{
513+
"empty stream (one tag-only chunk)": {aesstream.TagSize, 1},
514+
"single partial chunk": {aesstream.TagSize + 100, 1},
515+
"single full chunk": {enc, 1},
516+
"full + empty final": {enc + aesstream.TagSize, 2},
517+
"full + partial final": {enc + aesstream.TagSize + 7, 2},
518+
"two full chunks": {2 * enc, 2},
519+
}
520+
for name, c := range cases {
521+
t.Run(name, func(t *testing.T) {
522+
got, err := aesstream.ChunkCount(c.ciphertextLen, cs)
523+
require.NoError(t, err)
524+
require.Equal(t, c.want, got)
525+
})
526+
}
527+
}
528+
529+
// TestChunkCountInvalid confirms ChunkCount reports the same errors as the rest
530+
// of the geometry API for a length or chunk size that cannot describe a stream.
531+
func TestChunkCountInvalid(t *testing.T) {
532+
t.Run("ciphertext too short to hold a tag", func(t *testing.T) {
533+
_, err := aesstream.ChunkCount(aesstream.TagSize-1, aesstream.MinChunkSize)
534+
require.ErrorIs(t, err, aesstream.ErrCiphertextSize)
535+
})
536+
537+
t.Run("chunk size out of range", func(t *testing.T) {
538+
_, err := aesstream.ChunkCount(aesstream.TagSize, aesstream.MinChunkSize-1)
539+
require.ErrorIs(t, err, aesstream.ErrChunkSize)
540+
})
541+
542+
t.Run("zero chunk size selects the default", func(t *testing.T) {
543+
got, err := aesstream.ChunkCount(aesstream.EncryptedSize(3*aesstream.DefaultChunkSize, 0), 0)
544+
require.NoError(t, err)
545+
require.Equal(t, int64(3), got)
546+
})
547+
}

fee.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,12 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts
410410

411411
// chunkCountFor reports how many STREAM chunks a plaintext of nPlain bytes
412412
// produces at the given chunk size. Empty input is one (empty) final chunk.
413+
//
414+
// This is the producer's rule, matching the reference implementation, and it is
415+
// only for writing the chunk-count header. It is not a decoder's rule: a stream
416+
// whose plaintext is an exact multiple of the chunk size may legitimately carry
417+
// one more chunk than this (an empty final chunk), so a count read off the wire
418+
// is checked against [aesstream.ChunkCount] instead.
413419
func chunkCountFor(nPlain, chunkSize int64) int64 {
414420
if nPlain <= 0 {
415421
return 1

range.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"github.com/filecoin-project/go-fee/cose"
1010
)
1111

12-
// ErrSizeMismatch means the ciphertext length implied by the supplied blob size
12+
// ErrSizeMismatch means the number of chunks the supplied blob size accounts for
1313
// disagrees with the chunk count the envelope declares: the blob is truncated or
1414
// padded, or the size came from the wrong object.
1515
//
@@ -121,8 +121,11 @@ func (r *RangeReader) CiphertextSpan() (off, n int64) { return r.spanOff, r.span
121121
// are never read and so never checked, and — unlike whole-object [Decrypt] — a
122122
// range read cannot by itself detect that the stored object was truncated: an
123123
// understated blobSize describes a shorter object whose ranges decrypt cleanly.
124-
// The chunk-count check catches that when the envelope carries a chunk count, but
125-
// whole-object integrity is properly the job of the layer that supplied blobSize.
124+
// The chunk-count check catches an understatement of a whole chunk or more when
125+
// the envelope carries a count, but a size short by only part of the final chunk
126+
// accounts for the same number of chunks and so passes it, surfacing instead as
127+
// an authentication failure when that chunk is read. Whole-object integrity is
128+
// properly the job of the layer that supplied blobSize.
126129
func DecryptRange(blob io.ReaderAt, blobSize int64, unwrap RecipientUnwrapper, off, length int64) (*RangeReader, error) {
127130
if unwrap == nil {
128131
return nil, ErrNilUnwrapper
@@ -282,9 +285,16 @@ func spanRangeReader(blob io.ReaderAt, blobSize, headerLen int64, body bodyParam
282285

283286
// envelopePlaintextSize is [plaintextSizeFrom] for a blob whose parameters came
284287
// from its envelope rather than from a caller's cache: it adds the cross-check of
285-
// the envelope's declared chunk count against the derived size, when one is
286-
// present, catching a blob size that describes a different object than the
287-
// envelope does.
288+
// the envelope's declared chunk count against the chunks the ciphertext actually
289+
// holds, when a count is present, catching a blob size that describes a different
290+
// object than the envelope does.
291+
//
292+
// The comparison is against [aesstream.ChunkCount] rather than a count re-derived
293+
// from the plaintext size, because a plaintext that is an exact multiple of the
294+
// chunk size seals to either k chunks or k+1 (with an empty final chunk) and both
295+
// decrypt identically. Deriving from the plaintext size would recognize only the
296+
// first form and refuse the second, which the whole-object [Decrypt] path reads
297+
// without complaint.
288298
func envelopePlaintextSize(env *cose.Envelope, blobSize, headerLen int64, chunkSize int) (int64, error) {
289299
plainSize, err := plaintextSizeFrom(blobSize, headerLen, chunkSize)
290300
if err != nil {
@@ -297,8 +307,12 @@ func envelopePlaintextSize(env *cose.Envelope, blobSize, headerLen int64, chunkS
297307
if !ok {
298308
return 0, fmt.Errorf("%w: chunk-count header is present but not an integer", ErrMalformedEnvelope)
299309
}
300-
if want := chunkCountFor(plainSize, int64(chunkSize)); declared != want {
301-
return 0, fmt.Errorf("%w: envelope declares %d chunks, the blob size implies %d",
310+
want, err := aesstream.ChunkCount(blobSize-headerLen, chunkSize)
311+
if err != nil {
312+
return 0, fmt.Errorf("fee: blob of %d ciphertext bytes: %w", blobSize-headerLen, err)
313+
}
314+
if declared != want {
315+
return 0, fmt.Errorf("%w: envelope declares %d chunks, the ciphertext holds %d",
302316
ErrSizeMismatch, declared, want)
303317
}
304318
return plainSize, nil

range_test.go

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package fee_test
22

33
import (
44
"bytes"
5+
"crypto/aes"
6+
"crypto/cipher"
57
"crypto/ecdh"
68
"crypto/rand"
9+
"encoding/binary"
710
"encoding/hex"
811
"errors"
912
"io"
@@ -88,6 +91,61 @@ func encryptWithCEK(t *testing.T, plaintext, cek []byte, recipients []fee.Recipi
8891
return io.ReadAll(r)
8992
}
9093

94+
// sealTrailingEmptyFinalChunk builds a FEE blob whose ciphertext is k full
95+
// chunks followed by an empty final chunk, declared as k+1 — the second valid
96+
// encoding of a plaintext that is an exact multiple of the chunk size.
97+
//
98+
// aesstream.Writer never emits this form (it flushes a full buffer as the final
99+
// chunk, giving k), so the body is sealed chunk by chunk here, under the
100+
// documented per-chunk nonce and the envelope's own Enc_structure as AAD.
101+
func sealTrailingEmptyFinalChunk(t *testing.T, plaintext, cek, baseNonce []byte, chunkSize int) []byte {
102+
t.Helper()
103+
require.Zero(t, len(plaintext)%chunkSize, "plaintext must be an exact multiple of the chunk size")
104+
chunks := len(plaintext)/chunkSize + 1 // the trailing empty chunk is the extra one
105+
106+
env := &cose.Envelope{Headers: cose.Headers{
107+
Protected: cose.Header{}.
108+
Set(cose.HeaderLabelAlg, algChunkedStream).
109+
Set(cose.HeaderLabelType, fee.EnvelopeType),
110+
Unprotected: cose.Header{}.
111+
Set(cose.HeaderLabelIV, baseNonce).
112+
Set(labelChunkSize, int64(chunkSize)).
113+
Set(labelChunkCount, int64(chunks)),
114+
}}
115+
aad, err := env.EncStructure(nil)
116+
require.NoError(t, err)
117+
header, err := env.Encode()
118+
require.NoError(t, err)
119+
120+
block, err := aes.NewCipher(cek)
121+
require.NoError(t, err)
122+
aead, err := cipher.NewGCM(block)
123+
require.NoError(t, err)
124+
125+
blob := bytes.Clone(header)
126+
for i := range chunks {
127+
last := i == chunks-1
128+
var chunk []byte
129+
if !last {
130+
chunk = plaintext[i*chunkSize : (i+1)*chunkSize]
131+
}
132+
blob = aead.Seal(blob, streamNonce(baseNonce, i, last), chunk, aad)
133+
}
134+
return blob
135+
}
136+
137+
// streamNonce builds the per-chunk GCM nonce of the FEE body cipher:
138+
// baseNonce(7) || chunkIndex(4, big-endian) || lastFlag(1).
139+
func streamNonce(baseNonce []byte, index int, last bool) []byte {
140+
nonce := make([]byte, 0, aesstream.NonceSize)
141+
nonce = append(nonce, baseNonce...)
142+
nonce = binary.BigEndian.AppendUint32(nonce, uint32(index))
143+
if last {
144+
return append(nonce, 0x01)
145+
}
146+
return append(nonce, 0x00)
147+
}
148+
91149
// hexBytes decodes a hex literal, for the handful of tests that pin behaviour
92150
// against exact wire bytes rather than an encrypted fixture.
93151
func hexBytes(t *testing.T, s string) []byte {
@@ -126,10 +184,24 @@ func decryptRange(t *testing.T, blob []byte, u fee.RecipientUnwrapper, off, leng
126184
// envelope, unwrap material and a byte range returns exactly the requested
127185
// plaintext — across the interesting geometries of a multi-chunk object: whole
128186
// object, within one chunk, spanning a boundary, exactly one aligned chunk, into
129-
// the short final chunk, and single bytes at each end.
187+
// the final chunk, and single bytes at each end. It runs the whole set against
188+
// both object shapes, since the final chunk being full rather than short is the
189+
// corner the declared chunk count is ambiguous at.
130190
func TestDecryptRangeRoundTrip(t *testing.T) {
131-
const size = 3*rangeChunk + rangeChunk/2 // 3.5 chunks
132-
f := newRangeFixture(t, size)
191+
t.Run("partial final chunk", func(t *testing.T) {
192+
testRangeRoundTrip(t, 3*rangeChunk+rangeChunk/2)
193+
})
194+
195+
// An exact multiple of the chunk size: the final chunk is full rather than
196+
// short, the geometry corner the wire format's chunk count is ambiguous at.
197+
t.Run("exact multiple of the chunk size", func(t *testing.T) {
198+
testRangeRoundTrip(t, 4*rangeChunk)
199+
})
200+
}
201+
202+
func testRangeRoundTrip(t *testing.T, size int64) {
203+
t.Helper()
204+
f := newRangeFixture(t, int(size), fee.WithContentLength(size))
133205

134206
cases := []struct {
135207
name string
@@ -144,7 +216,7 @@ func TestDecryptRangeRoundTrip(t *testing.T) {
144216
{"across two boundaries", rangeChunk - 10, 2*rangeChunk + 20},
145217
{"exactly one aligned chunk", rangeChunk, rangeChunk},
146218
{"aligned start, unaligned end", 2 * rangeChunk, rangeChunk + 5},
147-
{"whole short final chunk", 3 * rangeChunk, rangeChunk / 2},
219+
{"from the last chunk's start", 3 * rangeChunk, rangeChunk / 2},
148220
{"into final chunk", 3*rangeChunk - 5, 100},
149221
{"ends exactly on boundary", rangeChunk / 2, rangeChunk / 2},
150222
{"length past end clamps", size - 10, 1000},
@@ -156,7 +228,7 @@ func TestDecryptRangeRoundTrip(t *testing.T) {
156228

157229
wantLen := clampLen(size, tc.off, tc.length)
158230
require.Equal(t, wantLen, r.Len(), "Len is the clamped range length")
159-
require.Equal(t, int64(size), r.Size(), "Size is the whole object")
231+
require.Equal(t, size, r.Size(), "Size is the whole object")
160232
require.Equal(t, f.plaintext[tc.off:tc.off+wantLen], got)
161233
})
162234
}
@@ -603,6 +675,50 @@ func TestDecryptRangeChunkCountMismatch(t *testing.T) {
603675
})
604676
}
605677

678+
// TestDecryptRangeTrailingEmptyFinalChunk pins that the range path accepts the
679+
// second valid encoding of an exact-multiple plaintext: k full chunks followed by
680+
// an empty final chunk, declared as k+1. aesstream reads that layout and so does
681+
// whole-object decryption, so the range entry points must agree rather than
682+
// refusing a blob the rest of the package accepts.
683+
func TestDecryptRangeTrailingEmptyFinalChunk(t *testing.T) {
684+
const size = 3 * rangeChunk
685+
cek, plaintext := newCEK(t), patternBytes(size)
686+
baseNonce := bytes.Repeat([]byte{0xa5}, aesstream.BaseNonceSize)
687+
blob := sealTrailingEmptyFinalChunk(t, plaintext, cek, baseNonce, rangeChunk)
688+
blobSize := int64(len(blob))
689+
690+
// The premise: whole-object decryption already reads this blob.
691+
whole, err := fee.DecryptWithCEK(bytes.NewReader(blob), cek)
692+
require.NoError(t, err)
693+
got, err := io.ReadAll(whole)
694+
require.NoError(t, err)
695+
require.Equal(t, plaintext, got)
696+
697+
t.Run("PlaintextSize agrees", func(t *testing.T) {
698+
n, err := fee.PlaintextSize(bytes.NewReader(blob), blobSize)
699+
require.NoError(t, err)
700+
require.Equal(t, int64(size), n)
701+
})
702+
703+
t.Run("range across the last full chunk", func(t *testing.T) {
704+
off, length := int64(2*rangeChunk-10), int64(20)
705+
r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), blobSize, cek, off, length)
706+
require.NoError(t, err)
707+
require.Equal(t, int64(size), r.Size())
708+
got, err := io.ReadAll(r)
709+
require.NoError(t, err)
710+
require.Equal(t, plaintext[off:off+length], got)
711+
})
712+
713+
t.Run("whole object as one range", func(t *testing.T) {
714+
r, err := fee.DecryptRangeWithCEK(bytes.NewReader(blob), blobSize, cek, 0, size)
715+
require.NoError(t, err)
716+
got, err := io.ReadAll(r)
717+
require.NoError(t, err)
718+
require.Equal(t, plaintext, got)
719+
})
720+
}
721+
606722
// TestDecryptRangeWrongBlobSizeNoChunkCount pins the documented trust model for an
607723
// envelope with no chunk count: a wrong blob size cannot be caught at construction,
608724
// so it surfaces as a failure to authenticate when the affected chunks are read.

vectors/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ to it and this repo matches it (see [Wire format](#wire-format)).
2424
| `multi-chunk-ts` | TS seals → Go decrypts | tag 16 (COSE_Encrypt0) |
2525
| `multi-recipient-go` | Go seals → TS parses recipients + decrypts body | tag 96 (COSE_Encrypt) |
2626
| `multi-chunk-go` | Go seals → TS decrypts (extra multi-chunk coverage) | tag 16 |
27+
| `exact-multiple-go` | Go seals → TS decrypts (plaintext is exactly 3 chunks) | tag 16 |
2728

2829
Each `testdata/<name>/` holds `blob.bin` (`envelope‖ciphertext`),
2930
`plaintext.bin`, and `meta.json`.
@@ -54,6 +55,12 @@ recipient = [ {1: alg}, {4: kid, ...}, wrappedKey ] # alg -31 or -5
5455
- **Body cipher** — chunked AES-256-GCM-STREAM, alg `-65793`. Per-chunk nonce is
5556
`baseNonce[7] ‖ chunkIndex[4, big-endian] ‖ lastFlag[1]` (`0x01` on the final
5657
chunk), tag 16 bytes.
58+
- **Chunking** — a producer writes `ceil(len / chunkSize)` chunks, minimum 1,
59+
with the remainder in the final chunk; empty input is one empty chunk. Both
60+
implementations follow that rule, so `exact-multiple-go` declares 3 chunks
61+
rather than 3 full chunks plus an empty one. A *decoder* also accepts a
62+
trailing empty final chunk, so the declared count is authoritative and must
63+
not be re-derived from the plaintext length.
5764
- **Body AAD**`Enc_structure = [ context, protected, "" ]`, the **same** for
5865
every chunk. `context` follows the envelope structure per RFC 9052 §5.3:
5966
`"Encrypt"` for a tag-96 envelope, `"Encrypt0"` for tag-16. AAD interop is
12.1 KB
Binary file not shown.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "exact-multiple-go",
3+
"producer": "go",
4+
"description": "Plaintext of exactly 3 chunks (full final chunk) encrypted in Go; decrypts in foc-encryption (TS).",
5+
"tag": 16,
6+
"algorithm": -65793,
7+
"typ": "application/vnd.foc-envelope+cose",
8+
"chunk_size": 4096,
9+
"chunk_count": 3,
10+
"cek_hex": "1b73245d0995604fb9b7040935a46bc6db4e2b6ec9f907b8a528810925166867",
11+
"base_nonce_hex": "baf5c82a5faa4c"
12+
}

0 commit comments

Comments
 (0)