Skip to content

Commit fbb1ee8

Browse files
committed
feat(fee): compose FEE encrypt/decrypt over the pinned foc wire format (FIL-569)
Add a top-level `fee` package that sequences the four FEE primitives (cose, aesstream, ecdhkw, aeskw) into a small streaming API: - Encrypt(plaintext, recipients) -> io.ReadCloser over envelope||ciphertext - Decrypt(src, unwrap) -> io.Reader over the recovered plaintext - EncryptWithCEK / DecryptWithCEK for an externally-managed CEK Both directions stream with O(chunk size) memory: Encrypt seals through an io.Pipe fed by a background goroutine; Decrypt reads only the envelope header up front (via the new cose.DecodeReader) and streams the detached ciphertext. Recipients are domain-agnostic and selected on decrypt by an opaque, caller-supplied kid (a DID verification method ID): ECDH-ES+A256KW to an X25519 public key, or A256KW under a symmetric KEK. Both kinds may be mixed in one envelope; a kid that matches no recipient yields ErrNoMatchingRecipient. With no recipients, EncryptWithCEK emits a recipient-less COSE_Encrypt0 (tag 16). The wire format matches the foc-encryption reference and the FIL-473 cross-implementation vectors: typ application/vnd.foc-envelope+cose, the chunked AES-256-GCM-STREAM body alg in the protected header, and the base nonce / chunk size (and, when the content length is known via WithContentLength, the advisory chunk count) in the unprotected header. The ECDH-ES ephemeral key is a self-describing COSE_Key (kty=OKP, crv=X25519), decoded and validated on unwrap. The body AAD is the envelope's own Enc_structure, so its context tracks the tag. Also add cose.DecodeReader: a streaming, tag-16/96-dispatching decoder that returns the decoded header plus a reader over the trailing detached ciphertext, producing the same AAD and trailing bytes as the byte-based Decode. De-word the aeskw/ecdhkw package docs (drop the app-specific "tenant"/"region" vocabulary) so the primitives read as a standalone FEE library. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
1 parent 2e4bc15 commit fbb1ee8

8 files changed

Lines changed: 1897 additions & 4 deletions

File tree

fee/aeskw/aeskw.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@
3030
// default IV. This package contributes the input validation, the ErrIntegrity
3131
// sentinel, and a stable API over it.
3232
//
33-
// This is a shared primitive. The tenant-recipient wrap (ECDH-ES+A256KW, in
34-
// the sibling fee/ecdhkw package) derives its KEK ephemerally per message and
35-
// feeds it here; the region wrap takes a KEK straight from a key provider.
33+
// This is a shared primitive. The ECDH-ES+A256KW wrap (in the sibling
34+
// fee/ecdhkw package) derives its KEK ephemerally per message and feeds it
35+
// here; a direct A256KW recipient takes a KEK straight from a key provider.
3636
// Both paths call Wrap and Unwrap identically.
3737
package aeskw
3838

fee/cose/decode.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cose
33
import (
44
"bytes"
55
"fmt"
6+
"io"
67

78
"github.com/fxamacker/cbor/v2"
89
)
@@ -181,6 +182,116 @@ func PeekTag(data []byte) (uint64, error) {
181182
return tag.Number, nil
182183
}
183184

185+
// DecodedEnvelope is a decoded detached COSE envelope header returned by
186+
// [DecodeReader]: either a COSE_Encrypt (tag 96, carrying Recipients) or a
187+
// COSE_Encrypt0 (tag 16, no recipients), distinguished by Tag.
188+
type DecodedEnvelope struct {
189+
// Tag is [TagCOSEEncrypt] (96) or [TagCOSEEncrypt0] (16).
190+
Tag uint64
191+
// Headers is the body protected/unprotected header pair.
192+
Headers Headers
193+
// Recipients are the per-recipient wrapped-key entries; nil for a
194+
// COSE_Encrypt0 (tag 16).
195+
Recipients []*Recipient
196+
}
197+
198+
// EncStructure returns the body AAD Enc_structure for this envelope, using the
199+
// context that matches its tag: "Encrypt" for a COSE_Encrypt (tag 96),
200+
// "Encrypt0" for a COSE_Encrypt0 (tag 16). See [Encrypt.EncStructure].
201+
func (e *DecodedEnvelope) EncStructure(externalAAD []byte) ([]byte, error) {
202+
ctx := contextEncrypt
203+
if e.Tag == TagCOSEEncrypt0 {
204+
ctx = contextEncrypt0
205+
}
206+
prot, err := e.Headers.protectedBytes()
207+
if err != nil {
208+
return nil, fmt.Errorf("cose: building Enc_structure: %w", err)
209+
}
210+
return encStructureBytes(ctx, prot, externalAAD)
211+
}
212+
213+
// DecodeReader reads one detached COSE_Encrypt (tag 96) or COSE_Encrypt0 (tag
214+
// 16) from the front of r and returns the decoded envelope header together with
215+
// rest: a reader over the bytes that follow the self-delimited envelope item —
216+
// the detached ciphertext. rest draws first from whatever the decoder buffered
217+
// past the envelope, then from r, so only the (small) header is held in memory
218+
// and an arbitrarily large ciphertext can be streamed.
219+
//
220+
// It is the streaming, tag-dispatching counterpart to [Decode] and
221+
// [DecodeEncrypt0], and is as strict as they are: a byte-string protected
222+
// header, map headers without duplicate labels, a null detached body, and — for
223+
// tag 96 — at least one well-formed 3-element recipient. Any deviation returns
224+
// an error (wrapping a package sentinel) and a nil envelope.
225+
func DecodeReader(r io.Reader, opts ...DecodeOption) (env *DecodedEnvelope, rest io.Reader, err error) {
226+
var cfg decodeConfig
227+
for _, o := range opts {
228+
o(&cfg)
229+
}
230+
231+
// Read exactly one CBOR item. Whatever the decoder buffered past that item,
232+
// followed by the unread remainder of r, is the detached payload.
233+
dec := decMode.NewDecoder(r)
234+
var first cbor.RawMessage
235+
if err := dec.Decode(&first); err != nil {
236+
return nil, nil, fmt.Errorf("%w: %v", ErrMalformed, err)
237+
}
238+
rest = io.MultiReader(dec.Buffered(), r)
239+
240+
var tag cbor.RawTag
241+
if err := decMode.Unmarshal(first, &tag); err != nil {
242+
return nil, nil, fmt.Errorf("%w: %v", ErrNotEncrypt, err)
243+
}
244+
if tag.Number != TagCOSEEncrypt && tag.Number != TagCOSEEncrypt0 {
245+
return nil, nil, fmt.Errorf("%w: got tag %d", ErrNotEncrypt, tag.Number)
246+
}
247+
248+
if cborMajor(tag.Content) != majorArray {
249+
return nil, nil, fmt.Errorf("%w: tag content is not an array", ErrMalformed)
250+
}
251+
var arr []cbor.RawMessage
252+
if err := decMode.Unmarshal(tag.Content, &arr); err != nil {
253+
return nil, nil, fmt.Errorf("%w: %v", ErrMalformed, err)
254+
}
255+
256+
// A COSE_Encrypt is a 4-element array (with recipients); a COSE_Encrypt0 is
257+
// a 3-element array (no recipients).
258+
wantLen := 3
259+
if tag.Number == TagCOSEEncrypt {
260+
wantLen = 4
261+
}
262+
if len(arr) != wantLen {
263+
return nil, nil, fmt.Errorf("%w: array has %d elements, want %d", ErrMalformed, len(arr), wantLen)
264+
}
265+
266+
headers, err := decodeHeaders(arr[0], arr[1])
267+
if err != nil {
268+
return nil, nil, err
269+
}
270+
271+
// Detached payload: the body ciphertext must be null.
272+
if !isNull(arr[2]) {
273+
return nil, nil, ErrDetachedPayload
274+
}
275+
276+
env = &DecodedEnvelope{Tag: tag.Number, Headers: headers}
277+
if tag.Number == TagCOSEEncrypt {
278+
recipients, err := decodeRecipients(arr[3])
279+
if err != nil {
280+
return nil, nil, err
281+
}
282+
env.Recipients = recipients
283+
}
284+
285+
if cfg.checkType {
286+
got, ok := env.Headers.Protected.Text(HeaderLabelType)
287+
if !ok || got != cfg.expectedType {
288+
return nil, nil, fmt.Errorf("%w: got %q, want %q", ErrUnexpectedType, got, cfg.expectedType)
289+
}
290+
}
291+
292+
return env, rest, nil
293+
}
294+
184295
// decodeHeaders decodes a [protected, unprotected] pair. The protected element
185296
// is a byte string whose content (when non-empty) is itself a CBOR map; its
186297
// raw bytes are preserved on RawProtected for AAD stability.

fee/cose/decode_reader_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package cose
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"testing"
7+
"testing/iotest"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
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.
17+
func TestDecodeReader(t *testing.T) {
18+
ciphertext := []byte("detached-stream-ciphertext-bytes-0123456789")
19+
20+
t.Run("tag 96 COSE_Encrypt", func(t *testing.T) {
21+
enc := &Encrypt{
22+
Headers: Headers{
23+
Protected: Header{}.
24+
Set(HeaderLabelType, exampleType).
25+
Set(HeaderLabelAlg, int64(-65793)),
26+
Unprotected: Header{}.Set(HeaderLabelIV, []byte("0123456789ab")),
27+
},
28+
Recipients: []*Recipient{{
29+
Headers: Headers{
30+
Protected: Header{}.Set(HeaderLabelAlg, AlgA256KW),
31+
Unprotected: Header{}.Set(HeaderLabelKID, []byte("kid-1")),
32+
},
33+
Ciphertext: []byte("wrapped-cek-0123456789abcdef"),
34+
}},
35+
}
36+
header, err := enc.Encode()
37+
require.NoError(t, err)
38+
blob := append(append([]byte{}, header...), ciphertext...)
39+
40+
env, rest, err := DecodeReader(bytes.NewReader(blob))
41+
require.NoError(t, err)
42+
require.Equal(t, TagCOSEEncrypt, env.Tag)
43+
require.Len(t, env.Recipients, 1)
44+
gotRest, err := io.ReadAll(rest)
45+
require.NoError(t, err)
46+
require.Equal(t, ciphertext, gotRest)
47+
48+
// The streaming decode agrees with the byte-based Decode on both the
49+
// trailing ciphertext and the AAD (context "Encrypt").
50+
byteEnv, byteRest, err := Decode(blob)
51+
require.NoError(t, err)
52+
require.Equal(t, ciphertext, byteRest)
53+
want, err := byteEnv.EncStructure(nil)
54+
require.NoError(t, err)
55+
got, err := env.EncStructure(nil)
56+
require.NoError(t, err)
57+
require.Equal(t, want, got)
58+
})
59+
60+
t.Run("tag 16 COSE_Encrypt0", func(t *testing.T) {
61+
enc0 := &Encrypt0{
62+
Headers: Headers{
63+
Protected: Header{}.
64+
Set(HeaderLabelType, exampleType).
65+
Set(HeaderLabelAlg, int64(-65793)),
66+
Unprotected: Header{}.Set(HeaderLabelIV, []byte("0123456789ab")),
67+
},
68+
}
69+
header, err := enc0.Encode()
70+
require.NoError(t, err)
71+
blob := append(append([]byte{}, header...), ciphertext...)
72+
73+
env, rest, err := DecodeReader(bytes.NewReader(blob))
74+
require.NoError(t, err)
75+
require.Equal(t, TagCOSEEncrypt0, env.Tag)
76+
require.Nil(t, env.Recipients)
77+
gotRest, err := io.ReadAll(rest)
78+
require.NoError(t, err)
79+
require.Equal(t, ciphertext, gotRest)
80+
81+
// Agrees with the byte-based DecodeEncrypt0 (context "Encrypt0").
82+
byteEnv, byteRest, err := DecodeEncrypt0(blob)
83+
require.NoError(t, err)
84+
require.Equal(t, ciphertext, byteRest)
85+
want, err := byteEnv.EncStructure(nil)
86+
require.NoError(t, err)
87+
got, err := env.EncStructure(nil)
88+
require.NoError(t, err)
89+
require.Equal(t, want, got)
90+
})
91+
92+
t.Run("ciphertext split across the decoder buffer and the reader", func(t *testing.T) {
93+
enc0 := &Encrypt0{
94+
Headers: Headers{Protected: Header{}.Set(HeaderLabelType, exampleType)},
95+
}
96+
header, err := enc0.Encode()
97+
require.NoError(t, err)
98+
blob := append(append([]byte{}, header...), ciphertext...)
99+
100+
// One byte at a time forces the header to be reassembled from many reads
101+
// and the ciphertext to straddle the decoder's read-ahead and the source.
102+
env, rest, err := DecodeReader(iotest.OneByteReader(bytes.NewReader(blob)))
103+
require.NoError(t, err)
104+
require.Equal(t, TagCOSEEncrypt0, env.Tag)
105+
gotRest, err := io.ReadAll(rest)
106+
require.NoError(t, err)
107+
require.Equal(t, ciphertext, gotRest)
108+
})
109+
110+
t.Run("expected type mismatch", func(t *testing.T) {
111+
enc0 := &Encrypt0{
112+
Headers: Headers{Protected: Header{}.Set(HeaderLabelType, "application/other")},
113+
}
114+
header, err := enc0.Encode()
115+
require.NoError(t, err)
116+
117+
env, rest, err := DecodeReader(bytes.NewReader(header), WithExpectedType(exampleType))
118+
require.Nil(t, env)
119+
require.Nil(t, rest)
120+
require.ErrorIs(t, err, ErrUnexpectedType)
121+
})
122+
123+
t.Run("not a COSE tag", func(t *testing.T) {
124+
env, rest, err := DecodeReader(bytes.NewReader([]byte{0x01}))
125+
require.Nil(t, env)
126+
require.Nil(t, rest)
127+
require.ErrorIs(t, err, ErrNotEncrypt)
128+
})
129+
130+
t.Run("empty input", func(t *testing.T) {
131+
env, rest, err := DecodeReader(bytes.NewReader(nil))
132+
require.Nil(t, env)
133+
require.Nil(t, rest)
134+
require.ErrorIs(t, err, ErrMalformed)
135+
})
136+
}

fee/ecdhkw/ecdhkw.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
// encrypts a content-encryption key (CEK) to a recipient's X25519 public key so
33
// that only the holder of the matching private key can recover it.
44
//
5-
// This is the tenant-recipient wrap of the FilOne encryption design. A fresh
5+
// This is one of FEE's two CEK wraps: it delivers the CEK to a recipient
6+
// identified by an X25519 public key (the sibling fee/aeskw package is the
7+
// other, wrapping directly under a symmetric KEK). A fresh
68
// ephemeral X25519 key pair is generated for every Wrap; an ECDH against the
79
// recipient's static public key yields a shared secret, the COSE Concat-KDF
810
// (RFC 9053 §5.1, see kdf.go) turns that secret into a 256-bit key-encryption

0 commit comments

Comments
 (0)