-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfee.go
More file actions
583 lines (536 loc) · 25.6 KB
/
Copy pathfee.go
File metadata and controls
583 lines (536 loc) · 25.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
// Package fee composes the FEE (Filecoin Encryption Envelope) primitives
// into a single, small public API for encrypting and decrypting whole objects.
//
// The cryptographic building blocks each live in a sub-package and are
// deliberately unaware of one another:
//
// - fee/cose — the COSE_Encrypt (tag 96) / COSE_Encrypt0 (tag 16)
// envelope with a detached payload, and the Enc_structure that
// authenticates the protected header as AEAD additional data (AAD).
// - fee/aesstream — the chunked AES-256-GCM-STREAM body cipher: it seals the
// plaintext under a per-object content-encryption key (CEK) and a random
// base nonce.
// - fee/ecdhkw — the ECDH-ES+A256KW key wrap over X25519: it encrypts the
// CEK to a recipient's X25519 public key.
// - fee/aeskw — RFC 3394 AES Key Wrap (A256KW): it wraps the CEK directly
// under a symmetric key-encryption key (KEK).
//
// This package sequences them so callers do not have to. [Encrypt] generates a
// fresh CEK, seals the plaintext with the STREAM body cipher, wraps the CEK to
// each [Recipient], and encodes the COSE envelope; [Decrypt] reverses the
// process, locating the recipient that a [RecipientUnwrapper] holds the key for,
// recovering the CEK, and streaming out the plaintext.
//
// # Recipients and content-encryption keys
//
// The body is sealed under a single content-encryption key (CEK). Two concerns
// are independent: which algorithm wraps the CEK, and how the CEK reaches the
// decryptor.
//
// A CEK can be carried in the envelope as one or more COSE_Recipient entries
// (a COSE_Encrypt, tag 96), each keyed by a caller-supplied key id (kid —
// opaque to this package, e.g. a DID verification method ID). Two wrap
// algorithms are available and may be mixed in one envelope; on decrypt the
// caller never selects the algorithm, the recipient's COSE header does:
//
// - ECDH-ES+A256KW to an X25519 public key: [NewECDHESRecipient] /
// [NewECDHESUnwrapper].
// - A256KW under a symmetric KEK: [NewA256KWRecipient] / [NewA256KWUnwrapper].
//
// Alternatively the CEK can be managed out of band — generated or unwrapped by a
// custody service and handed to this package directly. [EncryptWithCEK] seals
// under a caller-provided CEK, and with no recipients it emits a recipient-less
// COSE_Encrypt0 (tag 16); [DecryptWithCEK] decrypts with a caller-provided CEK,
// accepting either tag and ignoring any recipients.
//
// # Wire format
//
// The blob is the detached-payload convention: the encoded COSE envelope
// immediately followed by the STREAM ciphertext (envelope || ciphertext). The
// body protected header pins the FEE envelope type ([EnvelopeType]) and the body
// algorithm (chunked AES-256-GCM-STREAM); the body unprotected header carries the
// STREAM base nonce in the COSE iv parameter, the plaintext chunk size, and —
// when the plaintext length is known — the chunk count. These conventions and
// their private-use label values match the foc-encryption reference and the FEE
// cross-implementation vectors (see fee/vectors).
//
// # Streaming
//
// Both directions stream with O(chunk size) memory. [Encrypt] returns an
// io.ReadCloser over envelope||ciphertext, produced as the plaintext is read;
// [Decrypt] reads only the (small) envelope header up front and streams the
// detached ciphertext from its source on demand. Neither buffers the whole
// object.
//
// # Byte ranges
//
// [DecryptRange] serves one plaintext byte range of a stored blob without
// fetching or decrypting the rest of it: it decodes the envelope header, recovers
// the CEK exactly as [Decrypt] does, and reads only the ciphertext chunks the
// range overlaps. [DecryptRangeWithCEK] is its external-CEK counterpart, and
// [PlaintextSize] answers an object's decrypted size from the header alone, with
// no key material. Callers that hold raw ciphertext spans rather than a blob can
// use the underlying primitives in fee/aesstream directly.
//
// # Scope
//
// This package sequences the primitives and adds no cryptography of its own.
package fee
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
"io"
"github.com/filecoin-project/go-fee/aesstream"
"github.com/filecoin-project/go-fee/cose"
)
// EnvelopeType is the COSE "typ" (RFC 9596, header label 16) that every FEE
// envelope is pinned to. [Encrypt] writes it into the body protected header and
// [Decrypt] requires it on decode, so a blob that is not a FEE envelope is
// rejected before any key material is touched. Its value matches the
// foc-encryption reference.
const EnvelopeType = "application/vnd.foc-envelope+cose"
// algChunkedAES256GCMStream is the FEE private-use COSE algorithm id for the
// chunked AES-256-GCM-STREAM body cipher (fee/aesstream). It is the body
// protected header's alg value; [Decrypt] checks it so an envelope sealed with a
// different body cipher is refused rather than mis-decrypted.
const algChunkedAES256GCMStream int64 = -65793
// FEE private-use COSE header labels carried in the body unprotected header,
// matching the foc-encryption reference (src/cose/headers.ts).
const (
// labelChunkSize holds the STREAM plaintext chunk size, in bytes.
labelChunkSize int64 = -65790
// labelChunkCount holds the number of STREAM chunks. It is emitted only when
// the plaintext length is known ([WithContentLength]); it is advisory
// metadata for range/seek consumers and is not required to decrypt.
labelChunkCount int64 = -65791
)
// Sentinel errors. Decrypt failures that originate in a sub-package are wrapped
// rather than replaced, so errors.Is still matches the sub-package sentinel
// (e.g. cose.ErrMalformed, aeskw.ErrIntegrity, aesstream.ErrCorrupted) in
// addition to the fee-level classification here.
var (
// ErrNoRecipients means Encrypt was called with no recipients. Encrypt
// wraps a freshly generated CEK, so it needs at least one recipient to be
// recoverable; use EncryptWithCEK for a recipient-less (external-CEK) envelope.
ErrNoRecipients = errors.New("fee: at least one recipient is required")
// ErrNoMatchingRecipient means no recipient entry in the envelope carries a
// kid equal to the unwrapper's key id, so there is no wrapped CEK for this
// unwrapper to recover.
ErrNoMatchingRecipient = errors.New("fee: no envelope recipient matches the unwrapper's key id")
// ErrNoRecipientsInEnvelope means Decrypt was given a recipient-less
// COSE_Encrypt0 (tag 16) envelope; recover it with DecryptWithCEK instead.
ErrNoRecipientsInEnvelope = errors.New("fee: envelope carries no recipients; use DecryptWithCEK")
// ErrUnsupportedBodyAlg means the envelope's body algorithm header is absent
// or is not the FEE chunked AES-256-GCM-STREAM cipher.
ErrUnsupportedBodyAlg = errors.New("fee: unsupported body algorithm")
// ErrUnsupportedRecipientAlg means a matched recipient's key-wrap algorithm
// header does not match the unwrapper that was asked to recover it (e.g. an
// ECDH-ES unwrapper matched against an A256KW recipient).
ErrUnsupportedRecipientAlg = errors.New("fee: unsupported recipient key-wrap algorithm")
// ErrMalformedEnvelope means a required body header was missing or had the
// wrong type, or a declared parameter was out of range.
ErrMalformedEnvelope = errors.New("fee: malformed FEE envelope")
// ErrNilUnwrapper means Decrypt was given a nil RecipientUnwrapper.
ErrNilUnwrapper = errors.New("fee: nil recipient unwrapper")
// ErrInvalidCEK means a caller-provided content-encryption key (see
// EncryptWithCEK / DecryptWithCEK) was not the required AES-256 key length.
ErrInvalidCEK = errors.New("fee: content-encryption key must be 32 bytes")
// ErrContentLengthMismatch means the plaintext length declared via
// WithContentLength did not match the number of bytes actually read; it
// surfaces from the returned reader, and the envelope's chunk count (already
// written) is not to be trusted.
ErrContentLengthMismatch = errors.New("fee: plaintext length did not match the declared content length")
)
// encryptConfig holds the resolved, optional Encrypt parameters.
type encryptConfig struct {
chunkSize int
contentLength int64 // < 0 means unknown (chunk count omitted)
}
// EncryptOption configures [Encrypt] and [EncryptWithCEK].
type EncryptOption func(*encryptConfig)
// WithChunkSize sets the STREAM plaintext chunk size, in bytes. A value of 0
// (or an unset option) selects [aesstream.DefaultChunkSize] (256 KiB); any other
// value must be in [aesstream.MinChunkSize, aesstream.MaxChunkSize]. The chosen
// size is recorded in the envelope, so [Decrypt] recovers it.
func WithChunkSize(n int) EncryptOption {
return func(c *encryptConfig) { c.chunkSize = n }
}
// WithContentLength declares the total plaintext length in bytes. When set to a
// non-negative value, the envelope records the chunk count (advisory metadata
// that lets a range/seek consumer plan fetches from the header alone), and the
// returned reader fails with [ErrContentLengthMismatch] if the plaintext turns
// out to be a different length.
//
// A negative n is treated as "unknown", identical to not calling this option: a
// caller propagating an unknown HTTP Content-Length as -1 gets the unset
// behavior (no recorded chunk count, no mismatch check) rather than an error.
// When unknown, the chunk count is omitted — the object still decrypts, and
// range decryption derives the geometry from the ciphertext length instead.
func WithContentLength(n int64) EncryptOption {
return func(c *encryptConfig) { c.contentLength = n }
}
// Encrypt seals plaintext into a FEE envelope (a COSE_Encrypt, tag 96) addressed
// to recipients and returns a reader over the wire blob: the encoded envelope
// immediately followed by the detached STREAM ciphertext (envelope||ciphertext).
//
// It generates a fresh content-encryption key (CEK) and base nonce, wraps the
// CEK to each recipient, and streams the plaintext through the chunked
// AES-256-GCM-STREAM body cipher, so both plaintext and ciphertext flow with
// O(chunk size) memory. The same CEK is wrapped to every recipient, so any one
// of them can recover the object. recipients must be non-empty (the generated
// CEK would otherwise be unrecoverable); a nil or invalid recipient is reported
// before any plaintext is read. To seal under a CEK you already hold, or a
// recipient-less envelope, use [EncryptWithCEK].
//
// Encryption runs in a background goroutine that feeds the returned reader, so a
// caller MUST either read it to EOF or Close it: Close aborts the goroutine. An
// encryption failure surfaces as a non-EOF error from the reader's Read.
func Encrypt(plaintext io.Reader, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, error) {
if len(recipients) == 0 {
return nil, ErrNoRecipients
}
cek := make([]byte, aesstream.KeySize)
if _, err := rand.Read(cek); err != nil {
return nil, fmt.Errorf("fee: generating content-encryption key: %w", err)
}
// encryptStream copies the CEK into the body cipher and wraps it to the
// recipients (synchronously, before it returns), so our generated copy can be
// wiped once it returns — on every path.
defer zero(cek)
return encryptStream(plaintext, cek, recipients, opts...)
}
// EncryptWithCEK is [Encrypt] with a caller-provided content-encryption key
// instead of a freshly generated one — for when the CEK is managed out of band
// (derived deterministically, or issued by a custody service). cek must be 32
// bytes (AES-256).
//
// With one or more recipients it produces a COSE_Encrypt (tag 96) that also
// carries the wrapped CEK; with no recipients it produces a recipient-less
// COSE_Encrypt0 (tag 16). Pair it with [DecryptWithCEK] to recover without an
// in-envelope unwrap.
//
// The caller MUST use a distinct cek per envelope (or keep reuse far below the
// birthday bound below). Unlike [Encrypt], which draws a fresh CEK each call,
// this seals under a caller-supplied key — so the only cross-envelope nonce
// separation is the random base nonce, which is [aesstream.BaseNonceSize] (7)
// bytes. Under a reused CEK, two envelopes drawing the same base nonce reuse an
// AES-GCM (key, nonce) pair, which is catastrophic (keystream reuse + tag
// forgery). A 7-byte random nonce collides at a ~2^28-envelope birthday bound,
// so sealing on the order of 2^30 objects under one CEK makes a collision
// essentially certain. The wire format is fixed by the FEE spec, so this is a
// caller obligation, not something this package can enforce.
//
// The caller retains ownership of cek: it is copied into the body cipher (and
// wrapped to any recipients) but neither retained nor wiped by this call.
func EncryptWithCEK(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, error) {
if len(cek) != aesstream.KeySize {
return nil, fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek))
}
return encryptStream(plaintext, cek, recipients, opts...)
}
// encryptStream is the shared core of Encrypt and EncryptWithCEK: it seals
// plaintext under cek and returns a streaming reader over envelope||ciphertext.
// With no recipients it emits a COSE_Encrypt0 (tag 16); otherwise a COSE_Encrypt
// (tag 96). It does not modify or wipe cek.
//
// It also does not retain cek past its own return: the recipient wraps and the
// aesstream.NewWriter that internalizes the CEK (into a GCM AEAD) both run
// synchronously before encryptStream returns, so a caller may wipe cek as soon
// as it returns — even though the returned reader has not been read and its
// background encryption goroutine is still running. That goroutine works from
// the writer's internalized key, never from the cek slice.
func encryptStream(plaintext io.Reader, cek []byte, recipients []Recipient, opts ...EncryptOption) (io.ReadCloser, error) {
if plaintext == nil {
return nil, errors.New("fee: nil plaintext reader")
}
for i, r := range recipients {
if r == nil {
return nil, fmt.Errorf("fee: recipient %d is nil", i)
}
if err := r.validate(); err != nil {
return nil, fmt.Errorf("fee: recipient %d: %w", i, err)
}
}
cfg := encryptConfig{chunkSize: aesstream.DefaultChunkSize, contentLength: -1}
for _, o := range opts {
o(&cfg)
}
if cfg.chunkSize == 0 {
cfg.chunkSize = aesstream.DefaultChunkSize
}
if cfg.chunkSize < aesstream.MinChunkSize || cfg.chunkSize > aesstream.MaxChunkSize {
// An out-of-range WithChunkSize is an invalid argument on the encrypt
// path — no envelope exists yet — so it is not ErrMalformedEnvelope (a
// decode-side classification). Surface aesstream.ErrChunkSize, the same
// sentinel aesstream.NewWriter would return for this size.
return nil, fmt.Errorf("fee: chunk size %d: %w", cfg.chunkSize, aesstream.ErrChunkSize)
}
baseNonce, err := aesstream.NewBaseNonce()
if err != nil {
return nil, fmt.Errorf("fee: generating base nonce: %w", err)
}
// The body header is fixed before encryption: the algorithm and envelope
// type are protected (authenticated through the Enc_structure AAD); the base
// nonce, chunk size and (when the length is known) chunk count ride in the
// unprotected header.
unprotected := cose.Header{}.
Set(cose.HeaderLabelIV, baseNonce).
Set(labelChunkSize, int64(cfg.chunkSize))
if cfg.contentLength >= 0 {
unprotected.Set(labelChunkCount, int64(chunkCountFor(cfg.contentLength, int64(cfg.chunkSize))))
}
headers := cose.Headers{
Protected: cose.Header{}.
Set(cose.HeaderLabelAlg, algChunkedAES256GCMStream).
Set(cose.HeaderLabelType, EnvelopeType),
Unprotected: unprotected,
}
// Wrap the CEK to each recipient (none → a recipient-less envelope). All the
// fallible header work happens here, before the pipe is created, so an error
// returns before the pipe exists and can never orphan a PipeReader/PipeWriter
// pair.
entries := make([]*cose.Recipient, len(recipients))
for i, r := range recipients {
entry, werr := r.wrap(cek)
if werr != nil {
return nil, werr
}
entries[i] = entry
}
// One envelope drives both the AAD and the encoded header. Recipient presence
// alone selects the form — a COSE_Encrypt (tag 96) when present, a
// recipient-less COSE_Encrypt0 (tag 16) when not — and the Enc_structure
// context tracks it. The AAD binds the protected header into every STREAM
// chunk.
env := &cose.Envelope{Headers: headers, Recipients: entries}
aad, err := env.EncStructure(nil)
if err != nil {
return nil, fmt.Errorf("fee: building envelope AAD: %w", err)
}
header, err := env.Encode()
if err != nil {
return nil, fmt.Errorf("fee: encoding envelope: %w", err)
}
// The body cipher streams into a pipe that the returned reader drains. Create
// it only now that every fallible step above has succeeded; the one remaining
// fallible call (NewWriter) closes both ends on error, so no pipe is left
// dangling on any error path.
pr, pw := io.Pipe()
w, err := aesstream.NewWriter(pw, aesstream.Config{
Key: cek,
BaseNonce: baseNonce,
AAD: aad,
ChunkSize: cfg.chunkSize,
})
if err != nil {
_ = pw.Close()
_ = pr.Close()
return nil, fmt.Errorf("fee: initializing body cipher: %w", err)
}
declaredLen := cfg.contentLength
go func() {
n, cerr := io.Copy(w, plaintext)
if cerr == nil && declaredLen >= 0 && n != declaredLen {
cerr = fmt.Errorf("%w: declared %d, got %d", ErrContentLengthMismatch, declaredLen, n)
}
// Emit the final chunk only on a clean, length-matched copy. On a
// mismatch we deliberately skip w.Close(), so the stream ends without its
// last-flag chunk: a caller that ignores the error and stores the blob
// anyway gets a truncated ciphertext that fails to decrypt
// (aesstream.ErrTruncated), rather than a valid-but-mislabeled object.
if cerr == nil {
cerr = w.Close()
}
// A nil error closes the pipe with io.EOF (clean end); otherwise the
// error surfaces from the reader's Read.
_ = pw.CloseWithError(cerr)
}()
return &encryptReader{
body: io.MultiReader(bytes.NewReader(header), pr),
pr: pr,
}, nil
}
// chunkCountFor reports how many STREAM chunks a plaintext of nPlain bytes
// produces at the given chunk size. Empty input is one (empty) final chunk.
func chunkCountFor(nPlain, chunkSize int64) int64 {
if nPlain <= 0 {
return 1
}
return (nPlain + chunkSize - 1) / chunkSize
}
// encryptReader is the io.ReadCloser returned by [Encrypt] / [EncryptWithCEK].
// Read serves the envelope header and then the streamed ciphertext; Close aborts
// the background encryption goroutine by closing the pipe, so it is safe to
// abandon a partial read.
type encryptReader struct {
body io.Reader // io.MultiReader(header, pipe reader)
pr *io.PipeReader // closing it stops the encryption goroutine
}
func (e *encryptReader) Read(p []byte) (int, error) { return e.body.Read(p) }
func (e *encryptReader) Close() error { return e.pr.Close() }
// Decrypt recovers the plaintext from a FEE COSE_Encrypt (tag 96) envelope read
// from src.
//
// It decodes the envelope header from src (requiring the FEE typ), finds the
// recipient whose kid matches unwrap's key id, recovers the CEK through unwrap,
// and returns a streaming reader over the decrypted plaintext. Only the header
// is read up front; the detached ciphertext is streamed from src on demand.
//
// Decryption is streaming: a non-EOF error from the returned reader (see
// fee/aesstream) means the plaintext is incomplete and must be discarded.
//
// If the envelope carries no recipients (a COSE_Encrypt0), Decrypt returns
// [ErrNoRecipientsInEnvelope] — use [DecryptWithCEK]. If no recipient kid matches
// unwrap, it returns [ErrNoMatchingRecipient] without attempting an unwrap. If
// the matched recipient's wrapped CEK cannot be recovered (e.g. the wrong key),
// the unwrap error is returned and no plaintext reader is produced.
func Decrypt(src io.Reader, unwrap RecipientUnwrapper) (io.Reader, error) {
if src == nil {
return nil, errors.New("fee: nil envelope reader")
}
if unwrap == nil {
return nil, ErrNilUnwrapper
}
env, ciphertext, err := cose.DecodeReader(src, cose.WithExpectedType(EnvelopeType))
if err != nil {
return nil, fmt.Errorf("fee: decoding envelope: %w", err)
}
if len(env.Recipients) == 0 {
return nil, ErrNoRecipientsInEnvelope
}
match, err := matchRecipient(env.Recipients, unwrap.keyID())
if err != nil {
return nil, err
}
cek, err := unwrap.unwrap(match)
if err != nil {
return nil, err
}
// The recovered CEK is ours; wipe it once openStream has copied it into the
// body cipher (synchronously, before it returns).
defer zero(cek)
return openStream(env, ciphertext, cek)
}
// DecryptWithCEK is [Decrypt] with a caller-provided content-encryption key
// instead of one recovered from an in-envelope recipient — for when the CEK was
// obtained out of band (e.g. unwrapped by a custody service). It accepts either
// a COSE_Encrypt (tag 96) or a recipient-less COSE_Encrypt0 (tag 16); any
// recipients are ignored. cek must be 32 bytes (AES-256).
//
// The caller retains ownership of cek: it is copied into the body cipher but
// neither retained nor wiped by this call.
func DecryptWithCEK(src io.Reader, cek []byte) (io.Reader, error) {
if src == nil {
return nil, errors.New("fee: nil envelope reader")
}
if len(cek) != aesstream.KeySize {
return nil, fmt.Errorf("%w, got %d", ErrInvalidCEK, len(cek))
}
env, ciphertext, err := cose.DecodeReader(src, cose.WithExpectedType(EnvelopeType))
if err != nil {
return nil, fmt.Errorf("fee: decoding envelope: %w", err)
}
return openStream(env, ciphertext, cek)
}
// openStream is the shared core of Decrypt and DecryptWithCEK: given a decoded
// envelope, its detached ciphertext stream, and the content-encryption key, it
// validates the body parameters and returns the streaming plaintext reader.
//
// It does not retain cek past its own return: aesstream.NewReader internalizes
// the CEK (into a GCM AEAD) synchronously before openStream returns, so a caller
// may wipe cek as soon as it returns — even though the returned reader decrypts
// lazily on later reads, which work from the internalized key, never the cek
// slice.
func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader, error) {
body, err := validateBody(env)
if err != nil {
return nil, err
}
r, err := aesstream.NewReader(ciphertext, aesstream.Config{
Key: cek,
BaseNonce: body.baseNonce,
AAD: body.aad,
ChunkSize: body.chunkSize,
})
if err != nil {
return nil, fmt.Errorf("fee: initializing body cipher: %w", err)
}
return r, nil
}
// bodyParams is the validated STREAM configuration a FEE envelope's body header
// carries: everything fee/aesstream needs to decrypt the detached ciphertext
// apart from the content-encryption key.
type bodyParams struct {
baseNonce []byte
chunkSize int
aad []byte
}
// validateBody checks a decoded envelope's FEE body headers — the algorithm is
// the chunked AES-256-GCM-STREAM cipher, the base nonce (iv) is present, and the
// self-describing chunk size is in range — and rebuilds the Enc_structure AAD
// that the encoder bound into every chunk.
//
// It is shared by the whole-object path ([openStream]) and the range path
// ([newRangeReader]), so both accept exactly the same envelopes and report the
// same errors for a body header they cannot honour.
func validateBody(env *cose.Envelope) (bodyParams, error) {
alg, ok := env.Headers.Protected.Int(cose.HeaderLabelAlg)
if !ok {
return bodyParams{}, fmt.Errorf("%w: body algorithm header missing or not an integer", ErrUnsupportedBodyAlg)
}
if alg != algChunkedAES256GCMStream {
return bodyParams{}, fmt.Errorf("%w: body algorithm %d is not chunked AES-256-GCM-STREAM", ErrUnsupportedBodyAlg, alg)
}
baseNonce, ok := env.Headers.Unprotected.Bytes(cose.HeaderLabelIV)
if !ok {
return bodyParams{}, fmt.Errorf("%w: missing iv (base nonce)", ErrMalformedEnvelope)
}
// Self-describing chunk size; an envelope that omits it is read at the FEE
// default, but a header present with a non-integer value is a malformed
// envelope rather than a silent fallback. (The chunk count, when present, is
// advisory and not needed to decrypt.)
chunkSize := int64(aesstream.DefaultChunkSize)
if env.Headers.Unprotected.Has(labelChunkSize) {
n, ok := env.Headers.Unprotected.Int(labelChunkSize)
if !ok {
return bodyParams{}, fmt.Errorf("%w: chunk-size header is present but not an integer", ErrMalformedEnvelope)
}
chunkSize = n
}
if chunkSize < int64(aesstream.MinChunkSize) || chunkSize > int64(aesstream.MaxChunkSize) {
return bodyParams{}, fmt.Errorf("%w: declared chunk size %d out of range [%d, %d]",
ErrMalformedEnvelope, chunkSize, aesstream.MinChunkSize, aesstream.MaxChunkSize)
}
// The decrypt-side AAD is rebuilt from the decoded envelope, using the
// Enc_structure context that matches its tag — byte-identical to the value
// the encoder bound into every chunk.
aad, err := env.EncStructure(nil)
if err != nil {
return bodyParams{}, fmt.Errorf("fee: building envelope AAD: %w", err)
}
return bodyParams{baseNonce: baseNonce, chunkSize: int(chunkSize), aad: aad}, nil
}
// matchRecipient returns the first recipient whose kid equals want. A recipient
// without a kid header never matches. An empty want, or no match, yields
// ErrNoMatchingRecipient.
func matchRecipient(recipients []*cose.Recipient, want []byte) (*cose.Recipient, error) {
if len(want) == 0 {
return nil, ErrNoMatchingRecipient
}
for _, r := range recipients {
if kid, ok := r.Headers.Unprotected.Bytes(cose.HeaderLabelKID); ok && bytes.Equal(kid, want) {
return r, nil
}
}
return nil, ErrNoMatchingRecipient
}
// zero overwrites b, a best-effort wipe of the CEK once it has been copied into
// the body cipher (and, on encrypt, wrapped to every recipient). It wraps the
// built-in clear so callers can defer it (a bare defer clear(b) is not allowed);
// like the loop it replaced, it is best-effort — Go does not guarantee the write
// survives dead-store elimination.
func zero(b []byte) {
clear(b)
}