Verify scoped peer identity proofs before restricted content access
This commit is contained in:
@@ -7,14 +7,48 @@ use hyper::{Response, StatusCode};
|
||||
use super::{is_valid_app_id, ApiHandler};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match content_server::load_catalog(&config.data_dir).await {
|
||||
fn verified_content_peer(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Option<String>> {
|
||||
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
|
||||
crate::content_auth::incoming(headers, &audience, path, chrono::Utc::now().timestamp())
|
||||
}
|
||||
|
||||
async fn content_access_context(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<(Option<String>, bool, bool)> {
|
||||
let peer = self.verified_content_peer(path, headers)?;
|
||||
let known = if let Some(did) = &peer {
|
||||
crate::federation::load_nodes(&self.config.data_dir)
|
||||
.await?
|
||||
.iter()
|
||||
.any(|node| &node.did == did)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let owner = match crate::session::extract_session_cookie(headers) {
|
||||
Some(token) => self.session_store.validate(&token).await,
|
||||
None => false,
|
||||
};
|
||||
Ok((peer, known, owner))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_content_catalog(
|
||||
&self,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let (peer, known, owner) = self.content_access_context("/content", headers).await?;
|
||||
match content_server::load_catalog(&self.config.data_dir).await {
|
||||
Ok(catalog) => {
|
||||
// Only expose public metadata for available items
|
||||
let items: Vec<serde_json::Value> = catalog
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
|
||||
.filter(|item| content_server::visible_to(item, peer.as_deref(), known, owner))
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"id": i.id,
|
||||
@@ -82,11 +116,18 @@ impl ApiHandler {
|
||||
.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());
|
||||
let peer_did = match self.verified_content_peer(path, headers) {
|
||||
Ok(peer) => peer,
|
||||
Err(_) => {
|
||||
return Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"Peer authentication failed. Check both nodes are updated and their clocks are correct."}"#,
|
||||
),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// The authenticated local operator never pays for their own node's
|
||||
// content: validate the session cookie (same discipline as the model
|
||||
@@ -249,7 +290,11 @@ impl ApiHandler {
|
||||
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
|
||||
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
|
||||
/// Records a pending entitlement keyed by the invoice's payment hash.
|
||||
pub(super) async fn handle_content_invoice(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
pub(super) async fn handle_content_invoice(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/invoice"))
|
||||
@@ -262,6 +307,7 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let (peer, known, owner) = self.content_access_context(path, headers).await?;
|
||||
let catalog = content_server::load_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -275,6 +321,13 @@ impl ApiHandler {
|
||||
))
|
||||
}
|
||||
};
|
||||
if !content_server::visible_to(item, peer.as_deref(), known, owner) {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
));
|
||||
}
|
||||
let price_sats = match &item.access {
|
||||
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
|
||||
_ => {
|
||||
@@ -359,7 +412,11 @@ impl ApiHandler {
|
||||
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
|
||||
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
|
||||
/// pending entitlement keyed by the address; price doubles as expected amount.
|
||||
pub(super) async fn handle_content_onchain(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
pub(super) async fn handle_content_onchain(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/onchain"))
|
||||
@@ -371,9 +428,17 @@ impl ApiHandler {
|
||||
hyper::Body::from("Invalid content ID"),
|
||||
));
|
||||
}
|
||||
let (peer, known, owner) = self.content_access_context(path, headers).await?;
|
||||
let catalog = content_server::load_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if !content_server::visible_to(item, peer.as_deref(), known, owner) {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
));
|
||||
}
|
||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => match &i.access {
|
||||
content_server::AccessControl::Paid { price_sats, .. } => {
|
||||
|
||||
@@ -591,7 +591,7 @@ impl ApiHandler {
|
||||
|
||||
// Lightning-invoice peer-file sale (#46): mint invoice / poll settlement
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => {
|
||||
self.handle_content_invoice(p).await
|
||||
self.handle_content_invoice(p, &headers).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => {
|
||||
self.handle_content_invoice_status(p).await
|
||||
@@ -602,7 +602,7 @@ impl ApiHandler {
|
||||
self.handle_content_onchain_status(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => {
|
||||
self.handle_content_onchain(p).await
|
||||
self.handle_content_onchain(p, &headers).await
|
||||
}
|
||||
|
||||
// Content serving — peers access shared content over Tor (no session auth);
|
||||
@@ -612,7 +612,7 @@ impl ApiHandler {
|
||||
}
|
||||
|
||||
// Content catalog — list available content (no session auth, for peers)
|
||||
(Method::GET, "/content") => Self::handle_content_catalog(&self.config).await,
|
||||
(Method::GET, "/content") => self.handle_content_catalog(&headers).await,
|
||||
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
|
||||
@@ -261,6 +261,7 @@ impl ApiHandler {
|
||||
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
|
||||
req = req.header("Range", r.to_string());
|
||||
}
|
||||
let req = req.authenticate_content(&self.config.data_dir).await?;
|
||||
match req.send_get().await {
|
||||
Ok((resp, transport)) => {
|
||||
if resp.status().is_redirection() {
|
||||
|
||||
@@ -450,6 +450,8 @@ impl RpcHandler {
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.fips_timeout(std::time::Duration::from_secs(8))
|
||||
.authenticate_content(&self.config.data_dir)
|
||||
.await?
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
@@ -540,6 +542,8 @@ impl RpcHandler {
|
||||
// against the UI's 30s deadline — users saw errors, not
|
||||
// fallback.
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.authenticate_content(&self.config.data_dir)
|
||||
.await?
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
@@ -710,6 +714,8 @@ impl RpcHandler {
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.authenticate_content(&self.config.data_dir)
|
||||
.await?
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -829,6 +835,8 @@ impl RpcHandler {
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(25))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.authenticate_content(&self.config.data_dir)
|
||||
.await?
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -893,6 +901,8 @@ impl RpcHandler {
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.authenticate_content(&self.config.data_dir)
|
||||
.await?
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -985,6 +995,8 @@ impl RpcHandler {
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Invoice-Hash", payment_hash.to_string())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.authenticate_content(&self.config.data_dir)
|
||||
.await?
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -1083,6 +1095,8 @@ impl RpcHandler {
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(25))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.authenticate_content(&self.config.data_dir)
|
||||
.await?
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -1142,6 +1156,8 @@ impl RpcHandler {
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.authenticate_content(&self.config.data_dir)
|
||||
.await?
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -1199,6 +1215,8 @@ impl RpcHandler {
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Onchain-Address", address.to_string())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.authenticate_content(&self.config.data_dir)
|
||||
.await?
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -1279,6 +1297,8 @@ impl RpcHandler {
|
||||
.require_fips()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.authenticate_content(&self.config.data_dir)
|
||||
.await?
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer for preview")?;
|
||||
|
||||
Reference in New Issue
Block a user