Skip to content

Commit 82aac4f

Browse files
claudebajtos
authored andcommitted
feat: range decryption API on top of the fee package
Adds DecryptRange / DecryptRangeWithCEK to the root fee package, so a caller can decrypt one plaintext byte range of a stored FEE blob without fetching or decrypting the whole object — and without reaching into fee/aesstream or the COSE/wrap sub-packages to do it. The range path reuses the envelope-decode and recipient-unwrap logic that Decrypt uses (matchRecipient, RecipientUnwrapper.unwrap), which is why it lives inside the fee package rather than a sub-package, and adds only the range piece: locating the chunk-aligned ciphertext span and handing it to aesstream's range primitive instead of the whole stream. - RangeReader exposes Len (the clamped range length) and Size (the whole object's plaintext size), so an HTTP consumer can fill in Content-Length and Content-Range before any ciphertext is read, plus CiphertextSpan so a remote-backed caller can prefetch the span in a single range request. - PlaintextSize answers an object's decrypted size from the envelope header alone, with no key material — for HEAD responses and suffix ranges. - The envelope header is located by probing a small prefix with cose.Decode, which reports its exact encoded length; the probe grows only for unusually large envelopes and is bounded at 1 MiB. - When the envelope records a chunk count, it is cross-checked against the geometry the blob size implies (ErrSizeMismatch), catching a stale or wrong size before any plaintext is served. The count is unprotected metadata, so this is an operational check, not an integrity guarantee — documented as such. openStream's body-header validation is extracted into a shared validateBody helper so the whole-object and range paths accept exactly the same envelopes and report the same errors. No behavior change on the existing path. Every chunk a range overlaps is authenticated, so a tampered chunk fails rather than yielding corrupt plaintext; chunks outside the range are never fetched and so never checked, which the docs spell out along with the trust placed in the supplied blob size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CF6S9hmvMkpTkDyRvYopeN
1 parent 7feb6d1 commit 82aac4f

5 files changed

Lines changed: 1231 additions & 74 deletions

File tree

README.md

Lines changed: 44 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ import (
4646

4747
| Package | Purpose |
4848
|---|---|
49-
| [`fee`](.) (root) | Composes the primitives below into a small encrypt/decrypt API for whole objects. Adds no cryptography of its own. |
50-
| [`aesstream`](./aesstream) | The chunked AES-256-GCM STREAM body cipher: streaming `Writer`/`Reader` plus the range-decryption API (`CiphertextRange`, `SpanReader`, `OpenSpan`). |
49+
| [`fee`](.) (root) | Composes the primitives below into a small API: whole-object `Encrypt`/`Decrypt` plus byte-range `DecryptRange`. Adds no cryptography of its own. |
50+
| [`aesstream`](./aesstream) | The chunked AES-256-GCM STREAM body cipher: streaming `Writer`/`Reader` plus the range primitives (`CiphertextRange`, `SpanReader`, `OpenSpan`) that `fee.DecryptRange` is built on. |
5151
| [`cose`](./cose) | Just enough of COSE (RFC 9052): `COSE_Encrypt` (tag 96) / `COSE_Encrypt0` (tag 16) with a detached payload, and the `Enc_structure` AAD. |
5252
| [`ecdhkw`](./ecdhkw) | ECDH-ES+A256KW key wrap over X25519 (COSE algorithm −31). |
5353
| [`aeskw`](./aeskw) | RFC 3394 AES Key Wrap / A256KW (COSE algorithm −5). |
@@ -254,73 +254,63 @@ func roundTripExternalCEK(data []byte) ([]byte, error) {
254254
### Range (seekable) decryption
255255

256256
Because chunks are sealed independently, any plaintext byte range can be
257-
decrypted from a single contiguous slice of the ciphertext — one HTTP range
258-
request against a remote blob. The root `fee` package covers whole-object
259-
decryption only; ranges use the `cose` and `aesstream` packages directly:
257+
decrypted without fetching or decrypting the rest of the object.
258+
`fee.DecryptRange` takes the stored blob as an `io.ReaderAt` plus its exact size,
259+
unwraps the CEK just as `fee.Decrypt` does, and returns a reader over exactly the
260+
requested bytes:
260261

261262
```go
262263
import (
263-
"bytes"
264-
"errors"
264+
"fmt"
265+
"io"
266+
"net/http"
267+
"os"
268+
"strconv"
265269

266270
fee "github.com/filecoin-project/go-fee"
267-
"github.com/filecoin-project/go-fee/aesstream"
268-
"github.com/filecoin-project/go-fee/cose"
269271
)
270272

271-
// readRange decrypts plaintext[off : off+length] from a FEE blob without
272-
// decrypting the whole object. cek is the content-encryption key, obtained out
273-
// of band or unwrapped from a recipient entry (see ecdhkw / aeskw).
274-
func readRange(blob, cek []byte, off, length int64) ([]byte, error) {
275-
const labelChunkSize = int64(-65790)
276-
277-
// Decode the envelope header: base nonce, chunk size, and the
278-
// Enc_structure that every chunk is authenticated against.
279-
env, ciphertext, err := cose.Decode(blob, cose.WithExpectedType(fee.EnvelopeType))
280-
if err != nil {
281-
return nil, err
282-
}
283-
baseNonce, ok := env.Headers.Unprotected.Bytes(cose.HeaderLabelIV)
284-
if !ok {
285-
return nil, errors.New("missing base nonce")
286-
}
287-
chunkSize := aesstream.DefaultChunkSize
288-
if n, ok := env.Headers.Unprotected.Int(labelChunkSize); ok {
289-
chunkSize = int(n)
290-
}
291-
aad, err := env.EncStructure(nil)
273+
// serveRange answers an HTTP range request straight from an encrypted object.
274+
func serveRange(w http.ResponseWriter, f *os.File, size int64, u fee.RecipientUnwrapper, off, length int64) error {
275+
r, err := fee.DecryptRange(f, size, u, off, length)
292276
if err != nil {
293-
return nil, err
277+
return err // aesstream.ErrRange here means a 416
294278
}
295279

296-
// Which contiguous ciphertext bytes cover the requested plaintext range?
297-
// The third result (ignored here) is the clamped plaintext length of the
298-
// range — available before any fetch, e.g. for an HTTP Content-Length.
299-
start, n, _, err := aesstream.CiphertextRange(int64(len(ciphertext)), chunkSize, off, length)
300-
if err != nil {
301-
return nil, err
302-
}
280+
// Len is the requested length clamped to the object; Size is the whole
281+
// object's plaintext size. Both are known before any ciphertext is read.
282+
w.Header().Set("Content-Length", strconv.FormatInt(r.Len(), 10))
283+
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", off, off+r.Len()-1, r.Size()))
284+
w.WriteHeader(http.StatusPartialContent)
303285

304-
// Fetch exactly that span. Against a remote blob this is one range
305-
// request for bytes [headerLen+start, headerLen+start+n), where
306-
// headerLen = len(blob) - len(ciphertext).
307-
span := bytes.NewReader(ciphertext[start : start+n])
308-
309-
// Decrypt and trim to exactly the requested range. For large ranges,
310-
// aesstream.NewSpanReader streams instead of buffering.
311-
return aesstream.OpenSpan(aesstream.Config{
312-
Key: cek,
313-
BaseNonce: baseNonce,
314-
AAD: aad,
315-
ChunkSize: chunkSize,
316-
}, span, int64(len(ciphertext)), off, length)
286+
_, err = io.Copy(w, r) // decrypts chunk by chunk, O(chunk size) memory
287+
return err
317288
}
318289
```
319290

291+
Only the envelope header and the ciphertext chunks the range overlaps are read:
292+
one small `ReadAt` at offset 0 for the header, then one `ReadAt` per overlapping
293+
chunk as the result is read. Every chunk read is authenticated, so a tampered
294+
chunk fails rather than yielding corrupt plaintext.
295+
296+
Nothing beyond the header is fetched until the first `Read`, so a caller backed
297+
by a remote store can prefetch the whole span in a single range request:
298+
299+
```go
300+
r, err := fee.DecryptRange(blob, size, unwrapper, off, length)
301+
// ...
302+
spanOff, spanLen := r.CiphertextSpan() // blob-absolute; one range request
303+
```
304+
320305
The span is chunk-aligned, so it over-fetches by at most the unused head of the
321-
first chunk and tail of the last (under 2 × chunk size total). For a local
322-
random-access source, wrap it with `io.NewSectionReader(src, start, n)` instead
323-
of a fetch.
306+
first chunk and tail of the last (under 2 × chunk size total).
307+
308+
`fee.PlaintextSize` reports an object's decrypted size from the envelope header
309+
alone — no key material, no ciphertext — which is what a `HEAD` response or a
310+
suffix range (`bytes=-N`) needs. `fee.DecryptRangeWithCEK` is the external-CEK
311+
counterpart of `DecryptRange`. Callers holding raw ciphertext spans rather than a
312+
whole blob can use `aesstream.CiphertextRange` / `SpanReader` / `OpenSpan`
313+
directly.
324314

325315
## Wire format
326316

example_range_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package fee_test
2+
3+
import (
4+
"bytes"
5+
"crypto/ecdh"
6+
"crypto/rand"
7+
"fmt"
8+
"io"
9+
"log"
10+
11+
"github.com/filecoin-project/go-fee"
12+
)
13+
14+
// ExampleDecryptRange serves one byte range of an encrypted object the way an
15+
// HTTP handler would: it derives the response's Content-Length and Content-Range
16+
// from the reader before any ciphertext is fetched, then streams the range out.
17+
func ExampleDecryptRange() {
18+
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
19+
if err != nil {
20+
log.Fatal(err)
21+
}
22+
kid := []byte("did:key:zExampleRecipient#key-1")
23+
24+
plaintext := []byte("the quick brown fox jumps over the lazy dog")
25+
enc, err := fee.Encrypt(bytes.NewReader(plaintext),
26+
[]fee.Recipient{fee.NewECDHESRecipient(kid, priv.PublicKey())},
27+
fee.WithContentLength(int64(len(plaintext))))
28+
if err != nil {
29+
log.Fatal(err)
30+
}
31+
blob, err := io.ReadAll(enc)
32+
if err != nil {
33+
log.Fatal(err)
34+
}
35+
if err := enc.Close(); err != nil {
36+
log.Fatal(err)
37+
}
38+
39+
// A store hands over random access to the blob and its exact size; only the
40+
// envelope header and the chunks the range overlaps are ever read.
41+
const off, length = 4, 15
42+
r, err := fee.DecryptRange(bytes.NewReader(blob), int64(len(blob)),
43+
fee.NewECDHESUnwrapper(kid, priv), off, length)
44+
if err != nil {
45+
log.Fatal(err)
46+
}
47+
48+
// Both are known up front, so a handler can write its headers before
49+
// decrypting a single chunk.
50+
fmt.Printf("Content-Length: %d\n", r.Len())
51+
fmt.Printf("Content-Range: bytes %d-%d/%d\n", off, off+r.Len()-1, r.Size())
52+
53+
got, err := io.ReadAll(r)
54+
if err != nil {
55+
log.Fatal(err)
56+
}
57+
fmt.Printf("range: %q\n", got)
58+
59+
// Output:
60+
// Content-Length: 15
61+
// Content-Range: bytes 4-18/43
62+
// range: "quick brown fox"
63+
}

fee.go

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,19 @@
6262
// detached ciphertext from its source on demand. Neither buffers the whole
6363
// object.
6464
//
65+
// # Byte ranges
66+
//
67+
// [DecryptRange] serves one plaintext byte range of a stored blob without
68+
// fetching or decrypting the rest of it: it decodes the envelope header, recovers
69+
// the CEK exactly as [Decrypt] does, and reads only the ciphertext chunks the
70+
// range overlaps. [DecryptRangeWithCEK] is its external-CEK counterpart, and
71+
// [PlaintextSize] answers an object's decrypted size from the header alone, with
72+
// no key material. Callers that hold raw ciphertext spans rather than a blob can
73+
// use the underlying primitives in fee/aesstream directly.
74+
//
6575
// # Scope
6676
//
67-
// This package covers full-object encrypt/decrypt only. Range-based decryption
68-
// is a separate primitive in fee/aesstream, keyed off the ciphertext length and
69-
// chunk size rather than the envelope's chunk count; a higher-level range API is
70-
// tracked separately. This package adds no cryptography of its own.
77+
// This package sequences the primitives and adds no cryptography of its own.
7178
package fee
7279

7380
import (
@@ -476,17 +483,51 @@ func DecryptWithCEK(src io.Reader, cek []byte) (io.Reader, error) {
476483
// lazily on later reads, which work from the internalized key, never the cek
477484
// slice.
478485
func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader, error) {
486+
body, err := validateBody(env)
487+
if err != nil {
488+
return nil, err
489+
}
490+
r, err := aesstream.NewReader(ciphertext, aesstream.Config{
491+
Key: cek,
492+
BaseNonce: body.baseNonce,
493+
AAD: body.aad,
494+
ChunkSize: body.chunkSize,
495+
})
496+
if err != nil {
497+
return nil, fmt.Errorf("fee: initializing body cipher: %w", err)
498+
}
499+
return r, nil
500+
}
501+
502+
// bodyParams is the validated STREAM configuration a FEE envelope's body header
503+
// carries: everything fee/aesstream needs to decrypt the detached ciphertext
504+
// apart from the content-encryption key.
505+
type bodyParams struct {
506+
baseNonce []byte
507+
chunkSize int
508+
aad []byte
509+
}
510+
511+
// validateBody checks a decoded envelope's FEE body headers — the algorithm is
512+
// the chunked AES-256-GCM-STREAM cipher, the base nonce (iv) is present, and the
513+
// self-describing chunk size is in range — and rebuilds the Enc_structure AAD
514+
// that the encoder bound into every chunk.
515+
//
516+
// It is shared by the whole-object path ([openStream]) and the range path
517+
// ([newRangeReader]), so both accept exactly the same envelopes and report the
518+
// same errors for a body header they cannot honour.
519+
func validateBody(env *cose.Envelope) (bodyParams, error) {
479520
alg, ok := env.Headers.Protected.Int(cose.HeaderLabelAlg)
480521
if !ok {
481-
return nil, fmt.Errorf("%w: body algorithm header missing or not an integer", ErrUnsupportedBodyAlg)
522+
return bodyParams{}, fmt.Errorf("%w: body algorithm header missing or not an integer", ErrUnsupportedBodyAlg)
482523
}
483524
if alg != algChunkedAES256GCMStream {
484-
return nil, fmt.Errorf("%w: body algorithm %d is not chunked AES-256-GCM-STREAM", ErrUnsupportedBodyAlg, alg)
525+
return bodyParams{}, fmt.Errorf("%w: body algorithm %d is not chunked AES-256-GCM-STREAM", ErrUnsupportedBodyAlg, alg)
485526
}
486527

487528
baseNonce, ok := env.Headers.Unprotected.Bytes(cose.HeaderLabelIV)
488529
if !ok {
489-
return nil, fmt.Errorf("%w: missing iv (base nonce)", ErrMalformedEnvelope)
530+
return bodyParams{}, fmt.Errorf("%w: missing iv (base nonce)", ErrMalformedEnvelope)
490531
}
491532

492533
// Self-describing chunk size; an envelope that omits it is read at the FEE
@@ -497,12 +538,12 @@ func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader
497538
if env.Headers.Unprotected.Has(labelChunkSize) {
498539
n, ok := env.Headers.Unprotected.Int(labelChunkSize)
499540
if !ok {
500-
return nil, fmt.Errorf("%w: chunk-size header is present but not an integer", ErrMalformedEnvelope)
541+
return bodyParams{}, fmt.Errorf("%w: chunk-size header is present but not an integer", ErrMalformedEnvelope)
501542
}
502543
chunkSize = n
503544
}
504545
if chunkSize < int64(aesstream.MinChunkSize) || chunkSize > int64(aesstream.MaxChunkSize) {
505-
return nil, fmt.Errorf("%w: declared chunk size %d out of range [%d, %d]",
546+
return bodyParams{}, fmt.Errorf("%w: declared chunk size %d out of range [%d, %d]",
506547
ErrMalformedEnvelope, chunkSize, aesstream.MinChunkSize, aesstream.MaxChunkSize)
507548
}
508549

@@ -511,19 +552,10 @@ func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader
511552
// the encoder bound into every chunk.
512553
aad, err := env.EncStructure(nil)
513554
if err != nil {
514-
return nil, fmt.Errorf("fee: building envelope AAD: %w", err)
555+
return bodyParams{}, fmt.Errorf("fee: building envelope AAD: %w", err)
515556
}
516557

517-
r, err := aesstream.NewReader(ciphertext, aesstream.Config{
518-
Key: cek,
519-
BaseNonce: baseNonce,
520-
AAD: aad,
521-
ChunkSize: int(chunkSize),
522-
})
523-
if err != nil {
524-
return nil, fmt.Errorf("fee: initializing body cipher: %w", err)
525-
}
526-
return r, nil
558+
return bodyParams{baseNonce: baseNonce, chunkSize: int(chunkSize), aad: aad}, nil
527559
}
528560

529561
// matchRecipient returns the first recipient whose kid equals want. A recipient

0 commit comments

Comments
 (0)