Skip to content

Commit 6ee9f9b

Browse files
committed
feat: implement encrypted read
1 parent 8cc6409 commit 6ee9f9b

19 files changed

Lines changed: 908 additions & 60 deletions

DESIGN_NOTES.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,20 @@ by the location commitment. The indexing-service query path is implemented
100100
but unwired. The full trace is the
101101
[GetObject diagram](./docs/diagrams.md#getobject-version-resolution-local-tiers-network-retrieval).
102102

103+
**Encrypted blobs** (the FilOne encryption design's read side) decrypt inside
104+
the per-blob open, leaving every other read-path value plaintext: the
105+
manifest's `BlobRef.Offset/Length`, `Body.Size`, ETag and Content-Length are
106+
plaintext coordinates, while `BlobRef.Digest` names the stored FEE envelope.
107+
A `blob_encryption_params` row marks a blob encrypted and carries what its
108+
decryptor needs; the read unwraps the region-wrapped CEK through
109+
`regionkey.Provider` (OpenBao transit in production, bound to the blob's
110+
(space, digest)), maps the plaintext range to one contiguous ciphertext span
111+
(`aesstream.CiphertextRange`), fetches only that span (ranged from the spool
112+
or piri via `OpenBlobRange`), and decrypts it as it streams
113+
(`aesstream.SpanReader`). A tampered chunk fails authentication mid-stream.
114+
With no region key provider configured the lookups are skipped entirely.
115+
HEAD never decrypts. See `s3frontend/decrypt.go`.
116+
103117
## Identity & auth
104118

105119
- **agent**: `ServiceIdentity.Signer` (daemon: `identity.key_file` PEM), the

blockstore/cache.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ func NewCached(base BlockReader, maxBytes int64) BlockReader {
5454
}
5555

5656
var (
57-
_ BlockReader = (*Cached)(nil)
58-
_ BlobReader = (*Cached)(nil)
57+
_ BlockReader = (*Cached)(nil)
58+
_ BlobReader = (*Cached)(nil)
59+
_ BlobRangeReader = (*Cached)(nil)
5960
)
6061

6162
// OpenBlob streams a body blob straight from the base reader, bypassing the LRU.
@@ -69,6 +70,15 @@ func (c *Cached) OpenBlob(ctx context.Context, space did.DID, digest mh.Multihas
6970
return nil, ErrNotFound
7071
}
7172

73+
// OpenBlobRange streams stored bytes [off, off+n) of a body blob straight
74+
// from the base reader, bypassing the LRU exactly like OpenBlob.
75+
func (c *Cached) OpenBlobRange(ctx context.Context, space did.DID, digest mh.Multihash, off, n int64) (io.ReadCloser, error) {
76+
if br, ok := c.base.(BlobReader); ok {
77+
return OpenBlobRangeOf(ctx, br, space, digest, off, n)
78+
}
79+
return nil, ErrNotFound
80+
}
81+
7282
// GetBlock returns the cached block if present, otherwise fetches it from the
7383
// base reader and caches it (subject to the byte budget). The LRU is keyed by
7484
// CID only — blocks are content-addressed, so a hit is valid whatever space

blockstore/forge.go

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package blockstore
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"fmt"
78
"io"
89
"net/http"
910
"net/url"
1011

12+
"github.com/fil-forge/libforge/blobindex"
13+
1114
indexclient "github.com/fil-forge/indexing-service/pkg/client"
1215
contentcmds "github.com/fil-forge/libforge/commands/content"
1316
"github.com/fil-forge/libforge/ucan/retrieval"
@@ -55,8 +58,9 @@ type Forge struct {
5558
}
5659

5760
var (
58-
_ BlockReader = (*Forge)(nil)
59-
_ BlobReader = (*Forge)(nil)
61+
_ BlockReader = (*Forge)(nil)
62+
_ BlobReader = (*Forge)(nil)
63+
_ BlobRangeReader = (*Forge)(nil)
6064
)
6165

6266
// ForgeConfig wires a read-only Forge block reader.
@@ -141,16 +145,19 @@ func NewForge(cfg ForgeConfig) (*Forge, error) {
141145
// empty 200 when it can't resolve a did:plc principal in the proof chain)
142146
// otherwise surfaces only as a bare client-side EOF mid-body, with nothing
143147
// attributable in ingot's own logs.
144-
func (f *Forge) retrieve(ctx context.Context, space did.DID, c cid.Cid) (io.ReadCloser, int64, error) {
145-
rc, n, err := f.doRetrieve(ctx, space, c)
148+
// off/n select a sub-range of the located bytes: stored bytes [off, off+n)
149+
// of the blob. n < 0 retrieves the locator's whole range (the entire blob,
150+
// for a body blob).
151+
func (f *Forge) retrieve(ctx context.Context, space did.DID, c cid.Cid, off, n int64) (io.ReadCloser, int64, error) {
152+
rc, got, err := f.doRetrieve(ctx, space, c, off, n)
146153
if err != nil && !errors.Is(err, ErrNotFound) {
147154
f.logger.Warn("forge: retrieve failed",
148155
zap.Stringer("space", space), zap.Stringer("cid", c), zap.Error(err))
149156
}
150-
return rc, n, err
157+
return rc, got, err
151158
}
152159

153-
func (f *Forge) doRetrieve(ctx context.Context, space did.DID, c cid.Cid) (io.ReadCloser, int64, error) {
160+
func (f *Forge) doRetrieve(ctx context.Context, space did.DID, c cid.Cid, off, n int64) (io.ReadCloser, int64, error) {
154161
locations, err := f.locator.Locate(ctx, []did.DID{space}, c.Hash())
155162
if err != nil {
156163
var nf locator.NotFoundError
@@ -170,6 +177,23 @@ func (f *Forge) doRetrieve(ctx context.Context, space did.DID, c cid.Cid) (io.Re
170177
}
171178
target := cm.Location[0]
172179

180+
// Narrow the located range to the caller's sub-range. loc.Range is
181+
// inclusive and, for a whole blob, starts at 0 — so the sub-range is
182+
// [Start+off, Start+off+n-1], clamped to the located end. The decrypting
183+
// read path uses this to fetch only the ciphertext chunks it needs.
184+
if n >= 0 {
185+
start := loc.Range.Start + off
186+
if start > loc.Range.End || n == 0 {
187+
// Nothing to fetch; an empty body keeps the contract simple.
188+
return io.NopCloser(bytes.NewReader(nil)), 0, nil
189+
}
190+
end := start + n - 1
191+
if end > loc.Range.End {
192+
end = loc.Range.End
193+
}
194+
loc.Range = blobindex.Range{Start: start, End: end}
195+
}
196+
173197
// The commitment's space scopes the retrieve capability; fall back to
174198
// the caller's space if the commitment lacks one (the local locator
175199
// always stamps it).
@@ -236,7 +260,7 @@ func (f *Forge) doRetrieve(ctx context.Context, space did.DID, c cid.Cid) (io.Re
236260
// via a UCAN-authorized /content/retrieve. It buffers the whole block, so it is
237261
// for small catalog blocks; object-body blobs use the streaming OpenBlob.
238262
func (f *Forge) GetBlock(ctx context.Context, space did.DID, c cid.Cid) (block.Block, error) {
239-
rc, wantLen, err := f.retrieve(ctx, space, c)
263+
rc, wantLen, err := f.retrieve(ctx, space, c, 0, -1)
240264
if err != nil {
241265
return nil, err
242266
}
@@ -257,7 +281,15 @@ func (f *Forge) GetBlock(ctx context.Context, space did.DID, c cid.Cid) (block.B
257281
// straight off the /content/retrieve response; the caller owns the reader and
258282
// must Close it.
259283
func (f *Forge) OpenBlob(ctx context.Context, space did.DID, digest mh.Multihash) (io.ReadCloser, error) {
260-
rc, _, err := f.retrieve(ctx, space, cid.NewCidV1(cid.Raw, digest))
284+
rc, _, err := f.retrieve(ctx, space, cid.NewCidV1(cid.Raw, digest), 0, -1)
285+
return rc, err
286+
}
287+
288+
// OpenBlobRange streams stored bytes [off, off+n) of an object-body blob from
289+
// piri — a ranged /content/retrieve, so the decrypting read path fetches only
290+
// the ciphertext chunks a plaintext range needs rather than the whole blob.
291+
func (f *Forge) OpenBlobRange(ctx context.Context, space did.DID, digest mh.Multihash, off, n int64) (io.ReadCloser, error) {
292+
rc, _, err := f.retrieve(ctx, space, cid.NewCidV1(cid.Raw, digest), off, n)
261293
return rc, err
262294
}
263295

blockstore/layered.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,26 @@ func (l *Layered) OpenBlob(ctx context.Context, space did.DID, digest mh.Multiha
9494
return nil, ErrNotFound
9595
}
9696

97+
// OpenBlobRange streams stored bytes [off, off+n) of a body blob with the
98+
// same tiering as OpenBlob: spool first, then the network base. A tier that
99+
// implements only BlobReader is served through OpenBlob with the prefix
100+
// discarded (OpenBlobRangeOf).
101+
func (l *Layered) OpenBlobRange(ctx context.Context, space did.DID, digest mh.Multihash, off, n int64) (io.ReadCloser, error) {
102+
if br, ok := l.spool.(BlobReader); ok {
103+
rc, err := OpenBlobRangeOf(ctx, br, space, digest, off, n)
104+
if err == nil {
105+
return rc, nil
106+
}
107+
if !errors.Is(err, ErrNotFound) {
108+
return nil, err
109+
}
110+
}
111+
if br, ok := l.base.(BlobReader); ok {
112+
return OpenBlobRangeOf(ctx, br, space, digest, off, n)
113+
}
114+
return nil, ErrNotFound
115+
}
116+
97117
// layeredAsBlockstore lifts Layered into a BaseStore for the
98118
// CborStore wrapper, with the read's space bound in. Internal-only —
99119
// exists so the CBOR decoder reuses Layered's fallthrough order (and

blockstore/openblob_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,79 @@ func TestCached_OpenBlob_BypassesCache(t *testing.T) {
7979
t.Fatalf("cached OpenBlob: got %q err %v, want streamed", got, err)
8080
}
8181
}
82+
83+
// TestSpool_OpenBlobRange: the section reader serves exactly [off, off+n) of
84+
// the spooled file, and a range past the end yields a shorter stream.
85+
func TestSpool_OpenBlobRange(t *testing.T) {
86+
ctx := context.Background()
87+
sp, err := NewSpool(t.TempDir())
88+
if err != nil {
89+
t.Fatalf("NewSpool: %v", err)
90+
}
91+
data := []byte("0123456789abcdef")
92+
digest, _, err := sp.WriteBlob(ctx, bytes.NewReader(data))
93+
if err != nil {
94+
t.Fatalf("WriteBlob: %v", err)
95+
}
96+
97+
cases := []struct {
98+
name string
99+
off, n int64
100+
want string
101+
}{
102+
{"interior", 4, 6, "456789"},
103+
{"prefix", 0, 3, "012"},
104+
{"suffix past end clamps", 12, 100, "cdef"},
105+
{"empty", 5, 0, ""},
106+
}
107+
for _, c := range cases {
108+
t.Run(c.name, func(t *testing.T) {
109+
rc, err := sp.OpenBlobRange(ctx, did.Undef, digest, c.off, c.n)
110+
if err != nil {
111+
t.Fatalf("OpenBlobRange: %v", err)
112+
}
113+
defer rc.Close()
114+
got, err := io.ReadAll(rc)
115+
if err != nil {
116+
t.Fatalf("read: %v", err)
117+
}
118+
if string(got) != c.want {
119+
t.Fatalf("range [%d,+%d) = %q, want %q", c.off, c.n, got, c.want)
120+
}
121+
})
122+
}
123+
124+
if _, err := sp.OpenBlobRange(ctx, did.Undef, mustDigest(t, []byte("absent")), 0, 1); !errors.Is(err, ErrNotFound) {
125+
t.Fatalf("missing blob err = %v, want ErrNotFound", err)
126+
}
127+
}
128+
129+
// TestOpenBlobRangeOf_Fallback: a tier without the BlobRangeReader capability
130+
// is served through OpenBlob with the prefix discarded and the tail limited.
131+
func TestOpenBlobRangeOf_Fallback(t *testing.T) {
132+
ctx := context.Background()
133+
tier := fakeBlobTier{data: []byte("0123456789abcdef")}
134+
135+
rc, err := OpenBlobRangeOf(ctx, tier, did.Undef, nil, 4, 6)
136+
if err != nil {
137+
t.Fatalf("OpenBlobRangeOf: %v", err)
138+
}
139+
defer rc.Close()
140+
got, err := io.ReadAll(rc)
141+
if err != nil {
142+
t.Fatalf("read: %v", err)
143+
}
144+
if string(got) != "456789" {
145+
t.Fatalf("fallback range = %q, want %q", got, "456789")
146+
}
147+
}
148+
149+
// mustDigest hashes data into the multihash form blobs are keyed by.
150+
func mustDigest(t *testing.T, data []byte) mh.Multihash {
151+
t.Helper()
152+
d, err := mh.Sum(data, mh.SHA2_256, -1)
153+
if err != nil {
154+
t.Fatalf("multihash: %v", err)
155+
}
156+
return d
157+
}

blockstore/spool.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,21 @@ func (s *Spool) OpenBlob(_ context.Context, _ did.DID, digest mh.Multihash) (io.
116116
return f, nil
117117
}
118118

119+
// OpenBlobRange returns a reader over stored bytes [off, off+n) of the
120+
// spooled blob, or ErrNotFound — OpenBlob restricted to a section, for the
121+
// decrypting read path, which fetches only the ciphertext span a plaintext
122+
// range needs. A range past the file's end yields a shorter stream.
123+
func (s *Spool) OpenBlobRange(_ context.Context, _ did.DID, digest mh.Multihash, off, n int64) (io.ReadCloser, error) {
124+
f, err := os.Open(s.Path(digest))
125+
if errors.Is(err, os.ErrNotExist) {
126+
return nil, ErrNotFound
127+
}
128+
if err != nil {
129+
return nil, fmt.Errorf("blockstore: spool open %s: %w", digest.B58String(), err)
130+
}
131+
return readerCloser{Reader: io.NewSectionReader(f, off, n), Closer: f}, nil
132+
}
133+
119134
// GetBlock returns the blob stored under c's multihash, or ErrNotFound. A miss
120135
// is expected and cheap: it lets the layered read path fall through to the log
121136
// (for catalog blocks, which are never spooled) or the network tier (for a body
@@ -134,7 +149,8 @@ func (s *Spool) GetBlock(_ context.Context, _ did.DID, c cid.Cid) (block.Block,
134149
// Compile-time assertions: Spool is a raw-block read tier and the streaming
135150
// blob tier for object bodies.
136151
var (
137-
_ BlockReader = (*Spool)(nil)
138-
_ BlobReader = (*Spool)(nil)
139-
_ BlobWriter = (*Spool)(nil)
152+
_ BlockReader = (*Spool)(nil)
153+
_ BlobReader = (*Spool)(nil)
154+
_ BlobRangeReader = (*Spool)(nil)
155+
_ BlobWriter = (*Spool)(nil)
140156
)

blockstore/store.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ package blockstore
4343

4444
import (
4545
"context"
46+
"fmt"
4647
"io"
4748

4849
"github.com/fil-forge/ucantone/did"
@@ -104,6 +105,50 @@ type BlobReader interface {
104105
OpenBlob(ctx context.Context, space did.DID, digest mh.Multihash) (io.ReadCloser, error)
105106
}
106107

108+
// BlobRangeReader is the optional ranged counterpart of BlobReader: it
109+
// streams only the stored bytes [off, off+n) of a blob. The decrypting read
110+
// path uses it to fetch exactly the ciphertext chunks a plaintext range
111+
// needs instead of the whole blob. Returns ErrNotFound if the blob is not in
112+
// this tier; a range past the stored bytes yields a shorter stream, which
113+
// the caller detects. A tier without this capability is served through
114+
// OpenBlob with the prefix discarded (see OpenBlobRangeOf).
115+
type BlobRangeReader interface {
116+
OpenBlobRange(ctx context.Context, space did.DID, digest mh.Multihash, off, n int64) (io.ReadCloser, error)
117+
}
118+
119+
// OpenBlobRangeOf opens stored bytes [off, off+n) of a blob through br,
120+
// using its BlobRangeReader capability when present and falling back to
121+
// OpenBlob with the prefix read-and-discarded (or Seek'd, for a spool file)
122+
// when not. It is how range-consuming callers stay correct over any tier.
123+
func OpenBlobRangeOf(ctx context.Context, br BlobReader, space did.DID, digest mh.Multihash, off, n int64) (io.ReadCloser, error) {
124+
if rr, ok := br.(BlobRangeReader); ok {
125+
return rr.OpenBlobRange(ctx, space, digest, off, n)
126+
}
127+
rc, err := br.OpenBlob(ctx, space, digest)
128+
if err != nil {
129+
return nil, err
130+
}
131+
if off > 0 {
132+
if seeker, ok := rc.(io.Seeker); ok {
133+
if _, err := seeker.Seek(off, io.SeekStart); err != nil {
134+
_ = rc.Close()
135+
return nil, fmt.Errorf("blockstore: seek blob to %d: %w", off, err)
136+
}
137+
} else if _, err := io.CopyN(io.Discard, rc, off); err != nil {
138+
_ = rc.Close()
139+
return nil, fmt.Errorf("blockstore: skip into blob to %d: %w", off, err)
140+
}
141+
}
142+
return readerCloser{Reader: io.LimitReader(rc, n), Closer: rc}, nil
143+
}
144+
145+
// readerCloser pairs a wrapped reader with the closer releasing its
146+
// underlying stream.
147+
type readerCloser struct {
148+
io.Reader
149+
io.Closer
150+
}
151+
107152
// BlobWriter streams an object-body blob to local storage, computing its sha256
108153
// digest as it writes (never buffering the whole blob), and returns that digest
109154
// and the byte count. It copies r to EOF; an empty r stores nothing and returns

0 commit comments

Comments
 (0)