Skip to content
6 changes: 3 additions & 3 deletions fee/aeskw/aeskw.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@
// default IV. This package contributes the input validation, the ErrIntegrity
// sentinel, and a stable API over it.
//
// This is a shared primitive. The tenant-recipient wrap (ECDH-ES+A256KW, in
// the sibling fee/ecdhkw package) derives its KEK ephemerally per message and
// feeds it here; the region wrap takes a KEK straight from a key provider.
// This is a shared primitive. The ECDH-ES+A256KW wrap (in the sibling
// fee/ecdhkw package) derives its KEK ephemerally per message and feeds it
// here; a direct A256KW recipient takes a KEK straight from a key provider.
// Both paths call Wrap and Unwrap identically.
package aeskw

Expand Down
54 changes: 46 additions & 8 deletions fee/cose/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cose
import (
"bytes"
"fmt"
"io"

"github.com/fxamacker/cbor/v2"
)
Expand All @@ -13,7 +14,7 @@ type decodeConfig struct {
expectedType string
}

// DecodeOption configures [Decode].
// DecodeOption configures [Decode] and [DecodeReader].
type DecodeOption func(*decodeConfig)

// WithExpectedType requires the decoded protected header to carry a "typ"
Expand Down Expand Up @@ -87,9 +88,10 @@ func Decode(data []byte, opts ...DecodeOption) (env *Envelope, rest []byte, 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] — element-count and per-element validation
// is left to [decodeEnvelope] — factored out so a streaming decoder can reuse
// the same tag/array extraction without duplicating it.
// is the shared preamble of [Decode] and [DecodeReader] — which differ only in
// how they recover the trailing detached payload — so neither duplicates the
// tag/array extraction; element-count and per-element validation is left to
// [decodeEnvelope].
func decodeTagArray(first cbor.RawMessage) (tag uint64, arr []cbor.RawMessage, err error) {
var t cbor.RawTag
if err := decMode.Unmarshal(first, &t); err != nil {
Expand All @@ -105,10 +107,10 @@ func decodeTagArray(first cbor.RawMessage) (tag uint64, arr []cbor.RawMessage, e
}

// decodeEnvelope validates an already-decoded (tag, element-array) pair into an
// [Envelope]. It is the tag-dispatch core of [Decode]: tag 96 requires a
// 4-element array with a non-empty recipients array; tag 16 requires a 3-element
// array and yields a recipient-less envelope; both require a byte-string
// protected header and a null body. Any other tag is ErrNotEncrypt.
// [Envelope]. It is the tag-dispatch core shared by [Decode] and [DecodeReader]:
// tag 96 requires a 4-element array with a non-empty recipients array; tag 16
// requires a 3-element array and yields a recipient-less envelope; both require a
// byte-string protected header and a null body. Any other tag is ErrNotEncrypt.
func decodeEnvelope(tag uint64, arr []cbor.RawMessage) (*Envelope, error) {
switch tag {
case TagCOSEEncrypt:
Expand Down Expand Up @@ -158,6 +160,42 @@ func PeekTag(data []byte) (uint64, error) {
return tag.Number, nil
}

// DecodeReader reads one detached COSE_Encrypt (tag 96) or COSE_Encrypt0 (tag
// 16) from the front of r and returns the decoded [Envelope] together with rest:
// a reader over the bytes that follow the self-delimited envelope item — the
// detached ciphertext. rest draws first from whatever the decoder buffered past
// the envelope, then from r, so only the (small) header is held in memory and an
// arbitrarily large ciphertext can be streamed.
//
// It is the streaming counterpart to [Decode], dispatching on the same tags and
// as strict — both share the decodeEnvelope validation core: a byte-string
// protected header, map headers without duplicate labels, a null detached body,
// and, for tag 96, at least one well-formed 3-element recipient. Any deviation
// returns an error (wrapping a package sentinel) and a nil envelope.
func DecodeReader(r io.Reader, opts ...DecodeOption) (env *Envelope, rest io.Reader, err error) {
// Read exactly one CBOR item. Whatever the decoder buffered past that item,
// followed by the unread remainder of r, is the detached payload.
dec := decMode.NewDecoder(r)
var first cbor.RawMessage
if err := dec.Decode(&first); err != nil {
return nil, nil, fmt.Errorf("%w: %v", ErrMalformed, err)
}
rest = io.MultiReader(dec.Buffered(), r)

tag, arr, err := decodeTagArray(first)
if err != nil {
return nil, nil, err
}
env, err = decodeEnvelope(tag, arr)
if err != nil {
return nil, nil, err
}
if err := newDecodeConfig(opts).checkTyp(env.Headers.Protected); err != nil {
return nil, nil, err
}
return env, rest, nil
}

// decodeHeaders decodes a [protected, unprotected] pair. The protected element
// is a byte string whose content (when non-empty) is itself a CBOR map; its
// raw bytes are preserved on RawProtected for AAD stability.
Expand Down
134 changes: 134 additions & 0 deletions fee/cose/decode_reader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package cose

import (
"bytes"
"io"
"testing"
"testing/iotest"

"github.com/stretchr/testify/require"
)

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

t.Run("with recipients (COSE_Encrypt, tag 96)", func(t *testing.T) {
enc := &Envelope{
Headers: Headers{
Protected: Header{}.
Set(HeaderLabelType, exampleType).
Set(HeaderLabelAlg, int64(-65793)),
Unprotected: Header{}.Set(HeaderLabelIV, []byte("0123456789ab")),
},
Recipients: []*Recipient{{
Headers: Headers{
Protected: Header{}.Set(HeaderLabelAlg, AlgA256KW),
Unprotected: Header{}.Set(HeaderLabelKID, []byte("kid-1")),
},
Ciphertext: []byte("wrapped-cek-0123456789abcdef"),
}},
}
header, err := enc.Encode()
require.NoError(t, err)
blob := append(append([]byte{}, header...), ciphertext...)

env, rest, err := DecodeReader(bytes.NewReader(blob))
require.NoError(t, err)
require.Len(t, env.Recipients, 1, "recipient presence marks the tag-96 form")
gotRest, err := io.ReadAll(rest)
require.NoError(t, err)
require.Equal(t, ciphertext, gotRest)

// The streaming decode agrees with the byte-based Decode on both the
// trailing ciphertext and the AAD (context "Encrypt").
byteEnv, byteRest, err := Decode(blob)
require.NoError(t, err)
require.Equal(t, ciphertext, byteRest)
want, err := byteEnv.EncStructure(nil)
require.NoError(t, err)
got, err := env.EncStructure(nil)
require.NoError(t, err)
require.Equal(t, want, got)
})

t.Run("recipient-less (COSE_Encrypt0, tag 16)", func(t *testing.T) {
enc0 := &Envelope{
Headers: Headers{
Protected: Header{}.
Set(HeaderLabelType, exampleType).
Set(HeaderLabelAlg, int64(-65793)),
Unprotected: Header{}.Set(HeaderLabelIV, []byte("0123456789ab")),
},
}
header, err := enc0.Encode()
require.NoError(t, err)
blob := append(append([]byte{}, header...), ciphertext...)

env, rest, err := DecodeReader(bytes.NewReader(blob))
require.NoError(t, err)
require.Empty(t, env.Recipients, "recipient absence marks the tag-16 form")
gotRest, err := io.ReadAll(rest)
require.NoError(t, err)
require.Equal(t, ciphertext, gotRest)

// Agrees with the byte-based Decode (context "Encrypt0").
byteEnv, byteRest, err := Decode(blob)
require.NoError(t, err)
require.Equal(t, ciphertext, byteRest)
want, err := byteEnv.EncStructure(nil)
require.NoError(t, err)
got, err := env.EncStructure(nil)
require.NoError(t, err)
require.Equal(t, want, got)
})

t.Run("ciphertext split across the decoder buffer and the reader", func(t *testing.T) {
enc0 := &Envelope{
Headers: Headers{Protected: Header{}.Set(HeaderLabelType, exampleType)},
}
header, err := enc0.Encode()
require.NoError(t, err)
blob := append(append([]byte{}, header...), ciphertext...)

// One byte at a time forces the header to be reassembled from many reads
// and the ciphertext to straddle the decoder's read-ahead and the source.
env, rest, err := DecodeReader(iotest.OneByteReader(bytes.NewReader(blob)))
require.NoError(t, err)
require.Empty(t, env.Recipients)
gotRest, err := io.ReadAll(rest)
require.NoError(t, err)
require.Equal(t, ciphertext, gotRest)
})

t.Run("expected type mismatch", func(t *testing.T) {
enc0 := &Envelope{
Headers: Headers{Protected: Header{}.Set(HeaderLabelType, "application/other")},
}
header, err := enc0.Encode()
require.NoError(t, err)

env, rest, err := DecodeReader(bytes.NewReader(header), WithExpectedType(exampleType))
require.Nil(t, env)
require.Nil(t, rest)
require.ErrorIs(t, err, ErrUnexpectedType)
})

t.Run("not a COSE tag", func(t *testing.T) {
env, rest, err := DecodeReader(bytes.NewReader([]byte{0x01}))
require.Nil(t, env)
require.Nil(t, rest)
require.ErrorIs(t, err, ErrNotEncrypt)
})

t.Run("empty input", func(t *testing.T) {
env, rest, err := DecodeReader(bytes.NewReader(nil))
require.Nil(t, env)
require.Nil(t, rest)
require.ErrorIs(t, err, ErrMalformed)
})
}
4 changes: 3 additions & 1 deletion fee/ecdhkw/ecdhkw.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// encrypts a content-encryption key (CEK) to a recipient's X25519 public key so
// that only the holder of the matching private key can recover it.
//
// This is the tenant-recipient wrap of the FilOne encryption design. A fresh
// This is one of FEE's two CEK wraps: it delivers the CEK to a recipient
// identified by an X25519 public key (the sibling fee/aeskw package is the
// other, wrapping directly under a symmetric KEK). A fresh
// ephemeral X25519 key pair is generated for every Wrap; an ECDH against the
// recipient's static public key yields a shared secret, the COSE Concat-KDF
// (RFC 9053 §5.1, see kdf.go) turns that secret into a 256-bit key-encryption
Expand Down
Loading
Loading