Layer-0 read gates - #396
Open
ilchu wants to merge 8 commits into
Open
Conversation
Layer-0 read endpoints served every byte unauthenticated, so bucket visibility (#330) was unenforced at Layer 0: anyone could enumerate a node's buckets via GET /buckets, fetch a private bucket's MMR peaks and walk GET /node children links down to every chunk — the exact walk the replica sync loop performs. Bucket-bound reads (/commitment, /checkpoint-signature, /mmr_proof, /mmr_peaks, /exists, /replica/historical_roots, /replica/sync_status) now pass the same visibility-aware Reader check the fs/s3 handlers use. Hash-keyed lookups (/node, /read, /chunk_proof, /fetch_nodes) stay open as capability reads: the store is one deduplicated content-addressed namespace with no bucket binding, hashes are unguessable, and the surfaces that would reveal them are now gated. FetchNodesRequest drops its ignored bucket_id field accordingly. Two Reader exemptions keep the design's serving rules intact: - replica-held buckets serve everyone (visibility gates primaries only): the membership resolver gains fetch_replica_here — this node's own standing, kept out of BucketAccess but cached and invalidated alongside it (ReplicaAgreementEstablished drops the entry), resolved from the node's own StorageAgreements entry; - the node's own provider account passes any Reader check (operator self-auth), since provider accounts are never members and the dashboard's challenge-response flow fetches /mmr_proof over HTTP. StaticMembershipResolver now covers the full resolver interface (fixed BucketAccess + replica standing), replacing the per-test one-off resolver fixtures. Enumeration surfaces with no bucket to gate on are cut instead: GET /buckets is removed (no callers), GET /stats loses its per-bucket breakdown (the dashboard only reads total_bytes), and the GET /mmr_subtree stub (returned only the root, ignored its params, no callers) is deleted.
…#383) With /mmr_peaks Reader-gated, a private bucket's primaries refuse the sync walk (a replica is not a bucket member — the design's primary gate), so syncing from primaries alone would strand every replica of a public-turned-private bucket. Per the design, replicas serve everyone, so the bucket's other replicas are the honest fallback source. SyncDuty.primary_endpoints becomes source_endpoints: primaries first (most current), then the bucket's other replicas — deduplicated and excluding this node — resolved via the new ReplicaSyncChainClient::fetch_replica_endpoints. A failure to list replicas only shrinks the fallback set. SyncResult::PrimaryUnavailable is renamed SourcesUnavailable to match.
Every caller of the newly gated endpoints sent no Authorization header, which only worked while the endpoints were unauthenticated; private buckets (the default) now refuse them. - storage-client: header-attaching moves onto Signer::sign_request (one definition; the write path's private copy is gone); get_commitment, get_checkpoint_signature and check_exists sign with it, and CheckpointManager signs its /commitment poll when a signer is configured (public buckets still work without one). - layer0 (TS): fetchCheckpointSignature and fetchChallengeProof take an optional ChainSigner; the /mmr_proof step signs, /chunk_proof stays a capability read. - provider dashboard: fetchChallengeProof signs as the provider's own wallet account — the node grants its own account every Reader check. - s3-ui: the spot-check /mmr_proof call signs as the connected owner. - examples + e2e: thread the in-scope signers through; workflow 12 gains L0 gate cases (unsigned 401, member 200, capability read 200).
The design doc gains "Read access at Layer 0" under Bucket Visibility & Access (v2.4): bucket-bound reads gate at Reader on primaries; hash-keyed lookups are capability reads, with their limits (shared hashes are irrevocable, challenge responses open a subtree) spelled out; plus the replica-serving and operator self-read allowances. The implementation doc's Authentication & RBAC section and per-endpoint reference are synced to the implemented surface: gated endpoints carry their Authorization line, /buckets and /mmr_subtree are gone, /stats is aggregate-only, and the replica-sync flow names the replica fallback. EXECUTION_FLOWS marks the checkpoint flow's /commitment poll as signed.
danielbui12
reviewed
Sep 9, 2026
Comment on lines
+41
to
+63
|
|
||
| /// Attach the signed provider `Authorization` header to `req` | ||
| /// (`method` = upper-case HTTP verb of the request). Timestamped at call | ||
| /// time, so build the request freshly per attempt when retrying. | ||
| pub fn sign_request( | ||
| &self, | ||
| req: reqwest::RequestBuilder, | ||
| method: &str, | ||
| bucket_id: storage_primitives::BucketId, | ||
| ) -> reqwest::RequestBuilder { | ||
| let timestamp = std::time::SystemTime::now() | ||
| .duration_since(std::time::UNIX_EPOCH) | ||
| .map(|d| d.as_secs()) | ||
| .unwrap_or_default(); | ||
| let header = provider_auth::build_auth_header( | ||
| &self.0.public_key().0, | ||
| method, | ||
| bucket_id, | ||
| timestamp, | ||
| |msg| self.0.sign(msg).0, | ||
| ); | ||
| req.header("Authorization", header) | ||
| } |
| /// This node serves the bucket under a replica agreement, so Reader | ||
| /// checks pass without auth (replicas serve reads to everyone — | ||
| /// visibility gates primaries only). | ||
| pub(crate) replica_here: bool, |
Member
There was a problem hiding this comment.
could you use another, more meaningful name here
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #383.
Layer-0 read endpoints served every byte unauthenticated, so the bucket visibility shipped in #330 was unenforced at Layer 0: anyone who knew a provider's URL could list every bucket on the node via
GET /buckets, fetch a private bucket's MMR peaks, and walkGET /nodechildren links down to every chunk — the exact walk the replica sync loop performs, which the design's "primary gate" (a born-private bucket has no honest sync source) assumed was impossible.Bucket-bound reads now gate at Reader.
GET /commitment,GET /checkpoint-signature,GET /mmr_proof,GET /mmr_peaks,POST /exists,GET /replica/historical_rootsandGET /replica/sync_statuspass the same visibility-aware check the fs/s3 handlers use: members only when the bucket is private, anyone when it is public. The hash-keyed lookups (GET /node,GET /read,GET /chunk_proof,POST /fetch_nodes) stay open as capability reads — the node store is one content-addressed namespace deduplicated across buckets, so these requests have no bucket to gate on; possession of an unguessable blake2-256 hash is the capability, and every surface that would reveal such hashes is now gated. The design doc (v2.4, new "Read access at Layer 0" section) spells out this rule and its accepted limits: a shared hash keeps byte access until the data is gone, and an on-chain challenge response opens the published chunk's subtree.Two Reader exemptions keep the design's serving rules intact. A bucket this node holds under a replica agreement serves reads to everyone — replicas serve everyone, visibility gates primaries only — resolved through a new
fetch_replica_hereon the membership resolver, cached and invalidated alongside the member set (this also fixes the L1 gates from #330, which wrongly demanded membership on replica-served private buckets). And a request signed by the node's own provider account passes any Reader check: provider accounts are never bucket members, and the dashboard's challenge-response flow needs/mmr_proofon buckets the provider defends.Enumeration surfaces with no bucket to gate on are removed instead.
GET /bucketsis gone (it had no callers),GET /statskeeps only aggregate totals (the dashboard reads onlytotal_bytes), and theGET /mmr_subtreestub — it ignored its parameters and returned only the root, with no callers — is deleted.FetchNodesRequestdrops its ignoredbucket_idfield, and the storage backend'slist_buckets() → Vec<BucketSummary>shrinks tobucket_ids()since nothing consumed more than the ids.Replica sync gains a fallback source. With
/mmr_peaksgated, a private bucket's primaries now refuse the sync walk, which would strand every new replica of a public-turned-private bucket. Per the design, replicas that already synced serve everyone, so the coordinator now tries the bucket's other replicas after the primaries (SyncDuty.source_endpoints, deduplicated, this node excluded); a failed replica listing only shrinks the fallback set. Born-private buckets remain sourceless by design.Every client signs the gated reads. Header-attaching lives in one place now (
Signer::sign_requestin storage-client); the Rust SDK'sget_commitment/get_checkpoint_signature/check_existsand the CheckpointManager's/commitmentpoll sign with it, the TS layer0 wrappers (fetchCheckpointSignature,fetchChallengeProof) take an optional signer, the provider dashboard signs proof fetches as the provider's own wallet account, s3-ui signs its spot-check/mmr_proof, and the examples and e2e thread the in-scope signers through. Public buckets keep working unsigned everywhere.One accepted residual, noted in the docs:
/existsgates per bucket but the underlying existence check is global (cross-bucket dedup), so a member of any bucket can probe whether the node holds a given hash — an oracle over unguessable inputs, closable only with per-node ownership indexes (the option the design defers).