diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs index d9453bec..627983e7 100644 --- a/core/archipelago/src/api/handler/content.rs +++ b/core/archipelago/src/api/handler/content.rs @@ -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> { - match content_server::load_catalog(&config.data_dir).await { + fn verified_content_peer( + &self, + path: &str, + headers: &hyper::HeaderMap, + ) -> Result> { + 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, 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> { + 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 = 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> { + pub(super) async fn handle_content_invoice( + &self, + path: &str, + headers: &hyper::HeaderMap, + ) -> Result> { 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> { + pub(super) async fn handle_content_onchain( + &self, + path: &str, + headers: &hyper::HeaderMap, + ) -> Result> { 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, .. } => { diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 5179e000..b941d20b 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -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, diff --git a/core/archipelago/src/api/handler/proxy.rs b/core/archipelago/src/api/handler/proxy.rs index 3e92c5a4..1ee57175 100644 --- a/core/archipelago/src/api/handler/proxy.rs +++ b/core/archipelago/src/api/handler/proxy.rs @@ -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() { diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 601c80a3..bf42e200 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -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")?; diff --git a/core/archipelago/src/content_auth.rs b/core/archipelago/src/content_auth.rs new file mode 100644 index 00000000..111b8ef6 --- /dev/null +++ b/core/archipelago/src/content_auth.rs @@ -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> { + 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 { + 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> { + 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> { + 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()); + } + } +} diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index 30abbbc3..cbc4aeb8 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -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 }, } @@ -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); diff --git a/core/archipelago/src/fips/dial.rs b/core/archipelago/src/fips/dial.rs index 144ff8cd..85ff1247 100644 --- a/core/archipelago/src/fips/dial.rs +++ b/core/archipelago/src/fips/dial.rs @@ -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 { + 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) -> Self { self.headers.push((name, value.into())); self diff --git a/core/archipelago/src/identity.rs b/core/archipelago/src/identity.rs index fb0c6e58..686c8a4e 100644 --- a/core/archipelago/src/identity.rs +++ b/core/archipelago/src/identity.rs @@ -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 { + 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 diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index b8a18bc5..bb036784 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -40,6 +40,7 @@ mod ceremony; mod config; mod constants; mod container; +mod content_auth; mod content_hash; mod content_indeehub; mod content_invoice; diff --git a/docs/peer-content-authentication.md b/docs/peer-content-authentication.md new file mode 100644 index 00000000..a08047a1 --- /dev/null +++ b/docs/peer-content-authentication.md @@ -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.