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")?;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Short-lived, route- and recipient-bound proofs for peer content reads.
|
||||
//! A claimed X-Federation-DID is never authentication. Sign with the existing
|
||||
//! node Ed25519 identity; public shares remain readable without a peer proof.
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use ed25519_dalek::{Signature, Signer, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
pub const HEADER: &str = "x-archipelago-content-auth";
|
||||
const MAX_AGE: u64 = 60;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Proof {
|
||||
did: String,
|
||||
audience: String,
|
||||
path: String,
|
||||
range: String,
|
||||
timestamp: i64,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
impl Proof {
|
||||
fn preimage(&self) -> Result<Vec<u8>> {
|
||||
Ok(serde_json::to_vec(&(
|
||||
"archipelago-content-auth-v1",
|
||||
"GET",
|
||||
&self.did,
|
||||
&self.audience,
|
||||
&self.path,
|
||||
&self.range,
|
||||
self.timestamp,
|
||||
))?)
|
||||
}
|
||||
}
|
||||
|
||||
fn sign(
|
||||
identity: &crate::identity::NodeIdentity,
|
||||
audience: &str,
|
||||
path: &str,
|
||||
range: &str,
|
||||
now: i64,
|
||||
) -> Result<String> {
|
||||
let mut proof = Proof {
|
||||
did: identity.did_key()?,
|
||||
audience: audience.into(),
|
||||
path: path.into(),
|
||||
range: range.into(),
|
||||
timestamp: now,
|
||||
signature: String::new(),
|
||||
};
|
||||
proof.signature = hex::encode(identity.signing_key().sign(&proof.preimage()?).to_bytes());
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&proof)?))
|
||||
}
|
||||
|
||||
/// Only authenticated federation identity bindings are used as the audience.
|
||||
/// No matching peer means an anonymous request, never a forged peer identity.
|
||||
pub async fn outgoing(
|
||||
data_dir: &Path,
|
||||
onion: &str,
|
||||
path: &str,
|
||||
range: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let peers = crate::federation::load_nodes(data_dir).await?;
|
||||
let Some(peer) = peers.iter().find(|peer| peer.onion == onion) else {
|
||||
return Ok(None);
|
||||
};
|
||||
crate::identity::pubkey_bytes_from_did_key(&peer.did)?;
|
||||
let identity = crate::identity::NodeIdentity::load_existing(&data_dir.join("identity")).await?;
|
||||
Ok(Some(sign(
|
||||
&identity,
|
||||
&peer.did,
|
||||
path,
|
||||
range,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn incoming(
|
||||
headers: &hyper::HeaderMap,
|
||||
audience: &str,
|
||||
path: &str,
|
||||
now: i64,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(value) = headers.get(HEADER) else {
|
||||
return Ok(None);
|
||||
};
|
||||
anyhow::ensure!(value.as_bytes().len() <= 4096, "Peer proof is too large");
|
||||
let raw = base64::engine::general_purpose::STANDARD
|
||||
.decode(value.as_bytes())
|
||||
.context("Invalid peer proof encoding")?;
|
||||
let proof: Proof = serde_json::from_slice(&raw).context("Invalid peer proof")?;
|
||||
let range = headers
|
||||
.get("range")
|
||||
.map(|value| value.to_str())
|
||||
.transpose()?
|
||||
.unwrap_or("");
|
||||
anyhow::ensure!(
|
||||
proof.audience == audience && proof.path == path && proof.range == range,
|
||||
"Peer proof scope mismatch"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
proof.timestamp.abs_diff(now) <= MAX_AGE,
|
||||
"Peer proof expired or clock differs"
|
||||
);
|
||||
let key = VerifyingKey::from_bytes(&crate::identity::pubkey_bytes_from_did_key(&proof.did)?)?;
|
||||
let bytes = hex::decode(&proof.signature).context("Invalid peer signature")?;
|
||||
let signature = Signature::from_slice(&bytes)?;
|
||||
key.verify_strict(&proof.preimage()?, &signature)
|
||||
.context("Peer signature rejected")?;
|
||||
Ok(Some(proof.did))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn proof_is_bound_to_signer_recipient_path_range_and_time() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let identity = crate::identity::NodeIdentity::load_or_create(dir.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let audience = crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap();
|
||||
let mut headers = hyper::HeaderMap::new();
|
||||
headers.insert(
|
||||
HEADER,
|
||||
sign(&identity, &audience, "/content/film", "bytes=0-9", 1000)
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers.insert("range", "bytes=0-9".parse().unwrap());
|
||||
assert_eq!(
|
||||
incoming(&headers, &audience, "/content/film", 1000).unwrap(),
|
||||
Some(identity.did_key().unwrap())
|
||||
);
|
||||
for (recipient, path, time) in [
|
||||
(&audience[..], "/content/other", 1000),
|
||||
(&audience[..], "/content/film", 1061),
|
||||
(&audience[..], "/content/film", 939),
|
||||
("other", "/content/film", 1000),
|
||||
] {
|
||||
assert!(incoming(&headers, recipient, path, time).is_err());
|
||||
}
|
||||
headers.insert("range", "bytes=10-".parse().unwrap());
|
||||
assert!(incoming(&headers, &audience, "/content/film", 1000).is_err());
|
||||
headers.insert("range", "bytes=0-9".parse().unwrap());
|
||||
let mut proof: Proof = serde_json::from_slice(
|
||||
&base64::engine::general_purpose::STANDARD
|
||||
.decode(headers[HEADER].as_bytes())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
proof.did = audience.clone();
|
||||
headers.insert(
|
||||
HEADER,
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.encode(serde_json::to_vec(&proof).unwrap())
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(incoming(&headers, &audience, "/content/film", 1000).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_signing_never_creates_or_repairs_node_identity() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("identity");
|
||||
assert!(crate::identity::NodeIdentity::load_existing(&missing)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(!missing.exists());
|
||||
tokio::fs::create_dir(&missing).await.unwrap();
|
||||
tokio::fs::write(missing.join("node_key"), b"damaged")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(crate::identity::NodeIdentity::load_existing(&missing)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
tokio::fs::read(missing.join("node_key")).await.unwrap(),
|
||||
b"damaged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_invoice_and_bytes_visibility_share_the_same_rules() {
|
||||
use crate::content_server::{visible_to, AccessControl, Availability, ContentItem};
|
||||
let mut item = ContentItem {
|
||||
id: "id".into(),
|
||||
filename: "file".into(),
|
||||
mime_type: "video/mp4".into(),
|
||||
size_bytes: 1,
|
||||
description: String::new(),
|
||||
access: AccessControl::Paid {
|
||||
price_sats: 1,
|
||||
accepted: vec![],
|
||||
},
|
||||
availability: Availability::Specific {
|
||||
peers: vec!["verified-peer".into()],
|
||||
},
|
||||
added_at: String::new(),
|
||||
};
|
||||
assert!(!visible_to(&item, None, false, false));
|
||||
assert!(!visible_to(&item, Some("other-peer"), true, false));
|
||||
assert!(visible_to(&item, Some("verified-peer"), true, false));
|
||||
assert!(visible_to(&item, None, false, true));
|
||||
item.availability = Availability::AllPeers;
|
||||
item.access = AccessControl::PeersOnly;
|
||||
assert!(!visible_to(&item, None, false, false));
|
||||
assert!(!visible_to(&item, Some("not-connected"), false, false));
|
||||
assert!(visible_to(&item, Some("verified-peer"), true, false));
|
||||
item.access = AccessControl::Free;
|
||||
assert!(visible_to(&item, None, false, false));
|
||||
item.availability = Availability::Nobody;
|
||||
assert!(!visible_to(&item, None, false, true));
|
||||
assert!(!visible_to(&item, Some("verified-peer"), true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_claimed_did_never_becomes_an_authenticated_peer() {
|
||||
let mut headers = hyper::HeaderMap::new();
|
||||
headers.insert("x-federation-did", "did:key:claimed".parse().unwrap());
|
||||
assert!(incoming(&headers, "recipient", "/content/file", 1000)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
for invalid in ["bad".to_string(), "A".repeat(4097)] {
|
||||
headers.insert(HEADER, invalid.parse().unwrap());
|
||||
assert!(incoming(&headers, "recipient", "/content/file", 1000).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Tor-based content serving with access control.
|
||||
//! Peer content serving with access control.
|
||||
//!
|
||||
//! Serves only explicitly shared content items to authenticated peers.
|
||||
//! Content items can be free or ecash-gated (gating implemented later).
|
||||
//! Content items can be public, peer-restricted, or gated by verified payment.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -38,7 +38,7 @@ pub enum Availability {
|
||||
/// All connected peers can access.
|
||||
#[default]
|
||||
AllPeers,
|
||||
/// Only specific peers (by onion address).
|
||||
/// Only specific peers (by verified node DID).
|
||||
Specific { peers: Vec<String> },
|
||||
}
|
||||
|
||||
@@ -256,6 +256,28 @@ pub enum ServeResult {
|
||||
RangeNotSatisfiable(u64),
|
||||
}
|
||||
|
||||
/// Shared metadata/payment/bytes visibility gate. `peer_did` must already have
|
||||
/// a verified request signature; a header claim alone must never reach here.
|
||||
pub fn visible_to(
|
||||
item: &ContentItem,
|
||||
peer_did: Option<&str>,
|
||||
known_peer: bool,
|
||||
owner: bool,
|
||||
) -> bool {
|
||||
if matches!(item.availability, Availability::Nobody) {
|
||||
return false;
|
||||
}
|
||||
if owner {
|
||||
return true;
|
||||
}
|
||||
if let Availability::Specific { peers } = &item.availability {
|
||||
if !peer_did.is_some_and(|did| peers.iter().any(|allowed| allowed == did)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
!matches!(item.access, AccessControl::PeersOnly) || known_peer
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -335,22 +357,12 @@ where
|
||||
false
|
||||
};
|
||||
|
||||
// Check availability
|
||||
if !owner_session {
|
||||
match &item.availability {
|
||||
Availability::Nobody => return Ok(ServeResult::NotFound),
|
||||
Availability::Specific { peers } => {
|
||||
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 => {}
|
||||
}
|
||||
if !visible_to(item, peer_did, is_known_peer, owner_session) {
|
||||
return Ok(if matches!(item.availability, Availability::Nobody) {
|
||||
ServeResult::NotFound
|
||||
} else {
|
||||
ServeResult::Forbidden
|
||||
});
|
||||
}
|
||||
|
||||
let file_path = content_file_path(data_dir, item);
|
||||
|
||||
@@ -479,6 +479,29 @@ impl<'a> PeerRequest<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Authenticate peer content without granting trust to a caller-supplied DID.
|
||||
/// Call after setting Range, since the exact range is signed too.
|
||||
pub async fn authenticate_content(mut self, data_dir: &std::path::Path) -> Result<Self> {
|
||||
anyhow::ensure!(
|
||||
self.path == "/content" || self.path.starts_with("/content/"),
|
||||
"Not a content route"
|
||||
);
|
||||
let range = self
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case("range"))
|
||||
.map(|(_, value)| value.as_str())
|
||||
.unwrap_or("");
|
||||
if let Some(proof) =
|
||||
crate::content_auth::outgoing(data_dir, self.onion_host, self.path, range).await?
|
||||
{
|
||||
self.headers
|
||||
.retain(|(name, _)| !name.eq_ignore_ascii_case(crate::content_auth::HEADER));
|
||||
self.headers.push((crate::content_auth::HEADER, proof));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn header(mut self, name: &'a str, value: impl Into<String>) -> Self {
|
||||
self.headers.push((name, value.into()));
|
||||
self
|
||||
|
||||
@@ -72,6 +72,21 @@ impl NodeIdentity {
|
||||
})
|
||||
}
|
||||
|
||||
/// Load an existing identity for outbound requests. Never create or repair a
|
||||
/// key as a side effect of accessing another node.
|
||||
pub async fn load_existing(identity_dir: &Path) -> Result<Self> {
|
||||
let bytes = fs::read(identity_dir.join(NODE_KEY_FILE))
|
||||
.await
|
||||
.context("Existing node identity unavailable")?;
|
||||
let key: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid node key length"))?;
|
||||
Ok(Self {
|
||||
signing_key: SigningKey::from_bytes(&key),
|
||||
_identity_dir: identity_dir.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create node identity from a BIP-39 master seed (deterministic derivation).
|
||||
/// Writes derived key to disk in the same format as load_or_create.
|
||||
/// Also derives and persists the FIPS mesh transport key so the
|
||||
|
||||
@@ -40,6 +40,7 @@ mod ceremony;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod container;
|
||||
mod content_auth;
|
||||
mod content_hash;
|
||||
mod content_indeehub;
|
||||
mod content_invoice;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Peer content request authentication
|
||||
|
||||
Candidate implementation; isolated tests and actual-node qualification remain
|
||||
required. No live deployment or migration acceptance is implied.
|
||||
|
||||
The content routes previously treated `X-Federation-DID` as an authenticated
|
||||
identity. Knowing another node's public DID could therefore satisfy restricted
|
||||
sharing checks. A claimed identifier is no longer used for authorization.
|
||||
|
||||
New requests use `X-Archipelago-Content-Auth`: bounded base64 JSON signed with the
|
||||
existing node Ed25519 key. The versioned signature preimage binds the sender DID,
|
||||
recipient DID, GET method, exact path, exact Range header and timestamp. The
|
||||
receiver uses strict signature verification and a 60-second clock window. This
|
||||
is an Archipelago request-authentication extension, not a new Nostr NIP. It is
|
||||
a short-lived authorization proof for one read scope, not proof of settlement
|
||||
and not a claim of single-use replay protection within that scope/time window.
|
||||
FIPS transport and existing sharing/payment checks remain required.
|
||||
|
||||
Catalog visibility, invoice issuance and file serving use the same sharing gate.
|
||||
Anonymous public content remains available; specific-recipient and peer-only
|
||||
shares require verified identity. Delisted items are never advertised or offered
|
||||
for a new invoice. The local authenticated operator retains the existing owner
|
||||
access rules. Outbound reads never create or repair a missing signing identity.
|
||||
|
||||
Older nodes can still access public shares. Restricted sharing requires both
|
||||
nodes to support the proof; failure must not silently downgrade to a claimed
|
||||
DID or broaden availability. Test signature mutation, recipient/path/range/time
|
||||
changes, missing proofs, private catalog filtering, invoice refusal, legitimate
|
||||
peer streaming and clock skew before deployment. No private live file was used
|
||||
to demonstrate the source finding.
|
||||
|
||||
Separate open review: existing on-chain addresses serve as delivery gate tokens,
|
||||
and bearer ecash needs durable end-to-end receipt/recovery across a lost response
|
||||
or process failure during settlement. These are not resolved by authenticating
|
||||
a request and must not be marked passed by these signature tests.
|
||||
Reference in New Issue
Block a user