Skip to content

Layer-0 read gates - #396

Open
ilchu wants to merge 8 commits into
devfrom
ic/l0-read-auth
Open

Layer-0 read gates#396
ilchu wants to merge 8 commits into
devfrom
ic/l0-read-auth

Conversation

@ilchu

@ilchu ilchu commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

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 walk GET /node children 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_roots and GET /replica/sync_status pass 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_here on 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_proof on buckets the provider defends.

Enumeration surfaces with no bucket to gate on are removed instead. GET /buckets is gone (it had no callers), GET /stats keeps only aggregate totals (the dashboard reads only total_bytes), and the GET /mmr_subtree stub — it ignored its parameters and returned only the root, with no callers — is deleted. FetchNodesRequest drops its ignored bucket_id field, and the storage backend's list_buckets() → Vec<BucketSummary> shrinks to bucket_ids() since nothing consumed more than the ids.

Replica sync gains a fallback source. With /mmr_peaks gated, 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_request in storage-client); the Rust SDK's get_commitment/get_checkpoint_signature/check_exists and the CheckpointManager's /commitment poll 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: /exists gates 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).

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.
@ilchu ilchu self-assigned this Sep 8, 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)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

/// 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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you use another, more meaningful name here

@mudigal mudigal mentioned this pull request Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Provider] Layer-0 read endpoints are unauthenticated, so bucket visibility is unenforced

3 participants