Skip to content

Commit 3e12471

Browse files
committed
fee: make the API domain-agnostic; add external-CEK paths (FIL-569)
The composition layer named recipients by app role ("tenant"/"region"). This package should be a standalone FEE library, so name by mechanism instead and separate the two orthogonal concerns: the wrap algorithm, and how the CEK reaches the decryptor. Recipients / unwrappers (rename; kid is now an explicit, opaque caller-supplied key id — a DID verification method ID in the consuming app — for both kinds): - NewTenantRecipient -> NewECDHESRecipient(kid, pub) ECDH-ES+A256KW (X25519) - NewRegionRecipient -> NewA256KWRecipient(kid, kek) A256KW (32-byte KEK) - NewTenantUnwrapper -> NewECDHESUnwrapper(kid, priv) - NewRegionUnwrapper -> NewA256KWUnwrapper(kid, kek) Names track the exact COSE algorithms because the arguments are pinned to them (X25519-only key agreement; 32-byte A256KW KEK) rather than general ECDH/AES-KW. Externally-provided CEK (new, decoupled from the wrap algorithm): - EncryptWithCEK(plaintext, cek, recipients, opts): seal under a caller-provided CEK instead of a generated one. - DecryptWithCEK(src, cek): decrypt with a CEK obtained out of band (e.g. unwrapped by a custody service), ignoring the envelope's recipients. Encrypt/Decrypt and EncryptWithCEK/DecryptWithCEK share seal/open cores; a 32-byte CEK is required (ErrInvalidCEK). Also de-worded the fee/aeskw and fee/ecdhkw package doc comments (comments only; no behavior change) so the whole tree is free of app vocabulary. Tests: renamed throughout; added external-CEK round trip + cross-check + invalid length, a proper ECDH-ES wrong-key unwrap test (now possible with an explicit kid), and empty-kid recipient validation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fyx4RZx2j88mTQyeavbs79
1 parent 4d30b62 commit 3e12471

6 files changed

Lines changed: 377 additions & 234 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; the A256KW recipient wrap takes a KEK straight from a key provider.
3636
// Both paths call Wrap and Unwrap identically.
3737
package aeskw
3838

fee/ecdhkw/ecdhkw.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
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
6-
// ephemeral X25519 key pair is generated for every Wrap; an ECDH against the
5+
// This is the key-agreement recipient wrap of the FilOne encryption design. A
6+
// fresh ephemeral X25519 key pair is generated for every Wrap; an ECDH against the
77
// recipient's static public key yields a shared secret, the COSE Concat-KDF
88
// (RFC 9053 §5.1, see kdf.go) turns that secret into a 256-bit key-encryption
99
// key, and AES Key Wrap (RFC 3394, the sibling fee/aeskw package) wraps the CEK

fee/fee.go

Lines changed: 108 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -10,33 +10,39 @@
1010
// - fee/aesstream — the chunked AES-256-GCM-STREAM body cipher: it seals the
1111
// plaintext under a per-object content-encryption key (CEK) and a random
1212
// base nonce.
13-
// - fee/ecdhkw — the tenant recipient wrap (ECDH-ES+A256KW over X25519):
14-
// it encrypts the CEK to a tenant's X25519 public key.
15-
// - fee/aeskw — RFC 3394 AES Key Wrap, used for the region recipient wrap
16-
// (A256KW) directly under a key-encryption key (KEK).
13+
// - fee/ecdhkw — the ECDH-ES+A256KW key wrap over X25519: it encrypts the
14+
// CEK to a recipient's X25519 public key.
15+
// - fee/aeskw — RFC 3394 AES Key Wrap (A256KW): it wraps the CEK directly
16+
// under a symmetric key-encryption key (KEK).
1717
//
1818
// This package sequences them so callers do not have to. [Encrypt] generates a
1919
// fresh CEK, seals the plaintext with the STREAM body cipher, wraps the CEK to
2020
// each [Recipient], and encodes the COSE_Encrypt envelope; [Decrypt] reverses
2121
// the process, locating the recipient that a [RecipientUnwrapper] holds the key
2222
// for, recovering the CEK, and streaming out the plaintext.
2323
//
24-
// # Recipients
24+
// # Recipients and content-encryption keys
2525
//
26-
// A single envelope can carry several recipients, each holding the same CEK
27-
// wrapped under a different key, named by a key id (kid). A caller mixes
28-
// recipient kinds freely and never has to know which wrap algorithm a given
29-
// envelope entry uses — the kid and the recipient's COSE algorithm header
30-
// determine that:
26+
// The body is sealed under a single content-encryption key (CEK). Two concerns
27+
// are independent: which algorithm wraps the CEK, and how the CEK reaches the
28+
// decryptor.
3129
//
32-
// - A tenant recipient ([NewTenantRecipient] / [NewTenantUnwrapper]) wraps the
33-
// CEK to an X25519 public key with ECDH-ES+A256KW. Its kid is the raw
34-
// X25519 public-key bytes, so recovery needs only the matching private key.
35-
// - A region recipient ([NewRegionRecipient] / [NewRegionUnwrapper]) wraps the
36-
// CEK under a symmetric KEK with A256KW. Its kid is supplied by the caller
37-
// and names the KEK (and, by convention, its version). The KEK custody and
38-
// versioning layer is a separate concern (see FIL-547) that plugs in behind
39-
// the same Recipient/RecipientUnwrapper seam without changing this API.
30+
// A CEK can be carried in the envelope as one or more COSE_Recipient entries,
31+
// each keyed by a caller-supplied key id (kid — opaque to this package, e.g. a
32+
// DID verification method ID). Two wrap algorithms are available and may be mixed
33+
// in one envelope; the caller never selects the algorithm on decrypt, the
34+
// recipient's COSE header does:
35+
//
36+
// - ECDH-ES+A256KW to an X25519 public key: [NewECDHESRecipient] /
37+
// [NewECDHESUnwrapper].
38+
// - A256KW under a symmetric KEK: [NewA256KWRecipient] / [NewA256KWUnwrapper].
39+
//
40+
// Alternatively the CEK can be managed out of band — generated or unwrapped by a
41+
// custody service and handed to this package directly. [EncryptWithCEK] seals
42+
// under a caller-provided CEK and [DecryptWithCEK] decrypts with one, ignoring
43+
// the envelope's recipients. The two axes are orthogonal: either wrap algorithm
44+
// can be used for an in-envelope recipient, and the external-CEK path is
45+
// independent of both.
4046
//
4147
// # Wire format
4248
//
@@ -117,8 +123,8 @@ var (
117123
ErrUnsupportedBodyAlg = errors.New("fee: unsupported body algorithm")
118124

119125
// ErrUnsupportedRecipientAlg means a matched recipient's key-wrap algorithm
120-
// header does not match the unwrapper that was asked to recover it (e.g. a
121-
// tenant unwrapper matched against an A256KW recipient).
126+
// header does not match the unwrapper that was asked to recover it (e.g. an
127+
// ECDH-ES unwrapper matched against an A256KW recipient).
122128
ErrUnsupportedRecipientAlg = errors.New("fee: unsupported recipient key-wrap algorithm")
123129

124130
// ErrMalformedEnvelope means a required body header was missing or had the
@@ -127,6 +133,10 @@ var (
127133

128134
// ErrNilUnwrapper means Decrypt was given a nil RecipientUnwrapper.
129135
ErrNilUnwrapper = errors.New("fee: nil recipient unwrapper")
136+
137+
// ErrInvalidCEK means a caller-provided content-encryption key (see
138+
// EncryptWithCEK / DecryptWithCEK) was not the required AES-256 key length.
139+
ErrInvalidCEK = errors.New("fee: content-encryption key must be 32 bytes")
130140
)
131141

132142
// encryptConfig holds the resolved, optional Encrypt parameters.
@@ -162,16 +172,49 @@ func WithChunkSize(n int) EncryptOption {
162172
// caller MUST either read it to EOF or Close it: Close aborts the goroutine and
163173
// releases its resources. An encryption failure (reading plaintext, or
164174
// finalizing a chunk) surfaces as a non-EOF error from the reader's Read.
175+
//
176+
// To seal under a CEK you already hold (rather than a freshly generated one),
177+
// use [EncryptWithCEK].
165178
func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, error) {
179+
cek := make([]byte, aesstream.KeySize)
180+
if _, err := rand.Read(cek); err != nil {
181+
return nil, fmt.Errorf("fee: generating content-encryption key: %w", err)
182+
}
183+
// encryptStream copies the CEK into the body cipher and wraps it to the
184+
// recipients (all synchronously, before it returns), so our generated copy
185+
// can be wiped once it returns — on every path.
186+
defer zero(cek)
187+
return encryptStream(plaintext, cek, recipients, opts...)
188+
}
189+
190+
// EncryptWithCEK is [Encrypt] with a caller-provided content-encryption key
191+
// instead of a freshly generated one — for when the CEK is managed out of band
192+
// (e.g. derived deterministically, or issued by a custody service). cek must be
193+
// 32 bytes (AES-256). The envelope still carries whatever recipients are given;
194+
// pair it with [DecryptWithCEK] to recover without an in-envelope unwrap.
195+
//
196+
// The caller retains ownership of cek: it is copied into the body cipher and
197+
// wrapped to the recipients, but neither retained nor wiped by this call.
198+
func EncryptWithCEK(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, error) {
199+
if len(cek) != aesstream.KeySize {
200+
return nil, fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek))
201+
}
202+
return encryptStream(plaintext, cek, recipients, opts...)
203+
}
204+
205+
// encryptStream is the shared core of Encrypt and EncryptWithCEK: it seals
206+
// plaintext under cek and returns a streaming reader over envelope||ciphertext.
207+
// It does not retain, modify, or wipe cek — the caller owns that decision.
208+
func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, error) {
166209
if plaintext == nil {
167210
return nil, errors.New("fee: nil plaintext reader")
168211
}
169212
if len(recipients) == 0 {
170213
return nil, ErrNoRecipients
171214
}
172215
// Validate recipients before doing any encryption work, so a malformed
173-
// recipient (a nil entry, or invalid contents such as a nil tenant key or a
174-
// wrong-length region KEK) fails fast rather than after streaming has begun.
216+
// recipient (a nil entry, or invalid contents such as a nil key or a
217+
// wrong-length KEK) fails fast rather than after streaming has begun.
175218
for i, r := range recipients {
176219
if r == nil {
177220
return nil, fmt.Errorf("fee: recipient %d is nil", i)
@@ -193,16 +236,8 @@ func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption)
193236
ErrMalformedEnvelope, cfg.chunkSize, aesstream.MinChunkSize, aesstream.MaxChunkSize)
194237
}
195238

196-
// Fresh per-object content-encryption key and STREAM base nonce. The CEK is
197-
// wiped once it has been copied into the body cipher and wrapped to every
198-
// recipient; the wrapped copies, not this one, travel in the envelope.
199-
cek := make([]byte, aesstream.KeySize)
200-
if _, err := rand.Read(cek); err != nil {
201-
return nil, fmt.Errorf("fee: generating content-encryption key: %w", err)
202-
}
203239
baseNonce, err := aesstream.NewBaseNonce()
204240
if err != nil {
205-
zero(cek)
206241
return nil, fmt.Errorf("fee: generating base nonce: %w", err)
207242
}
208243

@@ -225,7 +260,6 @@ func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption)
225260
// be derived before the CEK is wrapped.
226261
aad, err := env.EncStructure(nil)
227262
if err != nil {
228-
zero(cek)
229263
return nil, fmt.Errorf("fee: building envelope AAD: %w", err)
230264
}
231265

@@ -239,7 +273,6 @@ func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption)
239273
ChunkSize: cfg.chunkSize,
240274
})
241275
if err != nil {
242-
zero(cek)
243276
return nil, fmt.Errorf("fee: initializing body cipher: %w", err)
244277
}
245278

@@ -248,21 +281,15 @@ func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption)
248281
for i, r := range recipients {
249282
entry, werr := r.wrap(cek)
250283
if werr != nil {
251-
zero(cek)
252284
return nil, werr
253285
}
254286
env.Recipients[i] = entry
255287
}
256288
header, err := env.Encode()
257289
if err != nil {
258-
zero(cek)
259290
return nil, fmt.Errorf("fee: encoding envelope: %w", err)
260291
}
261292

262-
// The CEK is now in the body cipher's key schedule and wrapped to every
263-
// recipient; wipe our copy before streaming begins.
264-
zero(cek)
265-
266293
go func() {
267294
_, cerr := io.Copy(w, plaintext)
268295
if cerr == nil {
@@ -305,9 +332,10 @@ func (e *encryptReader) Close() error { return e.pr.Close() }
305332
// fee/aesstream) means the plaintext is incomplete and must be discarded.
306333
//
307334
// If no recipient kid matches unwrap, Decrypt returns [ErrNoMatchingRecipient]
308-
// without attempting an unwrap. If the matched recipient's wrapped CEK cannot
309-
// be recovered (e.g. the wrong key), the unwrap error is returned and no
310-
// plaintext reader is produced.
335+
// without attempting an unwrap. If the matched recipient's wrapped CEK cannot be
336+
// recovered (e.g. the wrong key), the unwrap error is returned and no plaintext
337+
// reader is produced. To decrypt with a CEK obtained out of band, use
338+
// [DecryptWithCEK].
311339
func Decrypt(src io.Reader, unwrap RecipientUnwrapper) (io.Reader, error) {
312340
if src == nil {
313341
return nil, errors.New("fee: nil envelope reader")
@@ -321,25 +349,55 @@ func Decrypt(src io.Reader, unwrap RecipientUnwrapper) (io.Reader, error) {
321349
return nil, fmt.Errorf("fee: decoding envelope: %w", err)
322350
}
323351

324-
alg, ok := env.Headers.Protected.Int(cose.HeaderLabelAlg)
325-
if !ok {
326-
return nil, fmt.Errorf("%w: body algorithm header missing or not an integer", ErrUnsupportedBodyAlg)
327-
}
328-
if alg != algChunkedAES256GCMStream {
329-
return nil, fmt.Errorf("%w: body algorithm %d is not chunked AES-256-GCM-STREAM", ErrUnsupportedBodyAlg, alg)
330-
}
331-
332352
match, err := matchRecipient(env.Recipients, unwrap.keyID())
333353
if err != nil {
334354
return nil, err
335355
}
336-
337356
cek, err := unwrap.unwrap(match)
338357
if err != nil {
339358
return nil, err
340359
}
360+
// The recovered CEK is ours; wipe it once openStream has copied it into the
361+
// body cipher (synchronously, before it returns).
341362
defer zero(cek)
342363

364+
return openStream(env, ciphertext, cek)
365+
}
366+
367+
// DecryptWithCEK is [Decrypt] with a caller-provided content-encryption key
368+
// instead of one recovered from an in-envelope recipient — for when the CEK was
369+
// obtained out of band (e.g. unwrapped by a custody service). The envelope's
370+
// recipients are ignored. cek must be 32 bytes (AES-256).
371+
//
372+
// The caller retains ownership of cek: it is copied into the body cipher but
373+
// neither retained nor wiped by this call.
374+
func DecryptWithCEK(src io.Reader, cek []byte) (io.Reader, error) {
375+
if src == nil {
376+
return nil, errors.New("fee: nil envelope reader")
377+
}
378+
if len(cek) != aesstream.KeySize {
379+
return nil, fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek))
380+
}
381+
env, ciphertext, err := cose.DecodeReader(src, cose.WithExpectedType(EnvelopeType))
382+
if err != nil {
383+
return nil, fmt.Errorf("fee: decoding envelope: %w", err)
384+
}
385+
return openStream(env, ciphertext, cek)
386+
}
387+
388+
// openStream is the shared core of Decrypt and DecryptWithCEK: given a decoded
389+
// envelope, its detached ciphertext stream, and the content-encryption key, it
390+
// validates the body parameters and returns the streaming plaintext reader. It
391+
// copies cek into the body cipher and does not retain it.
392+
func openStream(env *cose.Encrypt, ciphertext io.Reader, cek []byte) (io.Reader, error) {
393+
alg, ok := env.Headers.Protected.Int(cose.HeaderLabelAlg)
394+
if !ok {
395+
return nil, fmt.Errorf("%w: body algorithm header missing or not an integer", ErrUnsupportedBodyAlg)
396+
}
397+
if alg != algChunkedAES256GCMStream {
398+
return nil, fmt.Errorf("%w: body algorithm %d is not chunked AES-256-GCM-STREAM", ErrUnsupportedBodyAlg, alg)
399+
}
400+
343401
baseNonce, ok := env.Headers.Unprotected.Bytes(cose.HeaderLabelIV)
344402
if !ok {
345403
return nil, fmt.Errorf("%w: missing iv (base nonce)", ErrMalformedEnvelope)

0 commit comments

Comments
 (0)