Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 48 additions & 53 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ import (

| Package | Purpose |
|---|---|
| [`fee`](.) (root) | Composes the primitives below into a small encrypt/decrypt API for whole objects. Adds no cryptography of its own. |
| [`aesstream`](./aesstream) | The chunked AES-256-GCM STREAM body cipher: streaming `Writer`/`Reader` plus the range-decryption API (`CiphertextRange`, `SpanReader`, `OpenSpan`). |
| [`fee`](.) (root) | Composes the primitives below into a small API: whole-object `Encrypt`/`Decrypt` plus byte-range `DecryptRange`. Adds no cryptography of its own. |
| [`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. |
| [`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. |
| [`ecdhkw`](./ecdhkw) | ECDH-ES+A256KW key wrap over X25519 (COSE algorithm −31). |
| [`aeskw`](./aeskw) | RFC 3394 AES Key Wrap / A256KW (COSE algorithm −5). |
Expand Down Expand Up @@ -254,73 +254,68 @@ func roundTripExternalCEK(data []byte) ([]byte, error) {
### Range (seekable) decryption

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

```go
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"

fee "github.com/filecoin-project/go-fee"
"github.com/filecoin-project/go-fee/aesstream"
"github.com/filecoin-project/go-fee/cose"
)

// readRange decrypts plaintext[off : off+length] from a FEE blob without
// decrypting the whole object. cek is the content-encryption key, obtained out
// of band or unwrapped from a recipient entry (see ecdhkw / aeskw).
func readRange(blob, cek []byte, off, length int64) ([]byte, error) {
const labelChunkSize = int64(-65790)

// Decode the envelope header: base nonce, chunk size, and the
// Enc_structure that every chunk is authenticated against.
env, ciphertext, err := cose.Decode(blob, cose.WithExpectedType(fee.EnvelopeType))
if err != nil {
return nil, err
}
baseNonce, ok := env.Headers.Unprotected.Bytes(cose.HeaderLabelIV)
if !ok {
return nil, errors.New("missing base nonce")
}
chunkSize := aesstream.DefaultChunkSize
if n, ok := env.Headers.Unprotected.Int(labelChunkSize); ok {
chunkSize = int(n)
}
aad, err := env.EncStructure(nil)
// serveRange answers an HTTP range request straight from an encrypted object.
func serveRange(w http.ResponseWriter, f *os.File, size int64, u fee.RecipientUnwrapper, off, length int64) error {
r, err := fee.DecryptRange(f, size, u, off, length)
if err != nil {
return nil, err
return err // aesstream.ErrRange here means a 416
}

// Which contiguous ciphertext bytes cover the requested plaintext range?
// The third result (ignored here) is the clamped plaintext length of the
// range — available before any fetch, e.g. for an HTTP Content-Length.
start, n, _, err := aesstream.CiphertextRange(int64(len(ciphertext)), chunkSize, off, length)
if err != nil {
return nil, err
// Len is the requested length clamped to the object; Size is the whole
// object's plaintext size. Both are known before any ciphertext is read.
if r.Len() == 0 {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", r.Size()))
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
return nil
}
w.Header().Set("Content-Length", strconv.FormatInt(r.Len(), 10))
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", off, off+r.Len()-1, r.Size()))
w.WriteHeader(http.StatusPartialContent)
Comment thread
Copilot marked this conversation as resolved.

// Fetch exactly that span. Against a remote blob this is one range
// request for bytes [headerLen+start, headerLen+start+n), where
// headerLen = len(blob) - len(ciphertext).
span := bytes.NewReader(ciphertext[start : start+n])

// Decrypt and trim to exactly the requested range. For large ranges,
// aesstream.NewSpanReader streams instead of buffering.
return aesstream.OpenSpan(aesstream.Config{
Key: cek,
BaseNonce: baseNonce,
AAD: aad,
ChunkSize: chunkSize,
}, span, int64(len(ciphertext)), off, length)
_, err = io.Copy(w, r) // decrypts chunk by chunk, O(chunk size) memory
return err
}
```

Only the envelope header and the ciphertext chunks the range overlaps are read:
one small `ReadAt` at offset 0 for the header, then one `ReadAt` per overlapping
chunk as the result is read. Every chunk read is authenticated, so a tampered
chunk fails rather than yielding corrupt plaintext.

Nothing beyond the header is fetched until the first `Read`, so a caller backed
by a remote store can prefetch the whole span in a single range request:

```go
r, err := fee.DecryptRange(blob, size, unwrapper, off, length)
// ...
spanOff, spanLen := r.CiphertextSpan() // blob-absolute; one range request
```

The span is chunk-aligned, so it over-fetches by at most the unused head of the
first chunk and tail of the last (under 2 × chunk size total). For a local
random-access source, wrap it with `io.NewSectionReader(src, start, n)` instead
of a fetch.
first chunk and tail of the last (under 2 × chunk size total).

`fee.PlaintextSize` reports an object's decrypted size from the envelope header
alone — no key material, no ciphertext — which is what a `HEAD` response or a
suffix range (`bytes=-N`) needs. `fee.DecryptRangeWithCEK` is the external-CEK
counterpart of `DecryptRange`. Callers holding raw ciphertext spans rather than a
whole blob can use `aesstream.CiphertextRange` / `SpanReader` / `OpenSpan`
directly.

## Wire format

Expand Down
66 changes: 66 additions & 0 deletions example_range_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package fee_test

import (
"bytes"
"crypto/ecdh"
"crypto/rand"
"fmt"
"io"
"log"

"github.com/filecoin-project/go-fee"
)

// ExampleDecryptRange serves one byte range of an encrypted object the way an
// HTTP handler would: it derives the response's Content-Length and Content-Range
// from the reader before any ciphertext is fetched, then streams the range out.
func ExampleDecryptRange() {
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
log.Fatal(err)
}
kid := []byte("did:key:zExampleRecipient#key-1")

plaintext := []byte("the quick brown fox jumps over the lazy dog")
enc, err := fee.Encrypt(bytes.NewReader(plaintext),
[]fee.Recipient{fee.NewECDHESRecipient(kid, priv.PublicKey())},
fee.WithContentLength(int64(len(plaintext))))
if err != nil {
log.Fatal(err)
}
blob, err := io.ReadAll(enc)
if err != nil {
log.Fatal(err)
}
if err := enc.Close(); err != nil {
log.Fatal(err)
}

// A store hands over random access to the blob and its exact size; only the
// envelope header and the chunks the range overlaps are ever read.
const off, length = 4, 15
r, err := fee.DecryptRange(bytes.NewReader(blob), int64(len(blob)),
fee.NewECDHESUnwrapper(kid, priv), off, length)
if err != nil {
log.Fatal(err)
}

// Both are known up front, so a handler can write its headers before
// decrypting a single chunk.
fmt.Printf("Content-Length: %d\n", r.Len())
if r.Len() == 0 {
fmt.Printf("Content-Range: bytes */%d\n", r.Size())
return
}
fmt.Printf("Content-Range: bytes %d-%d/%d\n", off, off+r.Len()-1, r.Size())
got, err := io.ReadAll(r)
if err != nil {
log.Fatal(err)
}
fmt.Printf("range: %q\n", got)

// Output:
// Content-Length: 15
// Content-Range: bytes 4-18/43
// range: "quick brown fox"
}
72 changes: 52 additions & 20 deletions fee.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,19 @@
// 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 covers full-object encrypt/decrypt only. Range-based decryption
// is a separate primitive in fee/aesstream, keyed off the ciphertext length and
// chunk size rather than the envelope's chunk count; a higher-level range API is
// tracked separately. This package adds no cryptography of its own.
// This package sequences the primitives and adds no cryptography of its own.
package fee

import (
Expand Down Expand Up @@ -476,17 +483,51 @@ func DecryptWithCEK(src io.Reader, cek []byte) (io.Reader, error) {
// 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 nil, fmt.Errorf("%w: body algorithm header missing or not an integer", ErrUnsupportedBodyAlg)
return bodyParams{}, fmt.Errorf("%w: body algorithm header missing or not an integer", ErrUnsupportedBodyAlg)
}
if alg != algChunkedAES256GCMStream {
return nil, fmt.Errorf("%w: body algorithm %d is not chunked AES-256-GCM-STREAM", ErrUnsupportedBodyAlg, alg)
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 nil, fmt.Errorf("%w: missing iv (base nonce)", ErrMalformedEnvelope)
return bodyParams{}, fmt.Errorf("%w: missing iv (base nonce)", ErrMalformedEnvelope)
}

// Self-describing chunk size; an envelope that omits it is read at the FEE
Expand All @@ -497,12 +538,12 @@ func openStream(env *cose.Envelope, ciphertext io.Reader, cek []byte) (io.Reader
if env.Headers.Unprotected.Has(labelChunkSize) {
n, ok := env.Headers.Unprotected.Int(labelChunkSize)
if !ok {
return nil, fmt.Errorf("%w: chunk-size header is present but not an integer", ErrMalformedEnvelope)
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 nil, fmt.Errorf("%w: declared chunk size %d out of range [%d, %d]",
return bodyParams{}, fmt.Errorf("%w: declared chunk size %d out of range [%d, %d]",
ErrMalformedEnvelope, chunkSize, aesstream.MinChunkSize, aesstream.MaxChunkSize)
}

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

r, err := aesstream.NewReader(ciphertext, aesstream.Config{
Key: cek,
BaseNonce: baseNonce,
AAD: aad,
ChunkSize: int(chunkSize),
})
if err != nil {
return nil, fmt.Errorf("fee: initializing body cipher: %w", err)
}
return r, nil
return bodyParams{baseNonce: baseNonce, chunkSize: int(chunkSize), aad: aad}, nil
}

// matchRecipient returns the first recipient whose kid equals want. A recipient
Expand Down
Loading
Loading