Skip to content

Commit 5d07354

Browse files
Peejaclaude
authored andcommitted
fee: top-level package composing encrypt/decrypt over existing primitives (FIL-569) (#14)
* 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 * fee: build envelope header before opening the stream pipe (FIL-569) Address review: encryptStream created the io.Pipe and the aesstream writer before wrapping recipients and encoding the COSE header, so an error from r.wrap or Encode returned without closing the pipe, orphaning the PipeReader/PipeWriter pair until GC. Move all the fallible header work ahead of the pipe, and close both ends if the sole remaining fallible step (NewWriter) fails, so no pipe is left dangling on any error path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79 * fee/cose: fix stale [Decode] doc link in DecodeReader (FIL-569) The DecodeReader doc still referenced [Decode], which #32 renamed to DecodeEncrypt. Align the doc link with the current name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79 * 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 and shares both cose's decodeTagArray preamble and decodeEnvelope validation core with cose.Decode (they differ only in how they recover the trailing detached payload — a []byte subslice vs an io.Reader). 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 * fee: document the synchronous CEK-absorption guarantee (FIL-569) Encrypt/Decrypt wipe their CEK with defer zero(cek) as soon as encryptStream / openStream return — before the returned reader is read. Document that guarantee where it's provided, not just where it's relied upon: encryptStream wraps the CEK to recipients and calls aesstream.NewWriter (which internalizes the key into a GCM AEAD) synchronously before returning; openStream calls aesstream.NewReader likewise. So neither retains the cek slice past its own return — the background encryption goroutine and the lazy decrypt reads both work from the internalized key, never the slice — and a caller may wipe cek immediately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79 * fee: use the built-in clear to wipe the CEK (FIL-569) Address review: replace zero's manual byte loop with the built-in clear (Go 1.21+; the module is on 1.25.7). Keep the zero wrapper so callers can defer it (a bare `defer clear(b)` on a built-in is not permitted) and so its doc records the best-effort-wipe intent. Behavior is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79 * fee: address review — CEK-uniqueness doc, safer mismatch, sentinel/nits (FIL-569) Five non-blocking review points (bajtos): - EncryptWithCEK: document the CEK-uniqueness requirement. Under a reused caller-supplied CEK the only cross-envelope separation is the 7-byte random base nonce (~2^28-envelope birthday bound before an AES-GCM nonce collision), so the doc now requires a distinct CEK per envelope (or reuse far below the bound). The wire format is fixed, so this is a caller obligation. - encryptStream: check the content-length mismatch before w.Close(), so a mismatch withholds the final STREAM chunk. A caller that ignores the error and stores the blob then gets a truncated object that fails to decrypt (aesstream.ErrTruncated) rather than a valid-but-mislabeled one. Covered by an extended TestContentLengthMismatch. - Encrypt-side out-of-range WithChunkSize is an invalid argument, not a malformed envelope (none exists yet): return aesstream.ErrChunkSize — the sentinel aesstream.NewWriter itself uses — instead of ErrMalformedEnvelope. Decode-side out-of-range stays ErrMalformedEnvelope. Test updated. - WithContentLength: document that a negative n is treated as "unknown" (same as unset), so a propagated HTTP -1 is explicit rather than a silent footgun. - test: replace the hand-rolled itoa with strconv.Itoa. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd1bd2a commit 5d07354

8 files changed

Lines changed: 1861 additions & 12 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: 46 additions & 8 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
)
@@ -13,7 +14,7 @@ type decodeConfig struct {
1314
expectedType string
1415
}
1516

16-
// DecodeOption configures [Decode].
17+
// DecodeOption configures [Decode] and [DecodeReader].
1718
type DecodeOption func(*decodeConfig)
1819

1920
// WithExpectedType requires the decoded protected header to carry a "typ"
@@ -87,9 +88,10 @@ func Decode(data []byte, opts ...DecodeOption) (env *Envelope, rest []byte, err
8788

8889
// decodeTagArray unmarshals one already-read CBOR item into the tag number and
8990
// element array it wraps: the item must be a tag whose content is an array. It
90-
// is the shared preamble of [Decode] — element-count and per-element validation
91-
// is left to [decodeEnvelope] — factored out so a streaming decoder can reuse
92-
// the same tag/array extraction without duplicating it.
91+
// is the shared preamble of [Decode] and [DecodeReader] — which differ only in
92+
// how they recover the trailing detached payload — so neither duplicates the
93+
// tag/array extraction; element-count and per-element validation is left to
94+
// [decodeEnvelope].
9395
func decodeTagArray(first cbor.RawMessage) (tag uint64, arr []cbor.RawMessage, err error) {
9496
var t cbor.RawTag
9597
if err := decMode.Unmarshal(first, &t); err != nil {
@@ -105,10 +107,10 @@ func decodeTagArray(first cbor.RawMessage) (tag uint64, arr []cbor.RawMessage, e
105107
}
106108

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

165+
// DecodeReader reads one detached COSE_Encrypt (tag 96) or COSE_Encrypt0 (tag
166+
// 16) from the front of r and returns the decoded [Envelope] together with rest:
167+
// a reader over the bytes that follow the self-delimited envelope item — the
168+
// detached ciphertext. rest draws first from whatever the decoder buffered past
169+
// the envelope, then from r, so only the (small) header is held in memory and an
170+
// arbitrarily large ciphertext can be streamed.
171+
//
172+
// It is the streaming counterpart to [Decode], dispatching on the same tags and
173+
// as strict — both share the decodeEnvelope validation core: a byte-string
174+
// protected header, map headers without duplicate labels, a null detached body,
175+
// and, for tag 96, at least one well-formed 3-element recipient. Any deviation
176+
// returns an error (wrapping a package sentinel) and a nil envelope.
177+
func DecodeReader(r io.Reader, opts ...DecodeOption) (env *Envelope, rest io.Reader, err error) {
178+
// Read exactly one CBOR item. Whatever the decoder buffered past that item,
179+
// followed by the unread remainder of r, is the detached payload.
180+
dec := decMode.NewDecoder(r)
181+
var first cbor.RawMessage
182+
if err := dec.Decode(&first); err != nil {
183+
return nil, nil, fmt.Errorf("%w: %v", ErrMalformed, err)
184+
}
185+
rest = io.MultiReader(dec.Buffered(), r)
186+
187+
tag, arr, err := decodeTagArray(first)
188+
if err != nil {
189+
return nil, nil, err
190+
}
191+
env, err = decodeEnvelope(tag, arr)
192+
if err != nil {
193+
return nil, nil, err
194+
}
195+
if err := newDecodeConfig(opts).checkTyp(env.Headers.Protected); err != nil {
196+
return nil, nil, err
197+
}
198+
return env, rest, nil
199+
}
200+
163201
// decodeHeaders decodes a [protected, unprotected] pair. The protected element
164202
// is a byte string whose content (when non-empty) is itself a CBOR map; its
165203
// raw bytes are preserved on RawProtected for AAD stability.

fee/cose/decode_reader_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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 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.
17+
func TestDecodeReader(t *testing.T) {
18+
ciphertext := []byte("detached-stream-ciphertext-bytes-0123456789")
19+
20+
t.Run("with recipients (COSE_Encrypt, tag 96)", func(t *testing.T) {
21+
enc := &Envelope{
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.Len(t, env.Recipients, 1, "recipient presence marks the tag-96 form")
43+
gotRest, err := io.ReadAll(rest)
44+
require.NoError(t, err)
45+
require.Equal(t, ciphertext, gotRest)
46+
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)
50+
require.NoError(t, err)
51+
require.Equal(t, ciphertext, byteRest)
52+
want, err := byteEnv.EncStructure(nil)
53+
require.NoError(t, err)
54+
got, err := env.EncStructure(nil)
55+
require.NoError(t, err)
56+
require.Equal(t, want, got)
57+
})
58+
59+
t.Run("recipient-less (COSE_Encrypt0, tag 16)", func(t *testing.T) {
60+
enc0 := &Envelope{
61+
Headers: Headers{
62+
Protected: Header{}.
63+
Set(HeaderLabelType, exampleType).
64+
Set(HeaderLabelAlg, int64(-65793)),
65+
Unprotected: Header{}.Set(HeaderLabelIV, []byte("0123456789ab")),
66+
},
67+
}
68+
header, err := enc0.Encode()
69+
require.NoError(t, err)
70+
blob := append(append([]byte{}, header...), ciphertext...)
71+
72+
env, rest, err := DecodeReader(bytes.NewReader(blob))
73+
require.NoError(t, err)
74+
require.Empty(t, env.Recipients, "recipient absence marks the tag-16 form")
75+
gotRest, err := io.ReadAll(rest)
76+
require.NoError(t, err)
77+
require.Equal(t, ciphertext, gotRest)
78+
79+
// Agrees with the byte-based Decode (context "Encrypt0").
80+
byteEnv, byteRest, err := Decode(blob)
81+
require.NoError(t, err)
82+
require.Equal(t, ciphertext, byteRest)
83+
want, err := byteEnv.EncStructure(nil)
84+
require.NoError(t, err)
85+
got, err := env.EncStructure(nil)
86+
require.NoError(t, err)
87+
require.Equal(t, want, got)
88+
})
89+
90+
t.Run("ciphertext split across the decoder buffer and the reader", func(t *testing.T) {
91+
enc0 := &Envelope{
92+
Headers: Headers{Protected: Header{}.Set(HeaderLabelType, exampleType)},
93+
}
94+
header, err := enc0.Encode()
95+
require.NoError(t, err)
96+
blob := append(append([]byte{}, header...), ciphertext...)
97+
98+
// One byte at a time forces the header to be reassembled from many reads
99+
// and the ciphertext to straddle the decoder's read-ahead and the source.
100+
env, rest, err := DecodeReader(iotest.OneByteReader(bytes.NewReader(blob)))
101+
require.NoError(t, err)
102+
require.Empty(t, env.Recipients)
103+
gotRest, err := io.ReadAll(rest)
104+
require.NoError(t, err)
105+
require.Equal(t, ciphertext, gotRest)
106+
})
107+
108+
t.Run("expected type mismatch", func(t *testing.T) {
109+
enc0 := &Envelope{
110+
Headers: Headers{Protected: Header{}.Set(HeaderLabelType, "application/other")},
111+
}
112+
header, err := enc0.Encode()
113+
require.NoError(t, err)
114+
115+
env, rest, err := DecodeReader(bytes.NewReader(header), WithExpectedType(exampleType))
116+
require.Nil(t, env)
117+
require.Nil(t, rest)
118+
require.ErrorIs(t, err, ErrUnexpectedType)
119+
})
120+
121+
t.Run("not a COSE tag", func(t *testing.T) {
122+
env, rest, err := DecodeReader(bytes.NewReader([]byte{0x01}))
123+
require.Nil(t, env)
124+
require.Nil(t, rest)
125+
require.ErrorIs(t, err, ErrNotEncrypt)
126+
})
127+
128+
t.Run("empty input", func(t *testing.T) {
129+
env, rest, err := DecodeReader(bytes.NewReader(nil))
130+
require.Nil(t, env)
131+
require.Nil(t, rest)
132+
require.ErrorIs(t, err, ErrMalformed)
133+
})
134+
}

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)