Qualify durable purchase and media primitives and preserve app launch paths

This commit is contained in:
archipelago
2026-10-06 20:50:44 -04:00
parent a876dc3d0b
commit b52214f7a0
31 changed files with 4417 additions and 83 deletions
+195 -9
View File
@@ -511,6 +511,66 @@ impl<'a> PeerRequest<'a> {
self.authenticate_content(data_dir).await?.send_get().await
}
/// Send a purchase request with the exact serialized bytes bound to its peer
/// proof. The expected seller must match the existing authenticated binding.
/// An ambiguous reply is recovered by the purchase journal, never by this
/// transport replaying a token through another route.
pub(crate) async fn send_content_json<B: serde::Serialize>(
self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
let (request, encoded) = self
.prepare_content_json(data_dir, expected_seller, body)
.await?;
request.send_encoded_json(&encoded).await
}
async fn prepare_content_json<B: serde::Serialize>(
mut self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
) -> Result<(Self, Vec<u8>)> {
anyhow::ensure!(
self.path.starts_with("/content/purchase/"),
"Not a purchase route"
);
let peer = crate::federation::load_unique_payment_peer(data_dir, self.onion_host).await?;
anyhow::ensure!(
peer.did == expected_seller,
"Purchase seller does not match the authenticated peer binding"
);
anyhow::ensure!(
self.fips_npub.is_some_and(|npub| !npub.is_empty())
&& peer.fips_npub.as_deref() == self.fips_npub,
"Purchase mesh route does not match the authenticated peer binding"
);
let identity =
crate::identity::NodeIdentity::load_existing(&data_dir.join("identity")).await?;
let encoded = serde_json::to_vec(body).context("Encode purchase request")?;
anyhow::ensure!(
encoded.len() <= 1024 * 1024,
"Purchase request is too large"
);
let proof = crate::content_auth::sign_request(
&identity,
expected_seller,
&hyper::Method::POST,
self.path,
&encoded,
chrono::Utc::now().timestamp(),
)?;
self.headers
.retain(|(name, _)| !name.eq_ignore_ascii_case(crate::content_auth::REQUEST_HEADER));
self.headers
.push((crate::content_auth::REQUEST_HEADER, proof));
self.single_delivery = true;
self.require_fips = true;
Ok((self, encoded))
}
pub fn header(mut self, name: &'a str, value: impl Into<String>) -> Self {
self.headers.push((name, value.into()));
self
@@ -537,12 +597,21 @@ impl<'a> PeerRequest<'a> {
pub async fn send_json<B: serde::Serialize>(
&self,
body: &B,
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
let encoded = serde_json::to_vec(body).context("Encode peer JSON request")?;
self.send_encoded_json(&encoded).await
}
// Serialize once: FIPS attempts and any permitted fallback use identical bytes.
async fn send_encoded_json(
&self,
body: &[u8],
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
use crate::settings::transport::TransportPref;
let pref = self.preference().await;
// FIPS-only or Auto: try FIPS first.
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
match self.try_fips_post_json(body).await? {
match self.try_fips_post_bytes(body).await? {
Some(resp) => {
// Use the FIPS reply unless it's one a Tor retry could
// fix (404 path-not-served / 5xx) and we're allowed to
@@ -574,7 +643,7 @@ impl<'a> PeerRequest<'a> {
}
}
}
let resp = self.send_tor_post_json(body).await?;
let resp = self.send_tor_post_bytes(body).await?;
self.spawn_record(crate::transport::TransportKind::Tor);
Ok((resp, crate::transport::TransportKind::Tor))
}
@@ -618,10 +687,7 @@ impl<'a> PeerRequest<'a> {
Ok((resp, crate::transport::TransportKind::Tor))
}
async fn try_fips_post_json<B: serde::Serialize>(
&self,
body: &B,
) -> Result<Option<reqwest::Response>> {
async fn try_fips_post_bytes(&self, body: &[u8]) -> Result<Option<reqwest::Response>> {
let Some(npub) = self.fips_npub else {
telemetry::record_fallback(FallbackReason::NoNpub);
return Ok(None);
@@ -657,7 +723,10 @@ impl<'a> PeerRequest<'a> {
budget
};
let c = client_with_delivery_policy(per_attempt, self.single_delivery || self.require_fips);
let mut rb = c.post(&url).json(body);
let mut rb = c
.post(&url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.to_vec());
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
@@ -770,10 +839,13 @@ impl<'a> PeerRequest<'a> {
}
}
async fn send_tor_post_json<B: serde::Serialize>(&self, body: &B) -> Result<reqwest::Response> {
async fn send_tor_post_bytes(&self, body: &[u8]) -> Result<reqwest::Response> {
let url = self.tor_url();
let client = self.tor_client()?;
let mut rb = client.post(&url).json(body);
let mut rb = client
.post(&url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.to_vec());
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
@@ -815,6 +887,120 @@ impl<'a> PeerRequest<'a> {
mod tests {
use super::*;
#[tokio::test]
async fn purchase_post_serializes_once_and_binds_the_actual_bytes_to_the_peer() {
use std::sync::atomic::{AtomicUsize, Ordering};
struct Counted(AtomicUsize);
impl serde::Serialize for Counted {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_u64(self.0.fetch_add(1, Ordering::SeqCst) as u64)
}
}
let dir = tempfile::tempdir().unwrap();
let identity = crate::identity::NodeIdentity::load_or_create(&dir.path().join("identity"))
.await
.unwrap();
let seller = crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap();
tokio::fs::create_dir(dir.path().join("federation"))
.await
.unwrap();
let peer = serde_json::json!({"did":seller,"pubkey":hex::encode([7;32]),"onion":"seller.onion",
"trust_level":"trusted","added_at":"2026-10-06T00:00:00Z","fips_npub":"npub-test-seller"});
tokio::fs::write(
dir.path().join("federation/nodes.json"),
serde_json::to_vec(&serde_json::json!({"nodes":[peer.clone()]})).unwrap(),
)
.await
.unwrap();
let body = Counted(AtomicUsize::new(0));
let path = "/content/purchase/settle";
let (request, bytes) = PeerRequest::new(Some("npub-test-seller"), "seller.onion", path)
.prepare_content_json(dir.path(), &seller, &body)
.await
.unwrap();
assert_eq!(body.0.load(Ordering::SeqCst), 1);
assert_eq!(bytes, b"0");
assert!(request.require_fips && request.single_delivery);
let mut headers = hyper::HeaderMap::new();
for (key, value) in request.headers {
headers.insert(
hyper::header::HeaderName::from_bytes(key.as_bytes()).unwrap(),
value.parse().unwrap(),
);
}
assert_eq!(
crate::content_auth::authenticate_request(
&headers,
&seller,
&hyper::Method::POST,
path,
&bytes,
chrono::Utc::now().timestamp()
)
.unwrap(),
identity.did_key().unwrap()
);
assert!(crate::content_auth::authenticate_request(
&headers,
&seller,
&hyper::Method::POST,
path,
b"1",
chrono::Utc::now().timestamp()
)
.is_err());
for (npub, did) in [
(Some("wrong-route"), seller.as_str()),
(None, seller.as_str()),
(Some("npub-test-seller"), "wrong-seller"),
] {
assert!(PeerRequest::new(npub, "seller.onion", path)
.prepare_content_json(dir.path(), did, &body)
.await
.is_err());
}
assert_eq!(body.0.load(Ordering::SeqCst), 1);
tokio::fs::write(
dir.path().join("federation/nodes.json"),
serde_json::to_vec(&serde_json::json!({"nodes":[peer.clone(),peer.clone()]})).unwrap(),
)
.await
.unwrap();
assert!(
PeerRequest::new(Some("npub-test-seller"), "seller.onion", path)
.prepare_content_json(dir.path(), &seller, &body)
.await
.is_err()
);
let mut conflicting = peer.clone();
conflicting["did"] = serde_json::json!(crate::identity::did_key_from_pubkey_hex(
&hex::encode([8; 32])
)
.unwrap());
conflicting["pubkey"] = serde_json::json!(hex::encode([8; 32]));
conflicting["onion"] = serde_json::json!("seller");
let mut wrong_key = peer.clone();
wrong_key["pubkey"] = serde_json::json!(hex::encode([8; 32]));
for nodes in [
serde_json::json!([peer, conflicting]),
serde_json::json!([wrong_key]),
] {
tokio::fs::write(
dir.path().join("federation/nodes.json"),
serde_json::to_vec(&serde_json::json!({"nodes": nodes})).unwrap(),
)
.await
.unwrap();
assert!(
PeerRequest::new(Some("npub-test-seller"), "seller.onion", path)
.prepare_content_json(dir.path(), &seller, &body)
.await
.is_err()
);
}
assert_eq!(body.0.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn required_media_never_falls_back_when_peer_has_no_fips_identity() {
let request = PeerRequest::new(None, "unreachable.onion", "/content/video").require_fips();