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}; use super::{is_valid_app_id, ApiHandler};
impl ApiHandler { impl ApiHandler {
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> { fn verified_content_peer(
match content_server::load_catalog(&config.data_dir).await { &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) => { Ok(catalog) => {
// Only expose public metadata for available items // Only expose public metadata for available items
let items: Vec<serde_json::Value> = catalog let items: Vec<serde_json::Value> = catalog
.items .items
.iter() .iter()
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody)) .filter(|item| content_server::visible_to(item, peer.as_deref(), known, owner))
.map(|i| { .map(|i| {
serde_json::json!({ serde_json::json!({
"id": i.id, "id": i.id,
@@ -82,11 +116,18 @@ impl ApiHandler {
.map(|s| s.to_string()) .map(|s| s.to_string())
}); });
// Extract federation peer DID from X-Federation-DID header let peer_did = match self.verified_content_peer(path, headers) {
let peer_did = headers Ok(peer) => peer,
.get("x-federation-did") Err(_) => {
.and_then(|v| v.to_str().ok()) return Ok(build_response(
.map(|s| s.to_string()); 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 // The authenticated local operator never pays for their own node's
// content: validate the session cookie (same discipline as the model // 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 /// 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. /// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
/// Records a pending entitlement keyed by the invoice's payment hash. /// 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 let content_id = path
.strip_prefix("/content/") .strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/invoice")) .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) let catalog = content_server::load_catalog(&self.config.data_dir)
.await .await
.unwrap_or_default(); .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 { let price_sats = match &item.access {
content_server::AccessControl::Paid { price_sats, .. } => *price_sats, 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 /// 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 /// 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. /// 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 let content_id = path
.strip_prefix("/content/") .strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/onchain")) .and_then(|s| s.strip_suffix("/onchain"))
@@ -371,9 +428,17 @@ impl ApiHandler {
hyper::Body::from("Invalid content ID"), 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) let catalog = content_server::load_catalog(&self.config.data_dir)
.await .await
.unwrap_or_default(); .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) { let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
Some(i) => match &i.access { Some(i) => match &i.access {
content_server::AccessControl::Paid { price_sats, .. } => { 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 // Lightning-invoice peer-file sale (#46): mint invoice / poll settlement
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => { (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/") => { (Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => {
self.handle_content_invoice_status(p).await self.handle_content_invoice_status(p).await
@@ -602,7 +602,7 @@ impl ApiHandler {
self.handle_content_onchain_status(p).await self.handle_content_onchain_status(p).await
} }
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => { (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); // 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) // 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) // Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await, (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()) { if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
req = req.header("Range", r.to_string()); req = req.header("Range", r.to_string());
} }
let req = req.authenticate_content(&self.config.data_dir).await?;
match req.send_get().await { match req.send_get().await {
Ok((resp, transport)) => { Ok((resp, transport)) => {
if resp.status().is_redirection() { if resp.status().is_redirection() {
+20
View File
@@ -450,6 +450,8 @@ impl RpcHandler {
.header("X-Federation-DID", local_did) .header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(120)) .timeout(std::time::Duration::from_secs(120))
.fips_timeout(std::time::Duration::from_secs(8)) .fips_timeout(std::time::Duration::from_secs(8))
.authenticate_content(&self.config.data_dir)
.await?
.send_get() .send_get()
.await .await
.context("Failed to connect to peer")?; .context("Failed to connect to peer")?;
@@ -540,6 +542,8 @@ impl RpcHandler {
// against the UI's 30s deadline — users saw errors, not // against the UI's 30s deadline — users saw errors, not
// fallback. // fallback.
.fips_timeout(std::time::Duration::from_secs(6)) .fips_timeout(std::time::Duration::from_secs(6))
.authenticate_content(&self.config.data_dir)
.await?
.send_get() .send_get()
.await .await
.context("Failed to connect to peer")?; .context("Failed to connect to peer")?;
@@ -710,6 +714,8 @@ impl RpcHandler {
.header("X-Payment-Token", token_str.clone()) .header("X-Payment-Token", token_str.clone())
.single_delivery() .single_delivery()
.timeout(std::time::Duration::from_secs(900)) .timeout(std::time::Duration::from_secs(900))
.authenticate_content(&self.config.data_dir)
.await?
.send_get() .send_get()
.await .await
{ {
@@ -829,6 +835,8 @@ impl RpcHandler {
.header("X-Federation-DID", local_did) .header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(25)) .timeout(std::time::Duration::from_secs(25))
.fips_timeout(std::time::Duration::from_secs(6)) .fips_timeout(std::time::Duration::from_secs(6))
.authenticate_content(&self.config.data_dir)
.await?
.send_get() .send_get()
.await .await
{ {
@@ -893,6 +901,8 @@ impl RpcHandler {
.service(crate::settings::transport::PeerService::PeerFiles) .service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(15)) .timeout(std::time::Duration::from_secs(15))
.fips_timeout(std::time::Duration::from_secs(6)) .fips_timeout(std::time::Duration::from_secs(6))
.authenticate_content(&self.config.data_dir)
.await?
.send_get() .send_get()
.await .await
{ {
@@ -985,6 +995,8 @@ impl RpcHandler {
.header("X-Federation-DID", local_did) .header("X-Federation-DID", local_did)
.header("X-Invoice-Hash", payment_hash.to_string()) .header("X-Invoice-Hash", payment_hash.to_string())
.timeout(std::time::Duration::from_secs(900)) .timeout(std::time::Duration::from_secs(900))
.authenticate_content(&self.config.data_dir)
.await?
.send_get() .send_get()
.await .await
{ {
@@ -1083,6 +1095,8 @@ impl RpcHandler {
.header("X-Federation-DID", local_did) .header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(25)) .timeout(std::time::Duration::from_secs(25))
.fips_timeout(std::time::Duration::from_secs(6)) .fips_timeout(std::time::Duration::from_secs(6))
.authenticate_content(&self.config.data_dir)
.await?
.send_get() .send_get()
.await .await
{ {
@@ -1142,6 +1156,8 @@ impl RpcHandler {
.service(crate::settings::transport::PeerService::PeerFiles) .service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(15)) .timeout(std::time::Duration::from_secs(15))
.fips_timeout(std::time::Duration::from_secs(6)) .fips_timeout(std::time::Duration::from_secs(6))
.authenticate_content(&self.config.data_dir)
.await?
.send_get() .send_get()
.await .await
{ {
@@ -1199,6 +1215,8 @@ impl RpcHandler {
.header("X-Federation-DID", local_did) .header("X-Federation-DID", local_did)
.header("X-Onchain-Address", address.to_string()) .header("X-Onchain-Address", address.to_string())
.timeout(std::time::Duration::from_secs(900)) .timeout(std::time::Duration::from_secs(900))
.authenticate_content(&self.config.data_dir)
.await?
.send_get() .send_get()
.await .await
{ {
@@ -1279,6 +1297,8 @@ impl RpcHandler {
.require_fips() .require_fips()
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6)) .fips_timeout(std::time::Duration::from_secs(6))
.authenticate_content(&self.config.data_dir)
.await?
.send_get() .send_get()
.await .await
.context("Failed to connect to peer for preview")?; .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());
}
}
}
+31 -19
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. //! 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 anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -38,7 +38,7 @@ pub enum Availability {
/// All connected peers can access. /// All connected peers can access.
#[default] #[default]
AllPeers, AllPeers,
/// Only specific peers (by onion address). /// Only specific peers (by verified node DID).
Specific { peers: Vec<String> }, Specific { peers: Vec<String> },
} }
@@ -256,6 +256,28 @@ pub enum ServeResult {
RangeNotSatisfiable(u64), 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. /// 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. /// 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). /// `peer_did` is the DID from the X-Federation-DID header (if present).
@@ -335,22 +357,12 @@ where
false false
}; };
// Check availability if !visible_to(item, peer_did, is_known_peer, owner_session) {
if !owner_session { return Ok(if matches!(item.availability, Availability::Nobody) {
match &item.availability { ServeResult::NotFound
Availability::Nobody => return Ok(ServeResult::NotFound), } else {
Availability::Specific { peers } => { ServeResult::Forbidden
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 => {}
}
} }
let file_path = content_file_path(data_dir, item); let file_path = content_file_path(data_dir, item);
+23
View File
@@ -479,6 +479,29 @@ impl<'a> PeerRequest<'a> {
self 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 { pub fn header(mut self, name: &'a str, value: impl Into<String>) -> Self {
self.headers.push((name, value.into())); self.headers.push((name, value.into()));
self 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). /// Create node identity from a BIP-39 master seed (deterministic derivation).
/// Writes derived key to disk in the same format as load_or_create. /// Writes derived key to disk in the same format as load_or_create.
/// Also derives and persists the FIPS mesh transport key so the /// Also derives and persists the FIPS mesh transport key so the
+1
View File
@@ -40,6 +40,7 @@ mod ceremony;
mod config; mod config;
mod constants; mod constants;
mod container; mod container;
mod content_auth;
mod content_hash; mod content_hash;
mod content_indeehub; mod content_indeehub;
mod content_invoice; 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.