Verify scoped peer identity proofs before restricted content access

This commit is contained in:
archipelago
2026-10-06 06:07:55 -04:00
parent 6fba95fe5a
commit a9edcd6b3e
10 changed files with 438 additions and 32 deletions
+75 -10
View File
@@ -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, .. } => {
+3 -3
View File
@@ -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() {
+20
View File
@@ -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")?;
+234
View File
@@ -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());
}
}
}
+30 -18
View File
@@ -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);
}
if !visible_to(item, peer_did, is_known_peer, owner_session) {
return Ok(if matches!(item.availability, Availability::Nobody) {
ServeResult::NotFound
} else {
return Ok(ServeResult::Forbidden);
}
}
Availability::AllPeers => {}
}
ServeResult::Forbidden
});
}
let file_path = content_file_path(data_dir, item);
+23
View File
@@ -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
+15
View File
@@ -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
+1
View File
@@ -40,6 +40,7 @@ mod ceremony;
mod config;
mod constants;
mod container;
mod content_auth;
mod content_hash;
mod content_indeehub;
mod content_invoice;
+35
View File
@@ -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.