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].
165178func 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].
311339func 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