Skip to content

Commit 5435a62

Browse files
committed
fee: adapt composer + DecodeReader to unified cose.Envelope (FIL-569)
FIL-473 collapsed cose.Encrypt/Encrypt0 into a single cose.Envelope (tag and Enc_structure context computed from recipient presence) and replaced the two byte decoders with one tag-dispatching cose.Decode. Adapt this PR's additions to match: - cose.DecodeReader now returns *cose.Envelope, sharing the decodeEnvelope validation core with cose.Decode; the redundant DecodedEnvelope type is gone. - encryptStream builds one cose.Envelope and uses it for both the AAD (EncStructure) and the encoded header (Encode), instead of a DecodedEnvelope for the AAD plus a separate Encrypt/Encrypt0 for the header. The envelopeTag helper is dropped — recipient presence is the form. - Decrypt discriminates the recipient-less envelope with len(Recipients)==0 rather than a Tag comparison; openStream takes *cose.Envelope. - Tests updated: DecodeReader equivalence now checks against cose.Decode, and fee tests build cose.Envelope / call cose.Decode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
1 parent 70ed066 commit 5435a62

4 files changed

Lines changed: 64 additions & 144 deletions

File tree

fee/cose/decode.go

Lines changed: 15 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ type decodeConfig struct {
1414
expectedType string
1515
}
1616

17-
// DecodeOption configures [Decode].
17+
// DecodeOption configures [Decode] and [DecodeReader].
1818
type DecodeOption func(*decodeConfig)
1919

2020
// WithExpectedType requires the decoded protected header to carry a "typ"
@@ -149,52 +149,19 @@ func PeekTag(data []byte) (uint64, error) {
149149
return tag.Number, nil
150150
}
151151

152-
// DecodedEnvelope is a decoded detached COSE envelope header returned by
153-
// [DecodeReader]: either a COSE_Encrypt (tag 96, carrying Recipients) or a
154-
// COSE_Encrypt0 (tag 16, no recipients), distinguished by Tag.
155-
type DecodedEnvelope struct {
156-
// Tag is [TagCOSEEncrypt] (96) or [TagCOSEEncrypt0] (16).
157-
Tag uint64
158-
// Headers is the body protected/unprotected header pair.
159-
Headers Headers
160-
// Recipients are the per-recipient wrapped-key entries; nil for a
161-
// COSE_Encrypt0 (tag 16).
162-
Recipients []*Recipient
163-
}
164-
165-
// EncStructure returns the body AAD Enc_structure for this envelope, using the
166-
// context that matches its tag: "Encrypt" for a COSE_Encrypt (tag 96),
167-
// "Encrypt0" for a COSE_Encrypt0 (tag 16). See [Encrypt.EncStructure].
168-
func (e *DecodedEnvelope) EncStructure(externalAAD []byte) ([]byte, error) {
169-
ctx := contextEncrypt
170-
if e.Tag == TagCOSEEncrypt0 {
171-
ctx = contextEncrypt0
172-
}
173-
prot, err := e.Headers.protectedBytes()
174-
if err != nil {
175-
return nil, fmt.Errorf("cose: building Enc_structure: %w", err)
176-
}
177-
return encStructureBytes(ctx, prot, externalAAD)
178-
}
179-
180152
// DecodeReader reads one detached COSE_Encrypt (tag 96) or COSE_Encrypt0 (tag
181-
// 16) from the front of r and returns the decoded envelope header together with
182-
// rest: a reader over the bytes that follow the self-delimited envelope item —
183-
// the detached ciphertext. rest draws first from whatever the decoder buffered
184-
// past the envelope, then from r, so only the (small) header is held in memory
185-
// and an arbitrarily large ciphertext can be streamed.
153+
// 16) from the front of r and returns the decoded [Envelope] together with rest:
154+
// a reader over the bytes that follow the self-delimited envelope item — the
155+
// detached ciphertext. rest draws first from whatever the decoder buffered past
156+
// the envelope, then from r, so only the (small) header is held in memory and an
157+
// arbitrarily large ciphertext can be streamed.
186158
//
187-
// It is the streaming, tag-dispatching counterpart to [DecodeEncrypt] and
188-
// [DecodeEncrypt0], and is as strict as they are: a byte-string protected
189-
// header, map headers without duplicate labels, a null detached body, and — for
190-
// tag 96 — at least one well-formed 3-element recipient. Any deviation returns
191-
// an error (wrapping a package sentinel) and a nil envelope.
192-
func DecodeReader(r io.Reader, opts ...DecodeOption) (env *DecodedEnvelope, rest io.Reader, err error) {
193-
var cfg decodeConfig
194-
for _, o := range opts {
195-
o(&cfg)
196-
}
197-
159+
// It is the streaming counterpart to [Decode], dispatching on the same tags and
160+
// as strict — both share the decodeEnvelope validation core: a byte-string
161+
// protected header, map headers without duplicate labels, a null detached body,
162+
// and, for tag 96, at least one well-formed 3-element recipient. Any deviation
163+
// returns an error (wrapping a package sentinel) and a nil envelope.
164+
func DecodeReader(r io.Reader, opts ...DecodeOption) (env *Envelope, rest io.Reader, err error) {
198165
// Read exactly one CBOR item. Whatever the decoder buffered past that item,
199166
// followed by the unread remainder of r, is the detached payload.
200167
dec := decMode.NewDecoder(r)
@@ -208,10 +175,6 @@ func DecodeReader(r io.Reader, opts ...DecodeOption) (env *DecodedEnvelope, rest
208175
if err := decMode.Unmarshal(first, &tag); err != nil {
209176
return nil, nil, fmt.Errorf("%w: %v", ErrNotEncrypt, err)
210177
}
211-
if tag.Number != TagCOSEEncrypt && tag.Number != TagCOSEEncrypt0 {
212-
return nil, nil, fmt.Errorf("%w: got tag %d", ErrNotEncrypt, tag.Number)
213-
}
214-
215178
if cborMajor(tag.Content) != majorArray {
216179
return nil, nil, fmt.Errorf("%w: tag content is not an array", ErrMalformed)
217180
}
@@ -220,42 +183,13 @@ func DecodeReader(r io.Reader, opts ...DecodeOption) (env *DecodedEnvelope, rest
220183
return nil, nil, fmt.Errorf("%w: %v", ErrMalformed, err)
221184
}
222185

223-
// A COSE_Encrypt is a 4-element array (with recipients); a COSE_Encrypt0 is
224-
// a 3-element array (no recipients).
225-
wantLen := 3
226-
if tag.Number == TagCOSEEncrypt {
227-
wantLen = 4
228-
}
229-
if len(arr) != wantLen {
230-
return nil, nil, fmt.Errorf("%w: array has %d elements, want %d", ErrMalformed, len(arr), wantLen)
231-
}
232-
233-
headers, err := decodeHeaders(arr[0], arr[1])
186+
env, err = decodeEnvelope(tag.Number, arr)
234187
if err != nil {
235188
return nil, nil, err
236189
}
237-
238-
// Detached payload: the body ciphertext must be null.
239-
if !isNull(arr[2]) {
240-
return nil, nil, ErrDetachedPayload
241-
}
242-
243-
env = &DecodedEnvelope{Tag: tag.Number, Headers: headers}
244-
if tag.Number == TagCOSEEncrypt {
245-
recipients, err := decodeRecipients(arr[3])
246-
if err != nil {
247-
return nil, nil, err
248-
}
249-
env.Recipients = recipients
250-
}
251-
252-
if cfg.checkType {
253-
got, ok := env.Headers.Protected.Text(HeaderLabelType)
254-
if !ok || got != cfg.expectedType {
255-
return nil, nil, fmt.Errorf("%w: got %q, want %q", ErrUnexpectedType, got, cfg.expectedType)
256-
}
190+
if err := newDecodeConfig(opts).checkTyp(env.Headers.Protected); err != nil {
191+
return nil, nil, err
257192
}
258-
259193
return env, rest, nil
260194
}
261195

fee/cose/decode_reader_test.go

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@ import (
99
"github.com/stretchr/testify/require"
1010
)
1111

12-
// TestDecodeReader exercises the streaming, tag-dispatching decoder: it decodes
13-
// both envelope shapes off a reader, streams back the detached ciphertext that
14-
// follows the header, and — critically — produces the same Enc_structure (AAD)
15-
// and the same trailing bytes as the byte-based Decode / DecodeEncrypt0, so a
16-
// caller can decrypt an envelope read either way.
12+
// TestDecodeReader exercises the streaming decoder: it decodes both envelope
13+
// forms off a reader, streams back the detached ciphertext that follows the
14+
// header, and — critically — produces the same Enc_structure (AAD) and the same
15+
// trailing bytes as the byte-based Decode, so a caller can decrypt an envelope
16+
// read either way.
1717
func TestDecodeReader(t *testing.T) {
1818
ciphertext := []byte("detached-stream-ciphertext-bytes-0123456789")
1919

20-
t.Run("tag 96 COSE_Encrypt", func(t *testing.T) {
21-
enc := &Encrypt{
20+
t.Run("with recipients (COSE_Encrypt, tag 96)", func(t *testing.T) {
21+
enc := &Envelope{
2222
Headers: Headers{
2323
Protected: Header{}.
2424
Set(HeaderLabelType, exampleType).
@@ -39,15 +39,14 @@ func TestDecodeReader(t *testing.T) {
3939

4040
env, rest, err := DecodeReader(bytes.NewReader(blob))
4141
require.NoError(t, err)
42-
require.Equal(t, TagCOSEEncrypt, env.Tag)
43-
require.Len(t, env.Recipients, 1)
42+
require.Len(t, env.Recipients, 1, "recipient presence marks the tag-96 form")
4443
gotRest, err := io.ReadAll(rest)
4544
require.NoError(t, err)
4645
require.Equal(t, ciphertext, gotRest)
4746

48-
// The streaming decode agrees with the byte-based DecodeEncrypt on both
49-
// the trailing ciphertext and the AAD (context "Encrypt").
50-
byteEnv, byteRest, err := DecodeEncrypt(blob)
47+
// The streaming decode agrees with the byte-based Decode on both the
48+
// trailing ciphertext and the AAD (context "Encrypt").
49+
byteEnv, byteRest, err := Decode(blob)
5150
require.NoError(t, err)
5251
require.Equal(t, ciphertext, byteRest)
5352
want, err := byteEnv.EncStructure(nil)
@@ -57,8 +56,8 @@ func TestDecodeReader(t *testing.T) {
5756
require.Equal(t, want, got)
5857
})
5958

60-
t.Run("tag 16 COSE_Encrypt0", func(t *testing.T) {
61-
enc0 := &Encrypt0{
59+
t.Run("recipient-less (COSE_Encrypt0, tag 16)", func(t *testing.T) {
60+
enc0 := &Envelope{
6261
Headers: Headers{
6362
Protected: Header{}.
6463
Set(HeaderLabelType, exampleType).
@@ -72,14 +71,13 @@ func TestDecodeReader(t *testing.T) {
7271

7372
env, rest, err := DecodeReader(bytes.NewReader(blob))
7473
require.NoError(t, err)
75-
require.Equal(t, TagCOSEEncrypt0, env.Tag)
76-
require.Nil(t, env.Recipients)
74+
require.Empty(t, env.Recipients, "recipient absence marks the tag-16 form")
7775
gotRest, err := io.ReadAll(rest)
7876
require.NoError(t, err)
7977
require.Equal(t, ciphertext, gotRest)
8078

81-
// Agrees with the byte-based DecodeEncrypt0 (context "Encrypt0").
82-
byteEnv, byteRest, err := DecodeEncrypt0(blob)
79+
// Agrees with the byte-based Decode (context "Encrypt0").
80+
byteEnv, byteRest, err := Decode(blob)
8381
require.NoError(t, err)
8482
require.Equal(t, ciphertext, byteRest)
8583
want, err := byteEnv.EncStructure(nil)
@@ -90,7 +88,7 @@ func TestDecodeReader(t *testing.T) {
9088
})
9189

9290
t.Run("ciphertext split across the decoder buffer and the reader", func(t *testing.T) {
93-
enc0 := &Encrypt0{
91+
enc0 := &Envelope{
9492
Headers: Headers{Protected: Header{}.Set(HeaderLabelType, exampleType)},
9593
}
9694
header, err := enc0.Encode()
@@ -101,14 +99,14 @@ func TestDecodeReader(t *testing.T) {
10199
// and the ciphertext to straddle the decoder's read-ahead and the source.
102100
env, rest, err := DecodeReader(iotest.OneByteReader(bytes.NewReader(blob)))
103101
require.NoError(t, err)
104-
require.Equal(t, TagCOSEEncrypt0, env.Tag)
102+
require.Empty(t, env.Recipients)
105103
gotRest, err := io.ReadAll(rest)
106104
require.NoError(t, err)
107105
require.Equal(t, ciphertext, gotRest)
108106
})
109107

110108
t.Run("expected type mismatch", func(t *testing.T) {
111-
enc0 := &Encrypt0{
109+
enc0 := &Envelope{
112110
Headers: Headers{Protected: Header{}.Set(HeaderLabelType, "application/other")},
113111
}
114112
header, err := enc0.Encode()

fee/fee.go

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -279,33 +279,30 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts
279279
Unprotected: unprotected,
280280
}
281281

282-
// The AAD binds the protected header into every STREAM chunk via the
283-
// Enc_structure whose context matches the envelope tag ("Encrypt0" for a
284-
// recipient-less envelope, "Encrypt" otherwise). It depends only on the
285-
// (now-fixed) protected header, so it can be derived before the CEK is wrapped.
286-
aad, err := (&cose.DecodedEnvelope{Tag: envelopeTag(len(recipients)), Headers: headers}).EncStructure(nil)
287-
if err != nil {
288-
return nil, fmt.Errorf("fee: building envelope AAD: %w", err)
282+
// Wrap the CEK to each recipient (none → a recipient-less envelope). All the
283+
// fallible header work happens here, before the pipe is created, so an error
284+
// returns before the pipe exists and can never orphan a PipeReader/PipeWriter
285+
// pair.
286+
entries := make([]*cose.Recipient, len(recipients))
287+
for i, r := range recipients {
288+
entry, werr := r.wrap(cek)
289+
if werr != nil {
290+
return nil, werr
291+
}
292+
entries[i] = entry
289293
}
290294

291-
// Encode the envelope header before creating the pipe: a COSE_Encrypt0 (no
292-
// recipients) or a COSE_Encrypt whose recipients each wrap the CEK. All the
293-
// fallible header work happens up front, so an error here returns before the
294-
// pipe exists and can never orphan a PipeReader/PipeWriter pair.
295-
var header []byte
296-
if len(recipients) == 0 {
297-
header, err = (&cose.Encrypt0{Headers: headers}).Encode()
298-
} else {
299-
entries := make([]*cose.Recipient, len(recipients))
300-
for i, r := range recipients {
301-
entry, werr := r.wrap(cek)
302-
if werr != nil {
303-
return nil, werr
304-
}
305-
entries[i] = entry
306-
}
307-
header, err = (&cose.Encrypt{Headers: headers, Recipients: entries}).Encode()
295+
// One envelope drives both the AAD and the encoded header. Recipient presence
296+
// alone selects the form — a COSE_Encrypt (tag 96) when present, a
297+
// recipient-less COSE_Encrypt0 (tag 16) when not — and the Enc_structure
298+
// context tracks it. The AAD binds the protected header into every STREAM
299+
// chunk.
300+
env := &cose.Envelope{Headers: headers, Recipients: entries}
301+
aad, err := env.EncStructure(nil)
302+
if err != nil {
303+
return nil, fmt.Errorf("fee: building envelope AAD: %w", err)
308304
}
305+
header, err := env.Encode()
309306
if err != nil {
310307
return nil, fmt.Errorf("fee: encoding envelope: %w", err)
311308
}
@@ -347,15 +344,6 @@ func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts
347344
}, nil
348345
}
349346

350-
// envelopeTag reports the COSE tag for an envelope with the given recipient
351-
// count: tag 16 (COSE_Encrypt0) for none, tag 96 (COSE_Encrypt) otherwise.
352-
func envelopeTag(nRecipients int) uint64 {
353-
if nRecipients == 0 {
354-
return cose.TagCOSEEncrypt0
355-
}
356-
return cose.TagCOSEEncrypt
357-
}
358-
359347
// chunkCountFor reports how many STREAM chunks a plaintext of nPlain bytes
360348
// produces at the given chunk size. Empty input is one (empty) final chunk.
361349
func chunkCountFor(nPlain, chunkSize int64) int64 {
@@ -406,7 +394,7 @@ func Decrypt(src io.Reader, unwrap RecipientUnwrapper) (io.Reader, error) {
406394
if err != nil {
407395
return nil, fmt.Errorf("fee: decoding envelope: %w", err)
408396
}
409-
if env.Tag != cose.TagCOSEEncrypt {
397+
if len(env.Recipients) == 0 {
410398
return nil, ErrNoRecipientsInEnvelope
411399
}
412400

@@ -451,7 +439,7 @@ func DecryptWithCEK(src io.Reader, cek []byte) (io.Reader, error) {
451439
// envelope, its detached ciphertext stream, and the content-encryption key, it
452440
// validates the body parameters and returns the streaming plaintext reader. It
453441
// copies cek into the body cipher and does not retain it.
454-
func openStream(env *cose.DecodedEnvelope, ciphertext io.Reader, cek []byte) (io.Reader, error) {
442+
func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader, error) {
455443
alg, ok := env.Headers.Protected.Int(cose.HeaderLabelAlg)
456444
if !ok {
457445
return nil, fmt.Errorf("%w: body algorithm header missing or not an integer", ErrUnsupportedBodyAlg)

fee/fee_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ func TestDecryptCorruptedProtectedHeader(t *testing.T) {
436436

437437
// Locate the protected-header bytes via a clean decode, then flip the first
438438
// byte (the CBOR map head) so the protected bytes are no longer a map.
439-
env, _, err := cose.DecodeEncrypt(blob, cose.WithExpectedType(fee.EnvelopeType))
439+
env, _, err := cose.Decode(blob, cose.WithExpectedType(fee.EnvelopeType))
440440
require.NoError(t, err)
441441
require.NotEmpty(t, env.Headers.RawProtected)
442442
off := bytes.Index(blob, env.Headers.RawProtected)
@@ -477,7 +477,7 @@ func TestDecryptTamperedCiphertext(t *testing.T) {
477477
// COSE typ) is refused before any key material is used.
478478
func TestDecryptWrongEnvelopeType(t *testing.T) {
479479
// A well-formed COSE_Encrypt with a non-FEE typ and a single recipient.
480-
other := &cose.Encrypt{
480+
other := &cose.Envelope{
481481
Headers: cose.Headers{
482482
Protected: cose.Header{}.Set(cose.HeaderLabelType, "application/not-fee"),
483483
},
@@ -550,7 +550,7 @@ func TestEnvelopeWireConventions(t *testing.T) {
550550
)
551551
require.NoError(t, err)
552552

553-
env, ciphertext, err := cose.DecodeEncrypt(blob, cose.WithExpectedType(fee.EnvelopeType))
553+
env, ciphertext, err := cose.Decode(blob, cose.WithExpectedType(fee.EnvelopeType))
554554
require.NoError(t, err)
555555

556556
// Protected header: typ and body algorithm are authenticated via the AAD.
@@ -610,7 +610,7 @@ func TestContentLengthChunkCount(t *testing.T) {
610610
)
611611
require.NoError(t, err)
612612

613-
env, _, err := cose.DecodeEncrypt(blob, cose.WithExpectedType(fee.EnvelopeType))
613+
env, _, err := cose.Decode(blob, cose.WithExpectedType(fee.EnvelopeType))
614614
require.NoError(t, err)
615615
cc, ok := env.Headers.Unprotected.Int(labelChunkCount)
616616
require.True(t, ok, "chunk count present when content length declared")
@@ -626,7 +626,7 @@ func TestContentLengthChunkCount(t *testing.T) {
626626
)
627627
require.NoError(t, err)
628628

629-
env, _, err := cose.DecodeEncrypt(blob, cose.WithExpectedType(fee.EnvelopeType))
629+
env, _, err := cose.Decode(blob, cose.WithExpectedType(fee.EnvelopeType))
630630
require.NoError(t, err)
631631
require.False(t, env.Headers.Unprotected.Has(labelChunkCount),
632632
"chunk count omitted when content length unknown")
@@ -737,7 +737,7 @@ func TestDecryptMalformedChunkSizeHeader(t *testing.T) {
737737

738738
// Hand-build an envelope whose chunk-size header (FEE private-use label, in
739739
// the unprotected header) is present but holds a string instead of an integer.
740-
env := &cose.Encrypt{
740+
env := &cose.Envelope{
741741
Headers: cose.Headers{
742742
Protected: cose.Header{}.
743743
Set(cose.HeaderLabelAlg, algChunkedStream).
@@ -785,7 +785,7 @@ func TestDecryptMalformedEphemeralKey(t *testing.T) {
785785
for _, tc := range cases {
786786
tc := tc
787787
t.Run(tc.name, func(t *testing.T) {
788-
env := &cose.Encrypt{
788+
env := &cose.Envelope{
789789
Headers: cose.Headers{
790790
Protected: cose.Header{}.
791791
Set(cose.HeaderLabelAlg, algChunkedStream).

0 commit comments

Comments
 (0)