diff --git a/core/archipelago/src/api/handler.rs b/core/archipelago/src/api/handler.rs index 63ac10e7..0982126d 100644 --- a/core/archipelago/src/api/handler.rs +++ b/core/archipelago/src/api/handler.rs @@ -403,6 +403,12 @@ impl ApiHandler { .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + // Extract federation peer DID from X-Federation-DID header + let peer_did = headers + .get("x-federation-did") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + // Parse Range header for streaming support let range = headers .get("range") @@ -413,6 +419,7 @@ impl ApiHandler { &config.data_dir, content_id, payment_token.as_deref(), + peer_did.as_deref(), range, ) .await @@ -456,6 +463,15 @@ impl ApiHandler { .body(hyper::Body::from(body_bytes)) .unwrap()) } + Ok(content_server::ServeResult::Forbidden) => { + Ok(Response::builder() + .status(StatusCode::FORBIDDEN) + .header("Content-Type", "application/json") + .body(hyper::Body::from( + r#"{"error":"Access denied — federation peer required"}"#, + )) + .unwrap()) + } Ok(content_server::ServeResult::NotFound) | Err(_) => { Ok(Response::builder() .status(StatusCode::NOT_FOUND) diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index f8a1fdce..66a8a29d 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -187,16 +187,20 @@ pub enum ServeResult { }, /// Payment required — includes price in sats. PaymentRequired(u64), + /// Access forbidden — peer not authorized. + Forbidden, /// Content not found. NotFound, } /// Serve a content item by ID with access control and optional range request. /// If the content is paid, checks for a valid payment token in the header. +/// `peer_did` is the DID from the X-Federation-DID header (if present). pub async fn serve_content( data_dir: &Path, id: &str, payment_token: Option<&str>, + peer_did: Option<&str>, range: Option, ) -> Result { let catalog = load_catalog(data_dir).await?; @@ -205,13 +209,26 @@ pub async fn serve_content( None => return Ok(ServeResult::NotFound), }; + // Load known federation peers for access checks + let is_known_peer = if peer_did.is_some() { + let nodes = crate::federation::load_nodes(data_dir).await.unwrap_or_default(); + nodes.iter().any(|n| Some(n.did.as_str()) == peer_did) + } else { + false + }; + // Check availability match &item.availability { Availability::Nobody => return Ok(ServeResult::NotFound), Availability::Specific { peers } => { - // In a real implementation, we'd check the requester's identity - // For now, log that peer-specific availability is set - debug!("Content '{}' restricted to {} specific peers", id, peers.len()); + if let Some(did) = peer_did { + if !peers.iter().any(|p| p == did) { + debug!("Content '{}' not available to peer {}", id, did); + return Ok(ServeResult::Forbidden); + } + } else { + return Ok(ServeResult::Forbidden); + } } Availability::AllPeers => {} } @@ -229,7 +246,9 @@ pub async fn serve_content( } } AccessControl::PeersOnly => { - // For now, allow all requests (peer auth is at the Tor level) + if !is_known_peer { + return Ok(ServeResult::Forbidden); + } } AccessControl::Free => {} } diff --git a/loop/plan.md b/loop/plan.md index c15e8330..e82e2228 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -514,7 +514,7 @@ - [x] **SHARE-01** — Test content sharing between two federated nodes. On node A (192.168.1.228): upload a test file to FileBrowser, then call `content.add` with the filename to share it. Call `content.set-pricing` with `access: "free"`. Call `content.set-availability` with `availability: "all_peers"`. On node B (192.168.1.198): call `content.browse-peer` with node A's onion address. Verify the shared file appears in the catalog with correct metadata (name, size, mime_type). Download the file via the content server's HTTP endpoint over Tor. Compare checksums. **Acceptance**: File shared on node A is browseable and downloadable from node B with matching content. If `browse-peer` fails, debug: check Tor SOCKS proxy, check content server HTTP handler is listening, check the file path mapping between FileBrowser storage and content catalog. -- [ ] **SHARE-02** — Test access control modes. On node A, share 3 files: one `free`, one `peers_only`, one `paid` (price: 100 sats). From node B (federated peer): verify `free` file is accessible, `peers_only` file is accessible (peer is authenticated via DID), `paid` file returns payment-required response with price. From an unfederated client (curl via Tor): verify `free` file is accessible, `peers_only` returns 403, `paid` returns payment-required. Test `availability: "specific"` with node B's onion in the allowed list — verify only node B can access. **Acceptance**: All 3 access modes enforce correctly for both federated peers and anonymous Tor clients. +- [x] **SHARE-02** — Test access control modes. On node A, share 3 files: one `free`, one `peers_only`, one `paid` (price: 100 sats). From node B (federated peer): verify `free` file is accessible, `peers_only` file is accessible (peer is authenticated via DID), `paid` file returns payment-required response with price. From an unfederated client (curl via Tor): verify `free` file is accessible, `peers_only` returns 403, `paid` returns payment-required. Test `availability: "specific"` with node B's onion in the allowed list — verify only node B can access. **Acceptance**: All 3 access modes enforce correctly for both federated peers and anonymous Tor clients. - [ ] **SHARE-03** — Test file sharing at scale. Share 10 files of varying sizes (1KB text, 100KB image, 1MB PDF, 10MB video) from node A. Browse the catalog from nodes B, C, and D simultaneously. Download the 10MB file from all 3 nodes at once. Measure: catalog browse latency (<5s over Tor), download speed for 10MB file (any speed is acceptable over Tor, just verify it completes). Verify no corrupted transfers (checksum all downloads). **Acceptance**: All files transfer correctly to all 3 peers. No timeouts, no corruption. Document transfer speeds.