Integrate recoverable native purchases, registered rentals and explicit payment consent

This commit is contained in:
archipelago
2026-10-06 22:44:06 -04:00
parent e4eae71314
commit 49703d7e88
63 changed files with 8028 additions and 134 deletions
@@ -0,0 +1,142 @@
//! Permanent Cloud snapshot delivery. Current source path/share state cannot
//! revoke a settled immutable snapshot; no rental clock is started here.
use super::{build_response, ApiHandler};
use crate::{
content_purchase::{Journal, SellerPhase},
content_server::ByteRange,
};
use anyhow::{Context, Result};
use hyper::{Body, HeaderMap, Response, StatusCode};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
impl ApiHandler {
pub(super) async fn handle_cloud_purchase(
&self,
path: &str,
headers: &HeaderMap,
) -> Result<Response<Body>> {
let (content_id, purchase_id) = path
.strip_prefix("/content/")
.and_then(|value| value.split_once("/purchase/"))
.context("Invalid purchase delivery route")?;
anyhow::ensure!(
!content_id.contains('/')
&& !content_id.starts_with("registered_")
&& !purchase_id.contains('/'),
"Invalid Cloud delivery route"
);
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
let buyer = crate::content_auth::incoming(
headers,
&audience,
path,
chrono::Utc::now().timestamp(),
)?
.context("Authenticated peer proof is required")?;
let mut values = headers.get_all("x-content-capability").iter();
let capability = values
.next()
.context("Delivery capability is required")?
.to_str()?;
anyhow::ensure!(values.next().is_none(), "Duplicate delivery capability");
let (contract, mime) = {
let journal = Journal::open(&self.config.data_dir).await?;
let record = journal
.seller(purchase_id)
.await?
.context("Purchase settlement not found")?;
let receipt = match record.phase {
SellerPhase::ReceiptSaved(receipt) => receipt,
_ => anyhow::bail!("Purchase settlement is not durable"),
};
anyhow::ensure!(
record.contract.buyer_did == buyer
&& record.contract.seller_did == audience
&& record.contract.content_id == content_id
&& receipt.capability == capability,
"Purchase delivery binding changed"
);
let envelope = journal
.protocol_envelope("seller", purchase_id)
.await?
.context("Original delivery metadata is missing")?;
anyhow::ensure!(
envelope.contract()? == record.contract,
"Delivery metadata binding changed"
);
(record.contract, envelope.offer.mime_type)
};
let range = headers
.get("range")
.map(|value| -> Result<_> {
crate::content_server::parse_range_header(value.to_str()?).context("Invalid range")
})
.transpose()?;
let total = contract.content_size;
let (start, end, partial) = match range {
None => (0, total - 1, false),
Some(ByteRange::From { start, end }) => {
(start, end.unwrap_or(total - 1).min(total - 1), true)
}
Some(ByteRange::Suffix(count)) if count > 0 => {
(total.saturating_sub(count), total - 1, true)
}
_ => anyhow::bail!("Invalid range"),
};
if start > end || start >= total {
let mut response = build_response(
StatusCode::RANGE_NOT_SATISFIABLE,
"text/plain",
Body::empty(),
);
response
.headers_mut()
.insert("content-range", format!("bytes */{total}").parse()?);
return Ok(response);
}
let data = self.config.data_dir.clone();
let file = tokio::task::spawn_blocking(move || {
crate::content_snapshot::open_matching(
&data,
&contract.content_id,
&contract.content_sha256,
contract.content_size,
)
})
.await??;
let mut file = tokio::fs::File::from_std(file.file);
file.seek(std::io::SeekFrom::Start(start)).await?;
let length = end - start + 1;
let chunks =
futures_util::stream::try_unfold((file, length), |(mut file, left)| async move {
if left == 0 {
return Ok::<_, std::io::Error>(None);
}
let mut bytes = vec![0; left.min(65536) as usize];
let count = file.read(&mut bytes).await?;
if count == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"Purchase snapshot ended early",
));
}
bytes.truncate(count);
Ok(Some((bytes, (file, left - count as u64))))
});
let mut response = Response::builder()
.status(if partial {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
})
.header("content-type", mime)
.header("content-length", length)
.header("accept-ranges", "bytes")
.header("cache-control", "private, no-store")
.header("x-content-type-options", "nosniff")
.header("content-security-policy", "sandbox; default-src 'none'");
if partial {
response = response.header("content-range", format!("bytes {start}-{end}/{total}"));
}
Ok(response.body(Body::wrap_stream(chunks))?)
}
}
+6 -6
View File
@@ -416,7 +416,7 @@ impl ApiHandler {
path: &str,
) -> Result<Response<hyper::Body>> {
Ok(invoice_status_response(path, |hash, id| async move {
self.rpc_handler.settle_content_invoice(&hash, &id).await
self.rpc_handler.content_invoice_lifecycle(&hash, &id).await
})
.await)
}
@@ -663,7 +663,7 @@ impl ApiHandler {
async fn invoice_status_response<F, Fut>(path: &str, settle: F) -> Response<hyper::Body>
where
F: FnOnce(String, String) -> Fut,
Fut: std::future::Future<Output = Result<bool>>,
Fut: std::future::Future<Output = Result<serde_json::Value>>,
{
let parsed = path
.strip_prefix("/content/")
@@ -682,10 +682,10 @@ where
);
};
match settle(hash.to_ascii_lowercase(), id.to_owned()).await {
Ok(paid) => build_response(
Ok(body) => build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::json!({"paid": paid}).to_string()),
hyper::Body::from(body.to_string()),
),
Err(_) => {
tracing::warn!("Peer-file payment status verification is temporarily unavailable");
@@ -721,7 +721,7 @@ mod invoice_status_tests {
let response = invoice_status_response(path, |_, _| async {
panic!("Invalid request reached wallet");
#[allow(unreachable_code)]
Ok(false)
Ok(serde_json::json!({"paid":false}))
})
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
@@ -741,7 +741,7 @@ mod invoice_status_tests {
let response = invoice_status_response(&path, |hash, id| async move {
assert_eq!(hash, "ab".repeat(32));
assert_eq!(id, "file");
Ok(paid)
Ok(serde_json::json!({"paid":paid}))
})
.await;
assert_eq!(response.status(), StatusCode::OK);
+18
View File
@@ -1,7 +1,10 @@
mod purchase;
mod cloud_purchase;
mod blob;
mod cdp;
mod content;
mod registered_media;
mod rental_playback;
mod dwn;
mod model_proxy;
mod node_message;
@@ -385,6 +388,10 @@ impl ApiHandler {
let path = req.uri().path().to_string();
let method = req.method().clone();
if path.starts_with("/api/rental-playback/") {
return self.handle_local_rental_request(&method, &path, req.headers()).await;
}
// Handle CORS preflight for all routes
if method == Method::OPTIONS {
let mut builder = Response::builder()
@@ -445,6 +452,14 @@ impl ApiHandler {
.await;
}
// Purchase routes bound the original body before the generic buffer.
if method == Method::POST && matches!(path.as_str(),
crate::content_purchase_protocol::OFFER_ROUTE | crate::content_purchase_protocol::ACCEPT_ROUTE
| crate::content_purchase_protocol::SETTLE_ROUTE | crate::content_purchase_protocol::STATUS_ROUTE
| crate::content_purchase_protocol::CANCEL_ROUTE) {
return self.handle_purchase_request(req).await;
}
// Convert body to bytes for non-WS routes
let headers = req.headers().clone();
let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default();
@@ -587,6 +602,9 @@ impl ApiHandler {
// Immutable registered rentals use durable seller receipts and their
// first-open window, never legacy mutable filename shares.
(Method::GET, p) if p.starts_with("/content/") && p.contains("/purchase/") => {
self.handle_cloud_purchase(p, &headers).await
}
(Method::GET, p) if p.starts_with("/content/registered_") && p.contains("/rental/") => {
self.handle_registered_rental(p, &headers).await
}
@@ -0,0 +1,170 @@
//! Add as api/handler/purchase.rs; dispatch only exact supported POST routes.
use super::{build_response, ApiHandler};
use crate::{
content_purchase::Journal, content_purchase_protocol as protocol, identity::NodeIdentity,
};
use anyhow::{Context, Result};
use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OfferRequest {
id: String,
content_id: String,
}
impl ApiHandler {
pub(super) async fn handle_purchase_request(
&self,
mut request: Request<Body>,
) -> Result<Response<Body>> {
let path = request.uri().path().to_owned();
anyhow::ensure!(
request.method() == Method::POST
&& matches!(
path.as_str(),
protocol::OFFER_ROUTE
| protocol::ACCEPT_ROUTE
| protocol::SETTLE_ROUTE
| protocol::STATUS_ROUTE
| protocol::CANCEL_ROUTE
),
"Unsupported purchase route"
);
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
let mut bytes = Vec::new();
while let Some(chunk) = request.body_mut().data().await {
let chunk = chunk?;
anyhow::ensure!(
bytes
.len()
.checked_add(chunk.len())
.is_some_and(|n| n <= 1024 * 1024),
"Purchase body too large"
);
bytes.extend_from_slice(&chunk);
}
Ok::<_, anyhow::Error>(bytes)
})
.await
.context("Purchase body timed out")??;
let buyer = crate::content_auth::authenticate_request(
request.headers(),
&audience,
&Method::POST,
&path,
&bytes,
chrono::Utc::now().timestamp(),
)?;
let data_dir = &self.config.data_dir;
let result = match path.as_str() {
protocol::OFFER_ROUTE => {
let body: OfferRequest = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
uuid::Uuid::parse_str(&body.id)?.to_string() == body.id,
"Invalid operation identifier"
);
let saved = {
Journal::open(data_dir)
.await?
.protocol_offer(&body.id)
.await?
};
let offer = if let Some(saved) = saved {
anyhow::ensure!(
saved.buyer_did == buyer && saved.content_id == body.content_id,
"Original offer binding changed"
);
saved
} else {
// Registration pins and immutable snapshot are node-owned;
// no content hash/price/path is accepted from the request.
if !body.content_id.starts_with("registered_") {
let wallet = crate::wallet::ecash::load_wallet(data_dir).await?;
let offer = crate::content_cloud_offer::offer(
data_dir,
&body.id,
&body.content_id,
&buyer,
&audience,
crate::wallet::ecash::load_network(data_dir).await?,
wallet.mint_url.trim_end_matches('/'),
crate::content_cloud_offer::SnapshotPolicy {
max_file_bytes: 64 * 1024 * 1024 * 1024,
max_total_bytes: 64 * 1024 * 1024 * 1024,
minimum_free_bytes: 512 * 1024 * 1024,
},
)
.await?;
return Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&offer)?),
));
}
let identity = NodeIdentity::load_existing(&data_dir.join("identity")).await?;
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
let selected = body.content_id.clone();
let root = data_dir.clone();
let (receipt, terms) = tokio::task::spawn_blocking(move || {
crate::registered_media::registered_terms(&root, &identity, &selected)
})
.await??;
anyhow::ensure!(
receipt
.payment_methods
.iter()
.any(|method| method == "cashu"),
"Content does not accept Cashu"
);
let now = chrono::Utc::now().timestamp();
let deadline = now.checked_add(120).context("Offer clock overflow")?;
let wallet = crate::wallet::ecash::load_wallet(data_dir).await?;
let offer = protocol::Offer {
id: body.id,
buyer_did: buyer.clone(),
seller_did: audience.clone(),
filename: receipt.content_id.clone(),
mime_type: "application/octet-stream".into(),
content_id: receipt.content_id,
content_sha256: receipt.sha256,
content_size: receipt.size_bytes.parse()?,
viewing_seconds: Some(receipt.viewing_seconds),
terms_sha256: terms,
network: crate::wallet::ecash::load_network(data_dir).await?,
mint_url: wallet.mint_url.trim_end_matches('/').to_owned(),
seller_net_sats: receipt.price_sats,
offered_at: now,
expires_at: deadline,
};
protocol::save_offer(data_dir, &offer, &buyer, now).await?
};
protocol::ensure_seller_mint_policy(data_dir, offer.network, &offer.mint_url)
.await?;
serde_json::to_value(offer)?
}
protocol::ACCEPT_ROUTE => serde_json::to_value(
protocol::accept(data_dir, &serde_json::from_slice(&bytes)?, &buyer, || {
chrono::Utc::now().timestamp()
})
.await?,
)?,
protocol::SETTLE_ROUTE => serde_json::to_value(
protocol::settle(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?,
)?,
protocol::CANCEL_ROUTE => serde_json::to_value(
protocol::cancel(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?,
)?,
protocol::STATUS_ROUTE => serde_json::to_value(
protocol::status(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?,
)?,
_ => unreachable!(),
};
Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&result)?),
))
}
}
@@ -0,0 +1,548 @@
//! Local browser playback. Only opaque local handles cross the browser boundary.
use super::{build_response, ApiHandler};
use crate::{content_purchase::Journal, content_server::ByteRange, identity::NodeIdentity};
use anyhow::{Context, Result};
use hyper::{Body, HeaderMap, Method, Response, StatusCode};
use std::{
io,
sync::Arc,
time::{Duration, Instant},
};
fn requested_bounds(headers: &HeaderMap, total: u64) -> Result<Option<(u64, u64)>> {
anyhow::ensure!(total > 0, "Empty purchased media");
anyhow::ensure!(
headers.get_all("range").iter().count() <= 1,
"Ambiguous playback ranges"
);
let Some(header) = headers.get("range") else {
return Ok(None);
};
let range = crate::content_server::parse_range_header(header.to_str()?)
.context("Invalid playback byte range")?;
let last = total - 1;
let (start, end) = match range {
ByteRange::From { start, end } => (start, end.unwrap_or(last).min(last)),
ByteRange::Suffix(count) => {
anyhow::ensure!(count > 0, "Invalid byte range");
(total.saturating_sub(count), last)
}
};
anyhow::ensure!(start <= end && start < total, "Invalid playback byte range");
Ok(Some((start, end)))
}
fn validate_upstream(
status: u16,
headers: &HeaderMap,
total: u64,
bounds: Option<(u64, u64)>,
now: u64,
) -> Result<(u16, u64, String, u64)> {
let expected_status = if bounds.is_some() { 206 } else { 200 };
anyhow::ensure!(
status == expected_status,
"Seller returned another range status"
);
let length = bounds.map_or(total, |(start, end)| end - start + 1);
anyhow::ensure!(
headers
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
== Some(length),
"Seller changed purchased byte length"
);
if let Some((start, end)) = bounds {
let expected = format!("bytes {start}-{end}/{total}");
anyhow::ensure!(
headers.get("content-range").and_then(|v| v.to_str().ok()) == Some(expected.as_str()),
"Seller changed purchased byte range"
);
}
let mime = headers
.get("content-type")
.context("Missing media type")?
.to_str()?
.to_owned();
anyhow::ensure!(
mime.starts_with("video/") || mime.starts_with("audio/"),
"Unsupported rental media type"
);
let expires = headers
.get("x-rental-expires-at")
.context("Missing rental expiry")?
.to_str()?
.parse::<u64>()?;
anyhow::ensure!(expires > now, "Rental viewing window ended");
Ok((expected_status, length, mime, expires))
}
fn installed_playback_origin(
origin: &str,
expected: &str,
host: &str,
gated_tls_port: bool,
) -> bool {
let (Ok(actual), Ok(expected), Ok(request)) = (
reqwest::Url::parse(origin),
reqwest::Url::parse(expected),
reqwest::Url::parse(&format!("http://{host}")),
) else {
return false;
};
if !matches!(actual.scheme(), "http" | "https")
|| actual.origin().ascii_serialization() != origin
|| request.path() != "/"
|| !request.username().is_empty()
|| request.password().is_some()
|| request.query().is_some()
|| request.fragment().is_some()
{
return false;
}
if actual.origin() == expected.origin() {
return true;
}
let same_port = actual.port_or_known_default() == expected.port_or_known_default();
let scheme = actual.scheme() == expected.scheme()
|| (gated_tls_port && actual.scheme() == "https" && expected.scheme() == "http");
let expected_loopback = matches!(
expected.host_str(),
Some("localhost" | "127.0.0.1" | "[::1]")
);
same_port
&& scheme
&& actual.host_str() == request.host_str()
&& (expected_loopback || actual.host_str() == expected.host_str())
}
fn unix_now() -> Result<u64> {
Ok(std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs())
}
fn stream_error(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::PermissionDenied, message)
}
impl ApiHandler {
pub(super) async fn handle_local_rental_request(
&self,
method: &Method,
path: &str,
headers: &HeaderMap,
) -> Result<Response<Body>> {
// HEAD is deliberately not GET: a browser probe must never open a lease.
if method != Method::GET && method != Method::OPTIONS {
return Ok(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET, OPTIONS")
.header("Cache-Control", "no-store")
.body(Body::empty())?);
}
let origin = headers.get("origin").map(|v| v.to_str()).transpose()?;
if let Some(origin) = origin {
let identity =
NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?;
let (state, _) = self.state_manager.get_snapshot().await;
let root = self.config.data_dir.clone();
let context = tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&root, &identity, &state)
})
.await??;
let host = headers
.get("host")
.and_then(|value| value.to_str().ok())
.unwrap_or("");
let port = reqwest::Url::parse(origin)
.ok()
.and_then(|url| url.port_or_known_default());
let ports = self.rpc_handler.app_gate.port_map().await;
let gated_tls_port = port
.and_then(|port| ports.gated(port))
.is_some_and(|gate| gate.app_id == context.app_id && gate.declared);
if !context
.app_origins
.iter()
.any(|allowed| installed_playback_origin(origin, allowed, host, gated_tls_port))
{
return Ok(build_response(
StatusCode::FORBIDDEN,
"text/plain",
Body::from("Playback origin is not the installed app"),
));
}
}
let mut response = if method == Method::OPTIONS {
Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())?
} else {
self.handle_local_rental(path, headers).await?
};
response
.headers_mut()
.insert("Cache-Control", "private, no-store".parse()?);
response.headers_mut().insert("Vary", "Origin".parse()?);
if let Some(origin) = origin {
response
.headers_mut()
.insert("Access-Control-Allow-Origin", origin.parse()?);
response
.headers_mut()
.insert("Access-Control-Allow-Credentials", "true".parse()?);
response
.headers_mut()
.insert("Access-Control-Allow-Methods", "GET, OPTIONS".parse()?);
response
.headers_mut()
.insert("Access-Control-Allow-Headers", "Range".parse()?);
response.headers_mut().insert(
"Access-Control-Expose-Headers",
"Content-Length, Content-Range, Accept-Ranges, X-Rental-Expires-At".parse()?,
);
}
Ok(response)
}
/// Dispatcher accepts GET only after normal session handling. HEAD and other
/// methods never reach upstream, so metadata probes cannot start a lease.
pub(super) async fn handle_local_rental(
&self,
path: &str,
headers: &HeaderMap,
) -> Result<Response<Body>> {
let token = match crate::session::extract_session_cookie(headers) {
Some(token) if self.session_store.validate(&token).await => token,
_ => return Ok(Self::unauthorized()),
};
let handle = path
.strip_prefix("/api/rental-playback/")
.context("Invalid playback route")?;
let identity =
Arc::new(NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?);
let (state, _) = self.state_manager.get_snapshot().await;
let root = self.config.data_dir.clone();
let key = identity.clone();
let context = tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&root, &key, &state)
})
.await??;
let binding = self
.rpc_handler
.playback_handles()
.lookup(handle, &token, &context)?;
let capability = {
let journal = Journal::open(&self.config.data_dir).await?;
let record = journal
.buyer(&binding.contract.id)
.await?
.context("Original purchase is missing")?;
anyhow::ensure!(
record.contract == binding.contract,
"Original purchase changed"
);
record
.receipt()
.context("Original purchase is not settled")?
.capability
.clone()
};
let peer = crate::federation::load_unique_payment_peer(
&self.config.data_dir,
&binding.seller_onion,
)
.await?;
anyhow::ensure!(
peer.did == binding.contract.seller_did,
"Purchased seller identity changed"
);
let mesh = peer
.fips_npub
.context("Seller mesh binding is unavailable")?;
let total = binding.contract.content_size;
let bounds = match requested_bounds(headers, total) {
Ok(bounds) => bounds,
Err(_) => {
return Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header("Content-Range", format!("bytes */{total}"))
.body(Body::empty())?)
}
};
let remote_path = format!(
"/content/{}/rental/{}",
binding.contract.content_id, binding.contract.id
);
let mut request =
crate::fips::dial::PeerRequest::new(Some(&mesh), &binding.seller_onion, &remote_path)
.require_fips()
.single_delivery()
.timeout(Duration::from_secs(24 * 60 * 60))
.header("X-Content-Capability", capability);
if let Some((start, end)) = bounds {
request = request.header("Range", format!("bytes={start}-{end}"));
}
let (response, transport) = tokio::time::timeout(
Duration::from_secs(20),
request.send_content_get(&self.config.data_dir),
)
.await
.context("Seller did not begin the original rental stream")??;
if !response.status().is_success() {
// Never forward arbitrary upstream bodies, redirects, cookies or private headers.
let status = if response.status().as_u16() == 403 {
StatusCode::FORBIDDEN
} else {
StatusCode::BAD_GATEWAY
};
return Ok(build_response(
status,
"text/plain",
Body::from(
"Original rental is unavailable; recover this purchase without paying again",
),
));
}
let (expected_status, length, mime, expires) = validate_upstream(
response.status().as_u16(),
response.headers(),
total,
bounds,
unix_now()?,
)?;
self.rpc_handler
.playback_handles()
.note_expiry(handle, &binding, expires)?;
let sessions = self.session_store.clone();
let state_manager = self.state_manager.clone();
let data_dir = self.config.data_dir.clone();
let chunks = futures_util::stream::try_unfold(
(response, length, None::<Instant>),
move |(mut response, left, mut checked)| {
let sessions = sessions.clone();
let token = token.clone();
let state_manager = state_manager.clone();
let data_dir = data_dir.clone();
let identity = identity.clone();
let context = context.clone();
async move {
if unix_now().map_err(|_| stream_error("Playback clock unavailable"))?
>= expires
{
return Err(stream_error("Rental viewing window ended"));
}
if left == 0 {
return Ok::<_, io::Error>(None);
}
let waiting_since = Instant::now();
loop {
if waiting_since.elapsed() >= Duration::from_secs(30) {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"Rental stream stalled; reopen the original purchase",
));
}
if unix_now().map_err(|_| stream_error("Playback clock unavailable"))?
>= expires
{
return Err(stream_error("Rental viewing window ended"));
}
if checked.is_none_or(|at| at.elapsed() >= Duration::from_secs(1)) {
if !sessions.validate(&token).await {
return Err(stream_error("Playback session ended"));
}
let (state, _) = state_manager.get_snapshot().await;
let root = data_dir.clone();
let key = identity.clone();
let actual = tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(
&root, &key, &state,
)
})
.await
.map_err(|_| stream_error("Playback app context unavailable"))?
.map_err(|_| stream_error("Playback app context unavailable"))?;
if actual != context {
return Err(stream_error("Playback app context changed"));
}
checked = Some(Instant::now());
}
// Keep checking revocation while the peer stalls; no local media cache.
let chunk = tokio::select! {
chunk=response.chunk() => chunk.map_err(|_|io::Error::new(io::ErrorKind::ConnectionAborted,"Rental stream interrupted; reopen the original purchase"))?,
_=tokio::time::sleep(Duration::from_secs(1)) => continue,
};
let bytes = chunk.ok_or_else(|| {
io::Error::new(
io::ErrorKind::UnexpectedEof,
"Purchased media ended early",
)
})?;
if bytes.len() as u64 > left {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Purchased media exceeded its declared length",
));
}
let remaining = left - bytes.len() as u64;
return Ok(Some((bytes, (response, remaining, checked))));
}
}
},
);
let mut result = Response::builder()
.status(expected_status)
.header("Content-Type", mime)
.header("Content-Length", length)
.header("Accept-Ranges", "bytes")
.header("Cache-Control", "private, no-store")
.header("X-Content-Type-Options", "nosniff")
.header("X-Rental-Expires-At", expires)
.header("X-Archipelago-Transport", transport.to_string());
if let Some((start, end)) = bounds {
result = result.header("Content-Range", format!("bytes {start}-{end}/{total}"));
}
Ok(result.body(Body::wrap_stream(chunks))?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn installed_origin_maps_only_current_host_and_verified_app_port() {
assert!(installed_playback_origin(
"http://192.168.1.5:7778",
"http://127.0.0.1:7778",
"192.168.1.5",
false
));
assert!(installed_playback_origin(
"https://192.168.1.5:7778",
"http://127.0.0.1:7778",
"192.168.1.5",
true
));
assert!(installed_playback_origin(
"https://[fd00::5]:7778",
"https://[::1]:7778",
"[fd00::5]:443",
false
));
for (actual, host, tls) in [
("https://192.168.1.5:7778", "192.168.1.5", false),
("http://evil.test:7778", "192.168.1.5", true),
("http://192.168.1.5:7779", "192.168.1.5", true),
("http://192.168.1.5:7778", "evil.test", true),
("http://192.168.1.5:7778/path", "192.168.1.5", true),
("http://192.168.1.5:7778", "user@192.168.1.5", true),
] {
assert!(
!installed_playback_origin(actual, "http://127.0.0.1:7778", host, tls),
"{actual} {host}"
);
}
}
#[test]
fn range_bounds_follow_purchased_size_and_reject_ambiguous_ranges() {
let check = |range: &str| {
let mut h = HeaderMap::new();
h.insert("range", range.parse().unwrap());
requested_bounds(&h, 100)
};
assert_eq!(check("bytes=20-39").unwrap(), Some((20, 39)));
assert_eq!(check("bytes=90-").unwrap(), Some((90, 99)));
assert_eq!(check("bytes=-10").unwrap(), Some((90, 99)));
assert_eq!(check("bytes=-200").unwrap(), Some((0, 99)));
assert_eq!(check("bytes=90-500").unwrap(), Some((90, 99)));
for range in [
"bytes=100-",
"bytes=20-10",
"bytes=-0",
"bytes=0-1,4-6",
"other=0-1",
] {
assert!(check(range).is_err(), "{range}");
}
assert_eq!(requested_bounds(&HeaderMap::new(), 100).unwrap(), None);
assert!(requested_bounds(&HeaderMap::new(), 0).is_err());
}
#[test]
fn upstream_range_expiry_and_media_headers_are_bound_before_bytes_escape() {
let mut headers = HeaderMap::new();
for (name, value) in [
("content-length", "20"),
("content-range", "bytes 20-39/100"),
("content-type", "video/mp4"),
("x-rental-expires-at", "200"),
] {
headers.insert(name, value.parse().unwrap());
}
assert_eq!(
validate_upstream(206, &headers, 100, Some((20, 39)), 100).unwrap(),
(206, 20, "video/mp4".into(), 200)
);
assert!(validate_upstream(200, &headers, 100, Some((20, 39)), 100).is_err());
assert!(validate_upstream(206, &headers, 100, Some((20, 39)), 200).is_err());
for (name, bad) in [
("content-length", "21"),
("content-range", "bytes 21-40/100"),
("content-type", "text/html"),
("x-rental-expires-at", "0"),
] {
let mut changed = headers.clone();
changed.insert(name, bad.parse().unwrap());
assert!(
validate_upstream(206, &changed, 100, Some((20, 39)), 100).is_err(),
"{name}"
);
}
headers.remove("content-range");
headers.insert("content-length", "100".parse().unwrap());
assert!(validate_upstream(200, &headers, 100, None, 100).is_ok());
}
#[tokio::test]
async fn metadata_probes_and_unauthenticated_get_do_not_touch_purchase_or_identity() {
let root = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = root.path().to_path_buf();
let handler = ApiHandler::new(
config,
Arc::new(crate::state::StateManager::new()),
Arc::new(crate::monitoring::MetricsStore::new()),
None,
None,
)
.await
.unwrap();
// ApiHandler initialization may establish its own node identity, but the
// denied route must not need installed apps, saved receipts or any peer.
for method in [Method::HEAD, Method::POST] {
let result = handler
.handle_request(
hyper::Request::builder()
.method(method)
.uri("/api/rental-playback/invalid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(result.status(), StatusCode::METHOD_NOT_ALLOWED);
}
let result = handler
.handle_request(
hyper::Request::builder()
.uri("/api/rental-playback/invalid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(result.status(), StatusCode::UNAUTHORIZED);
assert!(!root.path().join("content-purchases").exists());
}
}
@@ -332,7 +332,24 @@ impl RpcHandler {
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"content.browse-all-peers" => self.handle_content_browse_all_peers().await,
"content.playback-handle" => self.handle_playback_handle(params, session_token).await,
"content.playback-status" => self.handle_playback_status(params, session_token).await,
"content.rental-purchase" => self.handle_content_rental_purchase(params).await,
"content.purchase" => self.handle_content_purchase(params).await,
"content.cancel-purchase" => self.handle_content_cancel_purchase(params).await,
"content.payment-status" => self.handle_content_payment_status(params).await,
"media.registration.context" => {
self.handle_media_registration_context(params.unwrap_or_default())
.await
}
"media.registration.prepare" => {
self.handle_media_registration_prepare(params.unwrap_or_default())
.await
}
"media.registration.resolve" => {
self.handle_media_registration_resolve(params.unwrap_or_default())
.await
}
"content.owned-list" => self.handle_content_owned_list().await,
"content.owned-get" => self.handle_content_owned_get(params).await,
"content.request-invoice" => self.handle_content_request_invoice(params).await,
+125
View File
@@ -7,6 +7,57 @@ use zeroize::Zeroize;
use super::LND_REST_BASE_URL;
impl RpcHandler {
// Add inside api/rpc/lnd/wallet.rs RpcHandler impl; seller owns this lookup.
// Wire invoice-status response to this Value instead of reducing it to paid bool.
pub(crate) async fn content_invoice_lifecycle(
&self,
hash: &str,
content_id: &str,
) -> Result<serde_json::Value> {
anyhow::ensure!(
hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()),
"Invalid payment hash"
);
let hash = hash.to_ascii_lowercase();
let existing = crate::content_invoice::lookup(&self.config.data_dir, &hash).await?;
anyhow::ensure!(
existing.as_ref().is_none_or(|(id, _)| id == content_id),
"Invoice belongs to another content item"
);
if crate::content_invoice::is_paid_for(&self.config.data_dir, &hash, content_id).await {
return Ok(
serde_json::json!({"paid":true,"state":"settled","can_switch_method":false}),
);
}
let (client, macaroon_hex) = self.lnd_client().await?;
let response = client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(
serde_json::json!({"paid":false,"state":"unknown","can_switch_method":false}),
);
}
let body: serde_json::Value = response.error_for_status()?.json().await?;
let price = content_invoice_amount(&body, content_id)
.context("Invoice content binding is unavailable")?;
anyhow::ensure!(
existing
.as_ref()
.is_none_or(|(_, expected)| *expected == price),
"Invoice price binding changed"
);
crate::content_invoice::record_pending(&self.config.data_dir, &hash, content_id, price)
.await?;
let result = content_invoice_lifecycle_body(&body, price);
if result["paid"] == true {
crate::content_invoice::mark_paid(&self.config.data_dir, &hash).await?;
}
Ok(result)
}
/// Generate a new on-chain Bitcoin address.
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await.map_err(|e| {
@@ -1561,3 +1612,77 @@ mod peer_file_invoice_tests {
}
}
}
fn content_invoice_lifecycle_body(body: &serde_json::Value, price: u64) -> serde_json::Value {
let settled = content_invoice_fully_settled(body, price);
let cancelled = !settled
&& body["state"] == "CANCELED"
&& body.get("settled").and_then(|value| value.as_bool()) != Some(true)
&& body.get("amt_paid_sat").and_then(json_u64) == Some(0)
&& body
.get("amt_paid_msat")
.is_none_or(|value| json_u64(value) == Some(0));
let state = if settled {
"settled"
} else if cancelled {
"canceled"
} else {
match body.get("state").and_then(|value| value.as_str()) {
Some("OPEN") => "open",
Some("ACCEPTED") => "accepted",
_ => "unknown",
}
};
let expires_at = body
.get("creation_date")
.and_then(json_u64)
.zip(body.get("expiry").and_then(json_u64))
.and_then(|(created, expiry)| created.checked_add(expiry));
// Expiry is informational. Only LND's terminal canceled state releases the
// cross-method block; wall-clock passage or lookup failure never does.
serde_json::json!({"paid":settled,"state":state,"can_switch_method":cancelled,
"expires_at":expires_at,"cancel_supported":false})
}
#[cfg(test)]
mod invoice_lifecycle_tests {
use super::*;
#[test]
fn only_authoritative_zero_paid_cancel_unlocks_and_settlement_survives_expiry() {
for state in ["OPEN", "ACCEPTED", "UNKNOWN", "CANCELED"] {
for paid in [0u64, 1] {
let result = content_invoice_lifecycle_body(
&serde_json::json!({
"state":state,"settled":false,"amt_paid_sat":paid.to_string(),
"creation_date":"1","expiry":"1"}),
8,
);
assert_eq!(
result["can_switch_method"],
state == "CANCELED" && paid == 0
);
assert_eq!(result["paid"], false);
}
}
assert_eq!(
content_invoice_lifecycle_body(&serde_json::json!({"state":"CANCELED"}), 8)
["can_switch_method"],
false
);
assert_eq!(
content_invoice_lifecycle_body(
&serde_json::json!({"state":"CANCELED", "amt_paid_sat":"0", "amt_paid_msat":"1"}),
8
)["can_switch_method"],
false
);
let paid = content_invoice_lifecycle_body(
&serde_json::json!({
"state":"SETTLED","settled":true,"amt_paid_sat":"8","value":"8",
"creation_date":"1","expiry":"1"}),
8,
);
assert_eq!(paid["paid"], true);
assert_eq!(paid["can_switch_method"], false);
}
}
@@ -0,0 +1,353 @@
//! Owner-session/CSRF RPC plus producer-signed, exact media approval.
//! App callers use the native dashboard bridge; this is never an origin-only grant.
use super::RpcHandler;
use crate::media_registration::{AuthorizedSelection, Intent, Limits};
use anyhow::{Context, Result};
use nostr_sdk::prelude::{Event, Kind};
use serde::Deserialize;
use std::{
path::Path,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
// Dropping the request future cancels queued locks and chunked snapshot work.
// A completed durable record is still recovered by the original operation ID.
struct RequestCancellation(Arc<AtomicBool>);
impl Drop for RequestCancellation {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
fn request_cancellation() -> (RequestCancellation, Arc<AtomicBool>) {
let signal = Arc::new(AtomicBool::new(false));
(RequestCancellation(signal.clone()), signal)
}
const DOMAIN: &str = "archipelago.media-registration.approval.v1";
const KIND: u16 = 27236;
const MAX_APPROVAL: usize = 32 * 1024;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Params {
intent: Intent,
selection: AuthorizedSelection,
producer_event: Event,
}
const RESOLUTION_DOMAIN: &str = "archipelago.media-registration.resolution.v1";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ResolveParams {
intent: Intent,
producer_event: Event,
}
fn verified_resolution(input: serde_json::Value, now: u64) -> Result<ResolveParams> {
anyhow::ensure!(
serde_json::to_vec(&input)?.len() <= MAX_APPROVAL,
"Resolution approval is too large"
);
let params: ResolveParams = serde_json::from_value(input)?;
let event = &params.producer_event;
event
.verify()
.context("Resolution producer signature failed")?;
anyhow::ensure!(
event.kind == Kind::Custom(27237) && event.pubkey.to_hex() == params.intent.producer,
"Resolution signature purpose or producer changed"
);
let encoded = serde_json::to_value(event)?;
anyhow::ensure!(
encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]])
&& event.created_at.as_u64() >= params.intent.created_at.saturating_sub(30)
&& event.created_at.as_u64() <= now.saturating_add(30),
"Invalid resolution signature time or scope"
);
let content: serde_json::Value = serde_json::from_str(&event.content)?;
anyhow::ensure!(
content
== serde_json::json!({
"action":"Recover prepared video or retire this expired incomplete registration",
"scope":RESOLUTION_DOMAIN, "intent":params.intent,
}),
"Resolution signature changed the original intent"
);
Ok(params)
}
fn approval_content(intent: &Intent, selection: &AuthorizedSelection) -> Result<serde_json::Value> {
let path = selection
.relative_path
.to_str()
.context("Cloud file name is not UTF-8")?;
Ok(serde_json::json!({
"action":"Register this Cloud video for an IndeeHub project",
"scope":DOMAIN,
"intent":intent,
"selection":{"cloudFile":path,"paymentMethods":selection.payment_methods},
}))
}
fn verified_producer(params: &Params, now: u64) -> Result<String> {
let event = &params.producer_event;
anyhow::ensure!(
event.kind == Kind::Custom(KIND),
"This signature is not a media registration approval"
);
event
.verify()
.context("Producer approval signature failed")?;
let producer = event.pubkey.to_hex();
anyhow::ensure!(
producer == params.intent.producer,
"The signing identity differs from the project producer"
);
let created = event.created_at.as_u64();
anyhow::ensure!(
created >= params.intent.created_at.saturating_sub(30)
&& created < params.intent.expires_at
&& created <= now.saturating_add(30),
"Producer approval was not signed within this registration intent"
);
let encoded = serde_json::to_value(event)?;
anyhow::ensure!(
encoded["tags"] == serde_json::json!([["d", DOMAIN]]),
"Media approval signature scope changed"
);
let content: serde_json::Value = serde_json::from_str(&event.content)
.context("Producer approval is not readable registration terms")?;
anyhow::ensure!(
content == approval_content(&params.intent, &params.selection)?,
"Approved project, Cloud selection or rental terms changed"
);
Ok(producer)
}
fn parse(input: serde_json::Value, now: u64) -> Result<(Params, String)> {
anyhow::ensure!(
serde_json::to_vec(&input)?.len() <= MAX_APPROVAL,
"Registration approval is too large"
);
let params: Params = serde_json::from_value(input).context("Invalid registration approval")?;
let producer = verified_producer(&params, now)?;
Ok((params, producer))
}
fn registration_limit(_data_dir: &Path) -> u64 {
// Explicit per-file staging bound; shared immutable snapshot storage handles
// disk reservations separately before this route is enabled for live apps.
16 * 1024 * 1024 * 1024
}
impl RpcHandler {
/// Read-only public installation bindings for the native consent bridge.
/// A hidden standalone signer must compare its actual app origin with these
/// installed addresses; a caller-supplied app name is never sufficient.
pub(super) async fn handle_media_registration_context(
&self,
_input: serde_json::Value,
) -> Result<serde_json::Value> {
let (state, _) = self.state_manager.get_snapshot().await;
let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?;
let data_dir = self.config.data_dir.clone();
let context = tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&data_dir, &identity, &state)
})
.await??;
let mut result = serde_json::to_value(context)?;
result["paymentMethods"] = serde_json::json!(["cashu"]);
Ok(result)
}
pub(super) async fn handle_media_registration_resolve(
&self,
input: serde_json::Value,
) -> Result<serde_json::Value> {
let now = u64::try_from(chrono::Utc::now().timestamp()).context("Invalid node clock")?;
let params = verified_resolution(input, now)?;
let (state, _) = self.state_manager.get_snapshot().await;
let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?;
let data_dir = self.config.data_dir.clone();
let (_cancellation, cancelled) = request_cancellation();
tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&data_dir, &identity, &state)?;
crate::registered_media::resolve_registration(
&data_dir,
&identity,
&params.intent,
&params.producer_event.pubkey.to_hex(),
now,
&Limits {
max_bytes: registration_limit(&data_dir),
cancelled: &cancelled,
},
)
})
.await?
}
/// Standard RPC front door already requires owner session, permitted origin
/// and CSRF. Producer signature is additional exact-scope consent, not a
/// replacement for those owner checks. A replay repeats the original UUID.
pub(super) async fn handle_media_registration_prepare(
&self,
input: serde_json::Value,
) -> Result<serde_json::Value> {
let now = u64::try_from(chrono::Utc::now().timestamp()).context("Invalid node clock")?;
let (params, producer) = parse(input, now)?;
let (state, _) = self.state_manager.get_snapshot().await;
anyhow::ensure!(
state
.package_data
.get("indeedhub-api")
.is_some_and(|entry| matches!(
entry.state,
crate::data_model::PackageState::Running
)),
"The installed IndeeHub API must be running to register its media"
);
let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?;
let data_dir = self.config.data_dir.clone();
let (_cancellation, cancelled) = request_cancellation();
let receipt = tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&data_dir, &identity, &state)?;
let project = params.intent.project_id.clone();
let limits = Limits {
max_bytes: registration_limit(&data_dir),
cancelled: &cancelled,
};
crate::registered_media::register_approved_selection(
&data_dir,
&data_dir.join("filebrowser"),
&identity,
&crate::registered_media::ApprovedSelection {
authenticated_producer: &producer,
authenticated_project: &project,
intent: &params.intent,
selection: &params.selection,
},
now,
&limits,
|_| Ok(()),
)
})
.await??;
Ok(serde_json::to_value(receipt)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use nostr_sdk::prelude::{EventBuilder, Keys, Tag, Timestamp};
fn fixture() -> Params {
let keys = Keys::parse(&"07".repeat(32)).unwrap();
let intent = Intent {
version: 1,
request_id: uuid::Uuid::new_v4().to_string(),
nonce: "ab".repeat(32),
app_audience: uuid::Uuid::new_v4().to_string(),
node_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap(),
producer: keys.public_key().to_hex(),
project_id: "fixture-project".into(),
price_sats: 8,
viewing_seconds: 3600,
created_at: 1000,
expires_at: 1600,
};
let selection = AuthorizedSelection {
relative_path: "Movies/Film.mp4".into(),
payment_methods: vec!["cashu".into()],
};
let event = EventBuilder::new(
Kind::Custom(KIND),
serde_json::to_string_pretty(&approval_content(&intent, &selection).unwrap()).unwrap(),
)
.tag(Tag::identifier(DOMAIN))
.custom_created_at(Timestamp::from(1200))
.sign_with_keys(&keys)
.unwrap();
Params {
intent,
selection,
producer_event: event,
}
}
#[test]
fn producer_signature_binds_human_readable_exact_selection_terms_node_and_installation() {
let original = fixture();
assert_eq!(
verified_producer(&original, 1300).unwrap(),
original.intent.producer
);
// Original consent can recover an already-completed operation after
// expiry; underlying snapshot journal refuses creating a fresh one.
assert!(verified_producer(&original, 2000).is_ok());
let mut changed = fixture();
changed.intent.price_sats += 1;
assert!(verified_producer(&changed, 1300).is_err());
let mut changed = fixture();
changed.selection.relative_path = "Other.mp4".into();
assert!(verified_producer(&changed, 1300).is_err());
let mut changed = fixture();
changed.intent.app_audience = uuid::Uuid::new_v4().to_string();
assert!(verified_producer(&changed, 1300).is_err());
let mut changed = fixture();
changed.intent.producer = "cd".repeat(32);
assert!(verified_producer(&changed, 1300).is_err());
assert!(verified_producer(&fixture(), 1000).is_err());
}
#[test]
fn request_parser_rejects_unsigned_claims_unknown_fields_and_changed_signed_content() {
let original = fixture();
let mut encoded = serde_json::json!({"intent":original.intent,"selection":original.selection,"producerEvent":original.producer_event});
assert!(parse(encoded.clone(), 1300).is_ok());
encoded["producerEvent"]["content"] = serde_json::json!("approve everything");
assert!(parse(encoded, 1300).is_err());
assert!(parse(serde_json::json!({"producer":"cd".repeat(32)}), 1300).is_err());
}
#[test]
fn dropped_request_signals_blocking_copy_cancellation() {
let (guard, signal) = request_cancellation();
assert!(!signal.load(Ordering::Relaxed));
drop(guard);
assert!(signal.load(Ordering::Relaxed));
}
#[test]
fn resolution_signature_recovers_only_exact_intent_after_expiry_without_file_authority() {
let original = fixture();
let keys = Keys::parse(&"07".repeat(32)).unwrap();
let event = EventBuilder::new(
Kind::Custom(27237),
serde_json::json!({
"action":"Recover prepared video or retire this expired incomplete registration",
"scope":RESOLUTION_DOMAIN,"intent":original.intent,
})
.to_string(),
)
.tag(Tag::identifier(RESOLUTION_DOMAIN))
.custom_created_at(Timestamp::from(1700))
.sign_with_keys(&keys)
.unwrap();
let encoded = serde_json::json!({"intent":original.intent,"producerEvent":event});
assert!(verified_resolution(encoded.clone(), 1800).is_ok());
assert!(verified_resolution(encoded.clone(), 9999).is_ok());
assert!(parse(encoded.clone(), 1800).is_err());
let mut changed = encoded.clone();
changed["intent"]["priceSats"] = serde_json::json!(999);
assert!(verified_resolution(changed, 1800).is_err());
let mut changed = encoded;
changed["selection"] =
serde_json::json!({"relative_path":"film.mp4","payment_methods":["cashu"]});
assert!(verified_resolution(changed, 1800).is_err());
assert!(verified_resolution(
serde_json::json!({"intent":original.intent,"producerEvent":original.producer_event}),
1800
)
.is_err());
}
}
+54 -6
View File
@@ -19,6 +19,9 @@ mod identity;
mod interfaces;
pub(crate) mod lnd;
mod marketplace;
mod media_registration;
mod purchase;
mod playback;
// pub(crate): 13-10's `assistant::backends::select_backend` reuses
// `mesh::assistant::detect_ollama()` (D-04) rather than re-probing —
// matches the existing `pub(crate) mod bitcoin_relay;`/`pub(crate) mod
@@ -97,6 +100,22 @@ fn nostr_signing_origin_allowed(headers: &hyper::HeaderMap, dev_mode: bool) -> b
matches!(url.port_or_known_default(), Some(80 | 443))
}
fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_mode: bool) -> bool {
!matches!(
method,
"node.nostr-sign"
| "identity.nostr-sign"
| "media.registration.prepare"
| "media.registration.context"
| "media.registration.resolve"
| "content.rental-purchase"
| "content.purchase"
| "content.cancel-purchase"
| "content.playback-handle"
| "content.playback-status"
) || nostr_signing_origin_allowed(headers, dev_mode)
}
/// Read-only authenticated methods may skip CSRF, but they must still exist in
/// the dispatcher. The tab signer uses `system.get-hostname` as its lightweight
/// session probe, so keeping the policy in one testable function protects that
@@ -148,6 +167,7 @@ pub struct RpcHandler {
pub(crate) app_gate: Arc<crate::appgate::AppGate>,
endpoint_rate_limiter: EndpointRateLimiter,
response_cache: ResponseCache,
playback_handles: crate::playback_handles::PlaybackHandles,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
/// job at a time, since flashing needs exclusive access to the port.
@@ -172,6 +192,10 @@ pub struct RpcHandler {
}
impl RpcHandler {
pub(crate) fn playback_handles(&self) -> &crate::playback_handles::PlaybackHandles {
&self.playback_handles
}
pub async fn new(
config: Config,
state_manager: Arc<StateManager>,
@@ -231,6 +255,7 @@ impl RpcHandler {
app_gate,
endpoint_rate_limiter,
response_cache: ResponseCache::new(5),
playback_handles: Default::default(),
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
flash_job: crate::mesh::flash::new_job_handle(),
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
@@ -333,14 +358,10 @@ impl RpcHandler {
debug!("RPC method: {}", rpc_req.method);
if matches!(
rpc_req.method.as_str(),
"node.nostr-sign" | "identity.nostr-sign"
) && !nostr_signing_origin_allowed(&parts.headers, self.config.dev_mode)
{
if !native_consent_origin_allowed(&rpc_req.method, &parts.headers, self.config.dev_mode) {
return Ok(self.error_response(
403,
"Nostr signing from app origins requires the dashboard consent bridge",
"Native signing and Cloud registration from app origins require the dashboard consent bridge",
StatusCode::FORBIDDEN,
));
}
@@ -799,6 +820,33 @@ mod nostr_signing_origin_tests {
headers
}
#[test]
fn native_registration_and_purchase_use_dashboard_origin_and_keep_authentication_and_csrf() {
for method in [
"media.registration.prepare",
"media.registration.context",
"media.registration.resolve",
"content.rental-purchase",
"content.purchase",
"content.cancel-purchase",
"content.playback-handle",
"content.playback-status",
] {
assert!(!native_consent_origin_allowed(
method,
&headers(Some("http://node.local:7778")),
false
));
assert!(native_consent_origin_allowed(
method,
&headers(Some("https://node.local")),
false
));
assert!(!UNAUTHENTICATED_METHODS.contains(&method));
assert!(!csrf_exempt_method(method));
}
}
#[test]
fn signing_accepts_dashboard_and_authenticated_non_browser_clients() {
assert!(nostr_signing_origin_allowed(&headers(None), false));
+74
View File
@@ -0,0 +1,74 @@
//! Native broker only: settled purchases become session-bound opaque media URLs.
use super::RpcHandler;
use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Issue {
purchase_id: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Status {
handle: String,
}
impl RpcHandler {
async fn playback_context(
&self,
session: &Option<String>,
) -> Result<(
String,
crate::container::registration_pin::InstalledAppContext,
)> {
let session = session.as_ref().context("Owner session required")?;
anyhow::ensure!(
self.session_store.validate(session).await,
"Owner session expired"
);
let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?;
let (state, _) = self.state_manager.get_snapshot().await;
let root = self.config.data_dir.clone();
let context = tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&root, &identity, &state)
})
.await??;
Ok((session.clone(), context))
}
pub(super) async fn handle_playback_handle(
&self,
params: Option<serde_json::Value>,
session: &Option<String>,
) -> Result<serde_json::Value> {
let params: Issue = serde_json::from_value(params.context("Missing purchase identifier")?)?;
let (session, context) = self.playback_context(session).await?;
let handle = self
.playback_handles()
.issue(
&self.config.data_dir,
context.clone(),
&session,
&params.purchase_id,
)
.await?;
let expires = self
.playback_handles()
.expiry(&handle, &session, &context)?;
Ok(
serde_json::json!({"playback_url":format!("/api/rental-playback/{handle}"), "expires_at":expires}),
)
}
pub(super) async fn handle_playback_status(
&self,
params: Option<serde_json::Value>,
session: &Option<String>,
) -> Result<serde_json::Value> {
let params: Status = serde_json::from_value(params.context("Missing playback handle")?)?;
let (session, context) = self.playback_context(session).await?;
let expires = self
.playback_handles()
.expiry(&params.handle, &session, &context)?;
Ok(serde_json::json!({"expires_at":expires}))
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Owner RPC for permanent Cloud purchases. Registered app rentals use the
//! separate native installed-app context + opaque playback-handle dispatcher.
use super::RpcHandler;
use crate::{
content_purchase::Journal,
content_purchase_caller::{self as caller, PurchaseConsent, PurchaseTransport, ReadyPurchase},
content_purchase_transport::FipsPurchaseTransport,
};
use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PurchaseParams {
onion: String,
content_id: String,
filename: Option<String>,
max_wallet_debit: u64,
consent: Option<PurchaseConsent>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CancelParams {
onion: String,
operation_id: String,
}
impl RpcHandler {
pub(super) async fn handle_content_purchase(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params: PurchaseParams =
serde_json::from_value(params.context("Missing purchase parameters")?)?;
anyhow::ensure!(
!params.content_id.starts_with("registered_"),
"Registered rentals require the native app purchase context"
);
let transport =
FipsPurchaseTransport::load(self.config.data_dir.clone(), params.onion.clone()).await?;
let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?;
let buyer = identity.did_key()?;
let result = caller::purchase(
&self.config.data_dir,
&buyer,
&params.content_id,
params.filename.as_deref(),
params.max_wallet_debit,
params.consent.as_ref(),
&transport,
)
.await?;
match result {
ReadyPurchase::AwaitingConfirmation {
operation_id,
envelope_sha256,
gross_token_sats,
seller_net_sats,
wallet_debit_sats,
expires_at,
network,
mint_url,
} => Ok(
serde_json::json!({"state":"confirmation_required","operation_id":operation_id,
"envelope_sha256":envelope_sha256,"gross_token_sats":gross_token_sats,
"seller_net_sats":seller_net_sats,"wallet_debit_sats":wallet_debit_sats,"expires_at":expires_at,"network":network,"mint_url":mint_url}),
),
ReadyPurchase::Cancelled { operation_id } => {
Ok(serde_json::json!({"state":"cancelled_unspent","operation_id":operation_id}))
}
ReadyPurchase::Cached { content_id, .. } => Ok(
serde_json::json!({"state":"delivered","owned":true,"owned_content_id":content_id}),
),
ReadyPurchase::Entitlement { contract, receipt } => {
let item = crate::content_purchase_download::cache(
&self.config.data_dir,
&params.onion,
&contract,
&receipt,
)
.await?;
Ok(
serde_json::json!({"state":"delivered","owned":true,"operation_id":contract.id,
"owned_content_id":item.content_id,"mime_type":item.mime_type,"size_bytes":item.size_bytes}),
)
}
}
}
pub(super) async fn handle_content_cancel_purchase(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params: CancelParams =
serde_json::from_value(params.context("Missing cancellation parameters")?)?;
let transport =
FipsPurchaseTransport::load(self.config.data_dir.clone(), params.onion).await?;
let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?;
let (envelope, plan) = {
let journal = Journal::open(&self.config.data_dir).await?;
let record = journal
.buyer(&params.operation_id)
.await?
.context("Original purchase not found")?;
anyhow::ensure!(
record.contract.buyer_did == identity.did_key()?
&& record.contract.seller_did == transport.seller_did(),
"Cancellation purchase binding changed"
);
(
journal
.protocol_envelope("buyer", &params.operation_id)
.await?
.context("Original payment shape missing")?,
journal
.buyer_plan(&params.operation_id)
.await?
.context("Original wallet plan missing")?,
)
};
caller::cancel_purchase(&self.config.data_dir, &envelope, &plan, &transport).await?;
Ok(serde_json::json!({"state":"cancelled_unspent","operation_id":params.operation_id}))
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RentalParams {
seller_did: String,
content_id: String,
expected_sha256: String,
expected_price_sats: u64,
expected_viewing_seconds: u64,
max_wallet_debit: u64,
consent: Option<PurchaseConsent>,
}
impl RpcHandler {
pub(super) async fn handle_content_rental_purchase(
&self,
input: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params: RentalParams =
serde_json::from_value(input.context("Missing rental purchase terms")?)?;
anyhow::ensure!(
params.content_id.starts_with("registered_")
&& params.expected_price_sats > 0
&& (1..=31_536_000).contains(&params.expected_viewing_seconds)
&& params.expected_sha256.len() == 64
&& params
.expected_sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
"Invalid published rental terms"
);
let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?;
let buyer = identity.did_key()?;
let (state, _) = self.state_manager.get_snapshot().await;
let data = self.config.data_dir.clone();
tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&data, &identity, &state)
})
.await??;
let onion = crate::content_purchase_transport::seller_onion_for_did(
&self.config.data_dir,
&params.seller_did,
)
.await?;
let transport =
FipsPurchaseTransport::load(self.config.data_dir.clone(), onion.clone()).await?;
let expected = caller::ExpectedRental {
seller_did: params.seller_did,
content_id: params.content_id.clone(),
sha256: params.expected_sha256,
price_sats: params.expected_price_sats,
viewing_seconds: params.expected_viewing_seconds,
};
match caller::purchase_bound(
&self.config.data_dir,
&buyer,
&params.content_id,
None,
params.max_wallet_debit,
params.consent.as_ref(),
&transport,
Some(&expected),
)
.await?
{
ReadyPurchase::AwaitingConfirmation {
operation_id,
envelope_sha256,
gross_token_sats,
seller_net_sats,
wallet_debit_sats,
expires_at,
network,
mint_url,
} => Ok(serde_json::json!({
"state":"confirmation_required","operation_id":operation_id,"envelope_sha256":envelope_sha256,
"gross_token_sats":gross_token_sats,"seller_net_sats":seller_net_sats,"wallet_debit_sats":wallet_debit_sats,
"expires_at":expires_at,"seller_onion":onion,"network":network,"mint_url":mint_url})),
ReadyPurchase::Entitlement { contract, .. } => Ok(
serde_json::json!({"state":"entitled","operation_id":contract.id,"seller_onion":onion}),
),
ReadyPurchase::Cancelled { operation_id } => Ok(
serde_json::json!({"state":"cancelled_unspent","operation_id":operation_id,"seller_onion":onion}),
),
ReadyPurchase::Cached { .. } => {
anyhow::bail!("A timed rental cannot use a permanent owned copy")
}
}
}
}
+53 -2
View File
@@ -109,6 +109,24 @@ const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles
/// and peer media won't play (B3). Forwards Cookie (session auth) + Range and
/// disables buffering so streaming works. Kept in sync with the canonical
/// block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_RENTAL_PLAYBACK_BLOCK: &str = r#"
# Session-bound rental playback: never cache opaque handles or capabilities.
location /api/rental-playback/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header Origin $http_origin;
proxy_set_header Range $http_range;
proxy_buffering off;
proxy_cache off;
proxy_connect_timeout 10s;
proxy_read_timeout 40s;
error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout;
}
"#;
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// Inserted into every server block lacking the Pine node-status proxy.
@@ -1784,6 +1802,24 @@ fn heal_missing_nostr_signer(content: &str) -> Option<String> {
}
/// Keep both authenticated catalog endpoints on the backend in every vhost.
fn heal_rental_playback_route(content: &str) -> String {
let anchor = " location /lnd-connect-info {";
let mut output = String::new();
for part in content.split_inclusive(anchor) {
if let Some(prefix) = part.strip_suffix(anchor) {
let current_server = prefix.rsplit("server {").next().unwrap_or(prefix);
output.push_str(prefix);
if !current_server.contains("location /api/rental-playback/ {") {
output.push_str(NGINX_RENTAL_PLAYBACK_BLOCK);
}
output.push_str(anchor);
} else {
output.push_str(part);
}
}
output
}
fn heal_node_catalog_route(content: &str) -> String {
content.replace(
"location /api/app-catalog {",
@@ -1825,8 +1861,10 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
let missing_source_proxy = heal_missing_source_proxy(&content).is_some();
let missing_source_prefix = heal_source_forwarded_prefix(&content).is_some();
let missing_nostr_signer = heal_missing_nostr_signer(&content).is_some();
let missing_rental_playback = heal_rental_playback_route(&content) != content;
let legacy_catalog_route = content.contains("location /api/app-catalog {");
if !missing_app_catalog
if !missing_rental_playback
&& !missing_app_catalog
&& !legacy_catalog_route
&& !missing_bitcoin_status
&& !missing_lnd_proxy
@@ -1844,7 +1882,7 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
return Ok(false);
}
let mut patched = heal_node_catalog_route(&content);
let mut patched = heal_rental_playback_route(&heal_node_catalog_route(&content));
if let Some(p) = heal_stale_web_search_block(&patched) {
patched = p;
@@ -2023,6 +2061,19 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
#[cfg(test)]
mod tests {
#[test]
fn rental_playback_route_repairs_each_vhost_without_enabling_cache() {
let original = "server {\n location /lnd-connect-info { proxy_pass http://127.0.0.1:5678; }\n}\nserver {\n location /lnd-connect-info { proxy_pass http://127.0.0.1:5678; }\n}";
let fixed = super::heal_rental_playback_route(original);
assert_eq!(fixed.matches("location /api/rental-playback/ {").count(), 2);
assert_eq!(super::heal_rental_playback_route(&fixed), fixed);
assert_eq!(fixed.matches("proxy_cache off;").count(), 2);
assert_eq!(fixed.matches("proxy_set_header Range $http_range;").count(), 2);
let partial = fixed.replacen(super::NGINX_RENTAL_PLAYBACK_BLOCK, "", 1);
assert_eq!(super::heal_rental_playback_route(&partial), fixed);
}
#[test]
fn catalog_routes_upgrade_both_vhosts_without_changing_access_guards() {
let old = "server { if ($guard) { return 404; } location /api/app-catalog { proxy_pass http://127.0.0.1:5678; } }\nserver { location /api/app-catalog { proxy_set_header Cookie $http_cookie; } }";
@@ -218,7 +218,7 @@ impl DockerPackageScanner {
None
},
static_files: StaticFiles {
license: "MIT".to_string(),
license: String::new(),
instructions: metadata.description.clone(),
icon: manifest_icon.unwrap_or_else(|| metadata.icon.clone()),
},
@@ -231,7 +231,7 @@ impl DockerPackageScanner {
long: metadata.description.clone(),
},
release_notes: "Docker container".to_string(),
license: "MIT".to_string(),
license: String::new(),
wrapper_repo: metadata.repo.clone(),
upstream_repo: metadata.repo.clone(),
support_site: metadata.repo.clone(),
@@ -512,6 +512,32 @@ mod lifecycle_regression_tests {
assert_eq!(main.lan_config.as_deref(), Some("kept"));
}
#[test]
fn manifest_presentation_only_reports_declared_licenses() {
let mut entry = installing_fixture();
for (metadata, expected) in [
(serde_json::json!({"license":"MIT"}), "MIT"),
(
serde_json::json!({"license":" BSD-3-Clause "}),
"BSD-3-Clause",
),
(serde_json::json!({}), ""),
(serde_json::json!({"license":null}), ""),
(serde_json::json!({"license":true}), ""),
(serde_json::json!({"license":" "}), ""),
] {
apply_manifest_value(
&serde_json::json!({"app":{"metadata":metadata}}),
&mut entry,
);
assert_eq!(entry.manifest.license, expected);
assert_eq!(entry.static_files.license, expected);
}
apply_manifest_value(&serde_json::json!({"app":{}}), &mut entry);
assert_eq!(entry.manifest.license, "");
assert_eq!(entry.static_files.license, "");
}
#[test]
fn installed_manifest_entry_path_survives_scans_without_changing_runtime_origin() {
let mut entry = installing_fixture();
@@ -821,6 +847,14 @@ fn apply_manifest_value(value: &serde_json::Value, entry: &mut PackageDataEntry)
.filter(|s| !s.is_empty())
.map(str::to_owned)
};
// Absence is not a license grant. Empty strings are omitted by the UI.
let license = text(
app.get("metadata")
.and_then(|metadata| metadata.get("license")),
)
.unwrap_or_default();
entry.manifest.license = license.clone();
entry.static_files.license = license;
if let Some(name) = text(app.get("name")) {
entry.manifest.title = name;
}
@@ -27,6 +27,81 @@ pub struct RegistrationPin {
pub node_did: String,
pub app_audience: String,
}
/// Fixed installed app context used by native media selection and local playback
/// handles. Caller must still authenticate owner session/CSRF or its scoped handle.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct InstalledAppContext {
pub app_id: String,
pub backend_id: String,
pub app_audience: String,
pub node_did: String,
pub node_public_key: String,
pub app_origins: Vec<String>,
}
/// Blocking lookup; never provisions a new identity/audience. No request chooses
/// app scope. Revalidate fresh installation state before using a playback handle.
pub(crate) fn installed_context(
data_dir: &Path,
identity: &crate::identity::NodeIdentity,
state: &crate::data_model::DataModel,
) -> Result<InstalledAppContext> {
for id in ["indeedhub", "indeedhub-api"] {
let entry = state
.package_data
.get(id)
.context("IndeeHub is not installed")?;
anyhow::ensure!(
matches!(entry.state, crate::data_model::PackageState::Running)
&& entry.installed.is_some(),
"IndeeHub installation is not running"
);
}
let app = state.package_data.get("indeedhub").unwrap();
let installed = app.installed.as_ref().unwrap();
let mut origins = Vec::new();
for address in installed.interface_addresses.values() {
for text in
std::iter::once(address.tor_address.as_str()).chain(address.lan_address.as_deref())
{
let onion_url = text
.strip_suffix(".onion")
.filter(|host| {
host.len() == 56
&& host
.bytes()
.all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b))
})
.map(|_| format!("http://{text}"));
if let Ok(url) = reqwest::Url::parse(onion_url.as_deref().unwrap_or(text)) {
if matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
{
origins.push(url.origin().ascii_serialization());
}
}
}
}
origins.sort();
origins.dedup();
anyhow::ensure!(
!origins.is_empty(),
"IndeeHub has no installed browser origin"
);
let pin = load_existing(data_dir, "indeedhub-api", identity)?;
Ok(InstalledAppContext {
app_id: "indeedhub".into(),
backend_id: "indeedhub-api".into(),
app_audience: pin.app_audience,
node_did: pin.node_did,
node_public_key: pin.node_public_key,
app_origins: origins,
})
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Marker {
@@ -371,4 +446,48 @@ mod tests {
manifest.app.container.media_registration_identity = false;
assert!(apply_environment(&mut manifest, &pin).is_err());
}
#[tokio::test]
async fn installed_media_context_requires_both_apps_and_existing_pin_and_tracks_origin_changes()
{
let (root, identity) = fixture().await;
let mut state = crate::data_model::DataModel::default();
for id in ["indeedhub", "indeedhub-api"] {
let entry = serde_json::from_value(serde_json::json!({
"state":"running", "static-files":{"license":"","instructions":"","icon":""},
"manifest":{"id":id,"title":id,"version":"fixture",
"description":{"short":"fixture","long":""},"release-notes":"","license":"",
"wrapper-repo":"","upstream-repo":"","support-site":"","marketing-site":""},
"installed":{"current-dependents":{},"current-dependencies":{},"last-backup":null,"status":"running",
"interface-addresses":{"main":{"tor-address":"","lan-address":"https://localhost:7778/browse"}}}
})).unwrap();
state.package_data.insert(id.into(), entry);
}
assert!(installed_context(root.path(), &identity, &state).is_err());
let pin = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
let first = installed_context(root.path(), &identity, &state).unwrap();
assert_eq!(first.app_audience, pin.app_audience);
assert_eq!(first.app_origins, vec!["https://localhost:7778"]);
state.package_data.get_mut("indeedhub-api").unwrap().state =
crate::data_model::PackageState::Stopped;
assert!(installed_context(root.path(), &identity, &state).is_err());
state.package_data.get_mut("indeedhub-api").unwrap().state =
crate::data_model::PackageState::Running;
state
.package_data
.get_mut("indeedhub")
.unwrap()
.installed
.as_mut()
.unwrap()
.interface_addresses
.get_mut("main")
.unwrap()
.lan_address = Some("https://localhost:7779".into());
assert_ne!(
installed_context(root.path(), &identity, &state).unwrap(),
first
);
state.package_data.remove("indeedhub");
assert!(installed_context(root.path(), &identity, &state).is_err());
}
}
+137
View File
@@ -0,0 +1,137 @@
//! Ordinary owner-shared Cloud offers reuse a retained immutable version; they
//! never copy a large file for each buyer. Snapshot copying happens off-runtime.
use crate::{
content_purchase_protocol::Offer,
content_server::{self, AccessControl, Availability},
wallet::ecash::EcashNetwork,
};
use anyhow::{Context, Result};
use std::{
path::Path,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
pub(crate) struct SnapshotPolicy {
pub max_file_bytes: u64,
pub max_total_bytes: u64,
pub minimum_free_bytes: u64,
}
fn visible(item: &content_server::ContentItem, buyer: &str) -> bool {
match &item.availability {
Availability::Nobody => false,
Availability::AllPeers => true,
Availability::Specific { peers } => peers.iter().any(|did| did == buyer),
}
}
/// Caller identities come from v2 authentication/current node identity, not body.
pub(crate) async fn offer(
data_dir: &Path,
id: &str,
content_id: &str,
buyer: &str,
seller: &str,
network: EcashNetwork,
mint: &str,
policy: SnapshotPolicy,
) -> Result<Offer> {
crate::content_purchase_protocol::ensure_seller_mint_policy(data_dir, network, mint).await?;
let catalog = content_server::load_catalog(data_dir).await?;
let item = catalog
.items
.into_iter()
.find(|item| item.id == content_id)
.context("Shared content is unavailable")?;
anyhow::ensure!(
visible(&item, buyer),
"Content is not shared with this buyer"
);
let price = match &item.access {
AccessControl::Paid { price_sats, .. } if *price_sats > 0 => *price_sats,
_ => anyhow::bail!("This shared item does not require a payment"),
};
anyhow::ensure!(
content_server::method_accepted(&item.access, "ecash")
|| content_server::method_accepted(&item.access, "cashu"),
"This shared item does not accept Cashu"
);
content_server::ensure_payment_source_available(data_dir, &item).await?;
let source = content_server::content_file_path(data_dir, &item);
let roots = [data_dir.join("content/files"), data_dir.join("filebrowser")];
let (root, relative): (std::path::PathBuf, std::path::PathBuf) = roots
.iter()
.find_map(|root| {
source
.strip_prefix(root)
.ok()
.map(|relative| (root.clone(), relative.to_path_buf()))
})
.context("Content has no configured source root")?;
let data = data_dir.to_path_buf();
let selected = content_id.to_owned();
struct CancelCopy(Arc<AtomicBool>);
impl Drop for CancelCopy {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
let cancel_copy = CancelCopy(Arc::new(AtomicBool::new(false)));
let cancelled = cancel_copy.0.clone();
let snapshot = tokio::task::spawn_blocking(move || {
crate::content_snapshot::prepare(
&data,
&root,
&selected,
&relative,
&crate::media_registration::Limits {
max_bytes: policy.max_file_bytes,
cancelled: &cancelled,
},
policy.max_total_bytes,
policy.minimum_free_bytes,
|_| Ok(()),
)
})
.await??;
anyhow::ensure!(
snapshot.size == item.size_bytes,
"Shared file changed; refresh its catalog before accepting payment"
);
let terms = {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(serde_json::to_vec(&(
"archipelago-cloud-purchase-terms-v1",
seller,
content_id,
&snapshot.sha256,
snapshot.size,
price,
"permanent-download",
&item.filename,
&item.mime_type,
"cashu",
))?))
};
let now = chrono::Utc::now().timestamp();
let offer = Offer {
id: id.into(),
buyer_did: buyer.into(),
seller_did: seller.into(),
content_id: content_id.into(),
filename: item.filename.clone(),
mime_type: item.mime_type.clone(),
content_sha256: snapshot.sha256,
content_size: snapshot.size,
viewing_seconds: None,
terms_sha256: terms,
network,
mint_url: mint.into(),
seller_net_sats: price,
offered_at: now,
expires_at: now.checked_add(120).context("Offer clock overflow")?,
};
// Recheck owner visibility/price under the catalog writer lock when publishing
// the offer, so an unshare during a large snapshot copy blocks NEW offers.
content_server::publish_snapshot_offer(data_dir, &item, &offer).await
}
+369 -9
View File
@@ -39,7 +39,7 @@ fn validate_id(id: &str) -> Result<()> {
);
Ok(())
}
fn canonical_mint(value: &str) -> Result<String> {
pub(crate) fn canonical_mint(value: &str) -> Result<String> {
let url = reqwest::Url::parse(value).context("Invalid purchase mint")?;
anyhow::ensure!(
matches!(url.scheme(), "http" | "https")
@@ -228,6 +228,8 @@ impl PreparedToken {
pub(crate) enum BuyerPhase {
Intent,
AcceptanceSaved,
CancellationPending,
Cancelled,
TokenPrepared,
ReceiptSaved,
Delivered,
@@ -245,6 +247,8 @@ impl BuyerRecord {
pub fn public_status(&self) -> serde_json::Value {
let (state, settlement_confirmed, delivered) = match self.phase {
BuyerPhase::Intent => ("intent", false, false),
BuyerPhase::CancellationPending => ("cancellation_pending_seller", false, false),
BuyerPhase::Cancelled => ("cancelled_unspent", false, false),
BuyerPhase::AcceptanceSaved => ("accepted_payment_unconfirmed", false, false),
BuyerPhase::TokenPrepared => ("token_prepared_settlement_unconfirmed", false, false),
BuyerPhase::ReceiptSaved => ("settled_delivery_pending", true, false),
@@ -260,8 +264,8 @@ impl BuyerRecord {
"settlement_confirmed": settlement_confirmed,
"amount_received": self.receipt().map(|receipt| receipt.amount_received),
"delivered": delivered,
"recovery_required": !delivered,
"can_start_new_payment": false,
"recovery_required": !delivered && self.phase != BuyerPhase::Cancelled,
"can_start_new_payment": self.phase == BuyerPhase::Cancelled,
})
}
@@ -284,6 +288,8 @@ impl BuyerRecord {
}
anyhow::ensure!(
match self.phase {
BuyerPhase::CancellationPending | BuyerPhase::Cancelled =>
self.token.is_none() && self.receipt.is_none(),
BuyerPhase::Intent =>
self.acceptance.is_none() && self.token.is_none() && self.receipt.is_none(),
BuyerPhase::AcceptanceSaved =>
@@ -302,6 +308,7 @@ impl BuyerRecord {
#[serde(deny_unknown_fields)]
pub(crate) enum SellerPhase {
Intent,
Cancelled,
Settled { amount_received: u64 },
ReceiptSaved(Receipt),
}
@@ -315,6 +322,10 @@ pub(crate) struct SellerRecord {
}
impl SellerRecord {
pub fn acceptance(&self) -> Result<Acceptance> {
anyhow::ensure!(
!matches!(self.phase, SellerPhase::Cancelled),
"Seller cancelled this operation"
);
Ok(Acceptance {
contract_hash: self.contract.context_hash()?,
accepted_at: self.accepted_at,
@@ -322,15 +333,22 @@ impl SellerRecord {
}
fn validate(&self) -> Result<()> {
self.contract.validate()?;
self.acceptance()?.validate(&self.contract)?;
if !matches!(self.phase, SellerPhase::Cancelled) {
self.acceptance()?.validate(&self.contract)?;
}
if let Some(token_hash) = &self.token_hash {
anyhow::ensure!(valid_hash(token_hash), "Invalid seller token hash");
}
anyhow::ensure!(
matches!(self.phase, SellerPhase::Intent) || self.token_hash.is_some(),
matches!(self.phase, SellerPhase::Intent | SellerPhase::Cancelled)
|| self.token_hash.is_some(),
"Seller token was not durably bound"
);
match &self.phase {
SellerPhase::Cancelled => anyhow::ensure!(
self.token_hash.is_none(),
"Cancelled seller already has a token"
),
SellerPhase::Intent => (),
SellerPhase::Settled { amount_received } => {
anyhow::ensure!(
@@ -355,6 +373,13 @@ struct Envelope {
/// Exclusive journal access across tasks and processes. Hold this only while
/// changing local purchase state; release it before transport/wallet calls.
/// A later caller reopens and revalidates the immutable contract before advancing.
#[derive(Serialize, Deserialize)]
struct RetiredOffer {
id: String,
buyer_did: String,
offer_sha256: String,
}
pub(crate) struct Journal {
directory: PathBuf,
_lock: std::fs::File,
@@ -423,7 +448,16 @@ impl Journal {
fn path(&self, role: &str, id: &str) -> Result<PathBuf> {
validate_id(id)?;
anyhow::ensure!(
matches!(role, "buyer" | "seller"),
matches!(
role,
"buyer"
| "seller"
| "protocol-offer"
| "offer-retired"
| "envelope-buyer"
| "envelope-seller"
| "plan-buyer"
),
"Invalid purchase journal role"
);
Ok(self.directory.join(format!("{role}-{id}.json")))
@@ -511,6 +545,280 @@ impl Journal {
.sync_all()?;
Ok(())
}
/// Caller sealed the wallet first under its mutation guard. This phase
/// still blocks replacement until authenticated seller acknowledgement.
pub async fn begin_cancellation(&self, contract: &Contract) -> Result<()> {
let mut record = self.bound_buyer(contract).await?;
anyhow::ensure!(
matches!(
record.phase,
BuyerPhase::Intent
| BuyerPhase::AcceptanceSaved
| BuyerPhase::CancellationPending
| BuyerPhase::Cancelled
),
"Funded purchase cannot cancel as unspent"
);
if record.phase == BuyerPhase::Cancelled {
return Ok(());
}
record.phase = BuyerPhase::CancellationPending;
record.validate()?;
self.write("buyer", &contract.id, &record).await
}
pub async fn finish_cancellation(
&self,
contract: &Contract,
verified_seller: &str,
) -> Result<()> {
anyhow::ensure!(
verified_seller == contract.seller_did,
"Cancellation acknowledgement seller changed"
);
let mut record = self.bound_buyer(contract).await?;
anyhow::ensure!(
matches!(
record.phase,
BuyerPhase::CancellationPending | BuyerPhase::Cancelled
),
"Cancellation was not sealed locally"
);
record.phase = BuyerPhase::Cancelled;
record.validate()?;
self.write("buyer", &contract.id, &record).await
}
/// Runs under the same journal flock as accept/token binding. A token bound
/// before this lock wins and prevents cancellation, even before settlement.
pub async fn cancel_seller(&self, contract: &Contract) -> Result<()> {
let mut record = if let Some(record) = self.seller(&contract.id).await? {
anyhow::ensure!(
record.contract == *contract,
"Seller cancellation terms changed"
);
record
} else {
SellerRecord {
contract: contract.clone(),
accepted_at: 0,
token_hash: None,
phase: SellerPhase::Cancelled,
}
};
anyhow::ensure!(
record.token_hash.is_none()
&& matches!(record.phase, SellerPhase::Intent | SellerPhase::Cancelled),
"Seller already received this payment; recover settlement"
);
record.phase = SellerPhase::Cancelled;
record.validate()?;
self.write("seller", &contract.id, &record).await
}
// Add these methods inside content_purchase::Journal; extend path role allowlist
// with "protocol-offer" | "envelope-buyer" | "envelope-seller" | "plan-buyer".
// The existing same flock/checksum/private permissions/synchronous commit apply.
pub async fn protocol_offer(
&self,
id: &str,
) -> Result<Option<crate::content_purchase_protocol::Offer>> {
let value: Option<crate::content_purchase_protocol::Offer> =
self.read("protocol-offer", id).await?;
if let Some(offer) = &value {
anyhow::ensure!(offer.id == id, "Offer identifier changed");
offer.validate()?;
}
Ok(value)
}
pub async fn save_protocol_offer(
&self,
offer: &crate::content_purchase_protocol::Offer,
) -> Result<()> {
offer.validate()?;
let retired: Option<RetiredOffer> = self.read("offer-retired", &offer.id).await?;
anyhow::ensure!(
retired.is_none(),
"Original offer expired without acceptance; recover cancellation before replacing it"
);
if let Some(old) = self.protocol_offer(&offer.id).await? {
anyhow::ensure!(old == *offer, "Original offer changed");
return Ok(());
}
self.retire_unaccepted_offers(chrono::Utc::now().timestamp())
.await?;
// Bound unaffiliated authenticated peers' quote storage. Existing IDs
// replay above without consuming another slot; no accepted liability GC.
let mut entries = fs::read_dir(&self.directory).await?;
let mut total = 0usize;
let mut buyer = 0usize;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let Some(id) = name
.strip_prefix("protocol-offer-")
.and_then(|v| v.strip_suffix(".json"))
else {
continue;
};
// Accepted obligations are retained, but do not consume the quota
// for new, never-accepted quotes.
if self.seller(id).await?.is_some() {
continue;
}
total += 1;
anyhow::ensure!(
total < 4096,
"Purchase offer storage limit reached; existing operations remain recoverable"
);
if self
.protocol_offer(id)
.await?
.is_some_and(|value| value.buyer_did == offer.buyer_did)
{
buyer += 1;
anyhow::ensure!(
buyer < 128,
"Buyer offer storage limit reached; recover an existing operation"
);
}
}
self.write("protocol-offer", &offer.id, offer).await
}
/// Retire only provably unaccepted quotes under the same journal flock as
/// acceptance/cancellation. The immutable commitment survives forever;
/// absence of history is never interpreted as permission to pay again.
pub async fn retire_unaccepted_offers(&self, now: i64) -> Result<()> {
let mut entries = fs::read_dir(&self.directory).await?;
let mut bytes = 0u64;
while let Some(entry) = entries.next_entry().await? {
if entry
.file_name()
.to_string_lossy()
.starts_with("offer-retired-")
{
bytes = bytes
.checked_add(entry.metadata().await?.len())
.context("Offer retirement size overflow")?;
}
}
let mut entries = fs::read_dir(&self.directory).await?;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
let Some(id) = name
.to_str()
.and_then(|name| name.strip_prefix("protocol-offer-"))
.and_then(|name| name.strip_suffix(".json"))
else {
continue;
};
let Some(offer) = self.protocol_offer(id).await? else {
continue;
};
if offer.expires_at > now
|| self.seller(id).await?.is_some()
|| self.protocol_envelope("seller", id).await?.is_some()
{
continue;
}
let commitment = hash(&serde_json::to_vec(&offer)?);
let previous: Option<RetiredOffer> = self.read("offer-retired", id).await?;
if let Some(previous) = previous {
anyhow::ensure!(
previous.id == id
&& previous.buyer_did == offer.buyer_did
&& previous.offer_sha256 == commitment,
"Retired offer binding changed"
);
} else {
// Bound compact terminal metadata separately from accepted liability.
anyhow::ensure!(bytes < 64 * 1024 * 1024, "Quote retirement storage needs maintenance; existing purchases remain recoverable");
self.write(
"offer-retired",
id,
&RetiredOffer {
id: id.into(),
buyer_did: offer.buyer_did,
offer_sha256: commitment,
},
)
.await?;
bytes = bytes
.checked_add(std::fs::metadata(self.path("offer-retired", id)?)?.len())
.context("Retirement size overflow")?;
}
// Synchronous commit point: cancellation cannot leave an asynchronous
// deletion running after this flock is released.
std::fs::remove_file(self.path("protocol-offer", id)?)?;
std::fs::File::open(&self.directory)?.sync_all()?;
}
Ok(())
}
pub async fn retired_offer_matches(
&self,
offer: &crate::content_purchase_protocol::Offer,
) -> Result<bool> {
let value: Option<RetiredOffer> = self.read("offer-retired", &offer.id).await?;
let commitment = hash(&serde_json::to_vec(offer)?);
Ok(value.is_some_and(|value| {
value.id == offer.id
&& value.buyer_did == offer.buyer_did
&& value.offer_sha256 == commitment
}))
}
pub async fn protocol_envelope(
&self,
role: &str,
id: &str,
) -> Result<Option<crate::content_purchase_protocol::Envelope>> {
let role = match role {
"buyer" => "envelope-buyer",
"seller" => "envelope-seller",
_ => anyhow::bail!("Invalid envelope role"),
};
let value: Option<crate::content_purchase_protocol::Envelope> = self.read(role, id).await?;
if let Some(value) = &value {
anyhow::ensure!(value.contract()?.id == id, "Envelope identifier changed");
}
Ok(value)
}
pub async fn save_protocol_envelope(
&self,
role: &str,
value: &crate::content_purchase_protocol::Envelope,
) -> Result<()> {
let contract = value.contract()?;
if let Some(old) = self.protocol_envelope(role, &contract.id).await? {
anyhow::ensure!(old == *value, "Original payment shape changed");
return Ok(());
}
let role = match role {
"buyer" => "envelope-buyer",
"seller" => "envelope-seller",
_ => anyhow::bail!("Invalid envelope role"),
};
self.write(role, &contract.id, value).await
}
pub async fn buyer_plan(
&self,
id: &str,
) -> Result<Option<crate::wallet::purchase_plan::PreparedPayment>> {
self.read("plan-buyer", id).await
}
pub async fn save_buyer_plan(
&self,
id: &str,
plan: &crate::wallet::purchase_plan::PreparedPayment,
) -> Result<()> {
if let Some(old) = self.buyer_plan(id).await? {
anyhow::ensure!(
serde_json::to_value(&old)? == serde_json::to_value(plan)?,
"Original wallet plan changed"
);
return Ok(());
}
self.write("plan-buyer", id, plan).await
}
pub async fn buyer(&self, id: &str) -> Result<Option<BuyerRecord>> {
let result: Option<BuyerRecord> = self.read("buyer", id).await?;
if let Some(record) = &result {
@@ -527,6 +835,44 @@ impl Journal {
}
Ok(result)
}
/// Caller holds the wallet mutation guard. Keep accepted seller liabilities
/// redeemable until settlement or authenticated cancellation is durable.
pub(crate) async fn ensure_seller_policy_change(
&self,
network: EcashNetwork,
accepted_mints: Option<&[String]>,
) -> Result<()> {
let mut entries = fs::read_dir(&self.directory).await?;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
let Some(id) = name
.to_str()
.and_then(|v| v.strip_prefix("seller-"))
.and_then(|v| v.strip_suffix(".json"))
else {
continue;
};
let record = self
.seller(id)
.await?
.context("Seller liability disappeared")?;
if !matches!(record.phase, SellerPhase::Intent) {
continue;
}
anyhow::ensure!(
record.contract.network == network,
"A pending accepted sale requires its original wallet network"
);
if let Some(mints) = accepted_mints {
anyhow::ensure!(
mints.iter().any(|mint| canonical_mint(mint).ok().as_deref()
== Some(record.contract.mint_url.as_str())),
"A pending accepted sale requires its original accepted mint"
);
}
}
Ok(())
}
/// Discover existing node-owned intent after browser storage loss. The
/// journal lock makes this lookup and prepare_buyer's duplicate guard one
/// serialized decision; caller-supplied fresh UUIDs cannot bypass it.
@@ -584,9 +930,10 @@ impl Journal {
)
.await?;
anyhow::ensure!(
pending
.iter()
.all(|record| record.phase == BuyerPhase::Delivered),
pending.iter().all(|record| matches!(
record.phase,
BuyerPhase::Delivered | BuyerPhase::Cancelled
)),
"An existing purchase must be recovered before a new operation is created"
);
contract.validate_new_at(now)?;
@@ -607,6 +954,10 @@ impl Journal {
&record.contract == contract,
"Seller purchase terms changed"
);
anyhow::ensure!(
!matches!(record.phase, SellerPhase::Cancelled),
"Seller cancelled this operation"
);
return Ok(record);
}
contract.validate_new_at(now)?;
@@ -651,6 +1002,13 @@ impl Journal {
"Acceptance is from another seller"
);
let mut record = self.bound_buyer(contract).await?;
anyhow::ensure!(
!matches!(
record.phase,
BuyerPhase::CancellationPending | BuyerPhase::Cancelled
),
"Buyer cancellation is sealed"
);
acceptance.validate(contract)?;
if let Some(previous) = &record.acceptance {
anyhow::ensure!(previous == acceptance, "Seller acceptance changed");
@@ -718,6 +1076,7 @@ impl Journal {
) -> Result<SellerRecord> {
let mut record = self.bound_seller(contract).await?;
match &record.phase {
SellerPhase::Cancelled => anyhow::bail!("Seller cancelled this operation"),
SellerPhase::Intent => record.phase = SellerPhase::Settled { amount_received },
SellerPhase::Settled {
amount_received: saved,
@@ -744,6 +1103,7 @@ impl Journal {
pub async fn issue_receipt(&self, contract: &Contract) -> Result<Receipt> {
let mut record = self.bound_seller(contract).await?;
let amount_received = match &record.phase {
SellerPhase::Cancelled => anyhow::bail!("Seller cancelled this operation"),
SellerPhase::Intent => anyhow::bail!("Seller settlement is not durable"),
SellerPhase::Settled { amount_received } => *amount_received,
SellerPhase::ReceiptSaved(receipt) => return Ok(receipt.clone()),
@@ -0,0 +1,363 @@
//! Buyer orchestration. Transport implementation MUST use authenticated exact-body
//! single-delivery FIPS requests to the verified seller; retries replay this UUID.
use crate::{
content_purchase::{BuyerPhase, Contract, Journal, Receipt},
content_purchase_protocol::{Accepted, Cancelled, Envelope, Offer, SellerStatus, Settlement},
wallet::purchase_plan,
};
use anyhow::{Context, Result};
use std::{future::Future, path::Path};
pub(crate) trait PurchaseTransport: Send + Sync {
/// This identity is the independently verified peer binding, not response JSON.
fn seller_did(&self) -> &str;
fn seller_onion(&self) -> &str;
fn offer(&self, id: &str, content_id: &str) -> impl Future<Output = Result<Offer>> + Send;
fn accept(&self, envelope: &Envelope) -> impl Future<Output = Result<Accepted>> + Send;
fn status(&self, envelope: &Envelope) -> impl Future<Output = Result<SellerStatus>> + Send;
fn cancel(&self, envelope: &Envelope) -> impl Future<Output = Result<Cancelled>> + Send;
fn settle(&self, request: &Settlement) -> impl Future<Output = Result<Receipt>> + Send;
}
#[derive(Clone)]
pub(crate) enum ReadyPurchase {
AwaitingConfirmation {
operation_id: String,
envelope_sha256: String,
gross_token_sats: u64,
seller_net_sats: u64,
wallet_debit_sats: u64,
expires_at: i64,
network: crate::wallet::ecash::EcashNetwork,
mint_url: String,
},
Cancelled {
operation_id: String,
},
Cached {
seller_onion: String,
content_id: String,
},
Entitlement {
contract: Contract,
receipt: Receipt,
},
}
#[derive(Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct PurchaseConsent {
pub operation_id: String,
pub envelope_sha256: String,
pub wallet_debit_sats: u64,
}
/// Called after owner consent to content and maximum total wallet debit. Existing
/// pending purchase lookup runs before UUID allocation, even after browser loss.
/// Caller must separately reject unresolved legacy attempts; never invent their IDs.
pub(crate) async fn purchase(
data_dir: &Path,
verified_buyer: &str,
content_id: &str,
filename: Option<&str>,
max_wallet_debit: u64,
consent: Option<&PurchaseConsent>,
transport: &impl PurchaseTransport,
) -> Result<ReadyPurchase> {
purchase_bound(
data_dir,
verified_buyer,
content_id,
filename,
max_wallet_debit,
consent,
transport,
None,
)
.await
}
#[derive(Clone)]
pub(crate) struct ExpectedRental {
pub seller_did: String,
pub content_id: String,
pub sha256: String,
pub price_sats: u64,
pub viewing_seconds: u64,
}
impl ExpectedRental {
pub fn verify(&self, offer: &Offer) -> Result<()> {
anyhow::ensure!(
self.content_id.starts_with("registered_")
&& offer.content_id == self.content_id
&& offer.seller_did == self.seller_did
&& offer.content_sha256 == self.sha256
&& offer.seller_net_sats == self.price_sats
&& offer.viewing_seconds == Some(self.viewing_seconds),
"Published rental hash, price, duration or seller changed; no payment started"
);
Ok(())
}
}
pub(crate) async fn purchase_bound(
data_dir: &Path,
verified_buyer: &str,
content_id: &str,
filename: Option<&str>,
max_wallet_debit: u64,
consent: Option<&PurchaseConsent>,
transport: &impl PurchaseTransport,
expected: Option<&ExpectedRental>,
) -> Result<ReadyPurchase> {
let _purchase_lock =
crate::content_owned::lock_seller_purchases(transport.seller_onion()).await;
let owned = crate::content_owned::list_owned_checked(data_dir)
.await
.context("Could not verify prior purchases; no new payment started")?;
let mut unresolved_owned = false;
if let Some(cached) = owned.iter().find(|item| {
item.onion == transport.seller_onion()
&& (item.content_id == content_id
|| filename.is_some_and(|name| {
!name.is_empty()
&& item.filename.trim_start_matches('/') == name.trim_start_matches('/')
}))
}) {
// Metadata without bytes remains a delivery recovery, not permission to pay.
if let Ok(Some((_, mut file))) =
crate::content_owned::open_owned(data_dir, &cached.onion, &cached.content_id).await
{
let records = {
Journal::open(data_dir)
.await?
.find_buyers(verified_buyer, transport.seller_did(), &cached.content_id)
.await?
};
if let Some(record) = records
.iter()
.find(|record| record.phase == BuyerPhase::ReceiptSaved)
{
// Recover the narrow crash window after cache publication but
// before recording delivery. Never pay to repair this boundary.
use sha2::{Digest, Sha256};
use tokio::io::AsyncReadExt;
let mut hash = Sha256::new();
let mut count = 0u64;
let mut buffer = vec![0; 65536];
loop {
let read = file.read(&mut buffer).await?;
if read == 0 {
break;
}
count = count
.checked_add(read as u64)
.context("Cached size overflow")?;
anyhow::ensure!(
count <= record.contract.content_size,
"Cached purchase changed; no new payment allowed"
);
hash.update(&buffer[..read]);
}
let sha = hex::encode(hash.finalize());
Journal::open(data_dir)
.await?
.record_delivery(&record.contract, &sha, count)
.await?;
}
return Ok(ReadyPurchase::Cached {
seller_onion: cached.onion.clone(),
content_id: cached.content_id.clone(),
});
}
unresolved_owned = true;
}
let previous = {
let journal = Journal::open(data_dir).await?;
let matching = journal
.find_buyers(verified_buyer, transport.seller_did(), content_id)
.await?;
let unresolved: Vec<_> = matching
.into_iter()
.filter(|record| record.phase != BuyerPhase::Cancelled)
.collect();
anyhow::ensure!(
unresolved.len() <= 1,
"Multiple original purchases require recovery; no new payment started"
);
unresolved.into_iter().next()
};
let envelope = if let Some(previous) = previous {
let journal = Journal::open(data_dir).await?;
let envelope = journal
.protocol_envelope("buyer", &previous.contract.id)
.await?
.context("Original payment shape is missing; no new spend allowed")?;
anyhow::ensure!(
envelope.contract()? == previous.contract,
"Original purchase binding changed"
);
if let Some(expected) = expected {
expected.verify(&envelope.offer)?;
}
if let Some(receipt) = previous.receipt() {
return Ok(ReadyPurchase::Entitlement {
contract: previous.contract.clone(),
receipt: receipt.clone(),
});
}
envelope
} else {
anyhow::ensure!(
!unresolved_owned,
"Prior purchase delivery needs recovery; no new payment operation was created"
);
let id = uuid::Uuid::new_v4().to_string();
let offer = transport.offer(&id, content_id).await?;
offer.validate()?;
anyhow::ensure!(
offer.id == id
&& offer.content_id == content_id
&& offer.buyer_did == verified_buyer
&& offer.seller_did == transport.seller_did(),
"Authenticated seller offer binding changed"
);
if let Some(expected) = expected {
expected.verify(&offer)?;
}
let plan = purchase_plan::prepare(
data_dir,
&offer.mint_url,
offer.network,
offer.seller_net_sats,
max_wallet_debit,
)
.await?;
let envelope = Envelope {
offer,
fee_plan: plan.fee_plan.clone(),
};
purchase_plan::persist_intent(data_dir, &envelope, &plan).await?;
envelope
};
let contract = envelope.contract()?;
let (phase, plan) = {
let journal = Journal::open(data_dir).await?;
let buyer = journal
.buyer(&contract.id)
.await?
.context("Buyer intent disappeared")?;
let plan = journal
.buyer_plan(&contract.id)
.await?
.context("Original wallet plan is missing")?;
anyhow::ensure!(
plan.fee_plan == envelope.fee_plan,
"Original wallet fee shape changed"
);
(buyer.phase, plan)
};
if phase == BuyerPhase::CancellationPending {
cancel_purchase(data_dir, &envelope, &plan, transport).await?;
return Ok(ReadyPurchase::Cancelled {
operation_id: contract.id,
});
}
if phase == BuyerPhase::Intent {
let expected = envelope.commitment()?;
if let Some(consent) = consent {
anyhow::ensure!(
consent.operation_id == contract.id
&& consent.envelope_sha256 == expected
&& consent.wallet_debit_sats == plan.wallet_debit_sats,
"Payment confirmation does not match the saved quote"
);
} else {
return Ok(ReadyPurchase::AwaitingConfirmation {
operation_id: contract.id,
envelope_sha256: expected,
gross_token_sats: contract.gross_token_sats,
seller_net_sats: contract.minimum_net_sats,
wallet_debit_sats: plan.wallet_debit_sats,
expires_at: contract.expires_at,
network: envelope.offer.network,
mint_url: envelope.offer.mint_url.clone(),
});
}
}
// Replaying a prepared send journal never selects fresh wallet proofs.
purchase_plan::persist(data_dir, &contract, &plan).await?;
if phase == BuyerPhase::Intent {
let accepted = transport.accept(&envelope).await?;
anyhow::ensure!(
accepted.envelope_sha256 == envelope.commitment()?,
"Seller accepted another payment shape"
);
let journal = Journal::open(data_dir).await?;
journal
.record_acceptance(&contract, &accepted.acceptance, transport.seller_did())
.await?;
}
// Confirm the required authenticated FIPS route before any fresh mint
// dispatch. An outage keeps this operation; it never selects Tor/new UUID.
// Disconnect after this check is still possible and remains recoverable.
let known_receipt = if phase != BuyerPhase::TokenPrepared {
match transport.status(&envelope).await? {
SellerStatus::Accepted => None,
SellerStatus::Settled { receipt } => Some(receipt),
SellerStatus::Cancelled => anyhow::bail!(
"Seller cancelled this operation; reconcile the original unspent intent"
),
}
} else {
None
};
// Planned executor performs fresh-post keyset/fee validation at the wallet
// mutation boundary; original committed/restore results recover before expiry.
let token = crate::content_purchase_executor::prepare_buyer_token_planned(
data_dir,
&contract,
&envelope.fee_plan,
)
.await?;
envelope.fee_plan.validate_token(&token)?;
let receipt = if let Some(receipt) = known_receipt {
receipt
} else {
transport.settle(&Settlement { envelope, token }).await?
};
{
let journal = Journal::open(data_dir).await?;
journal.record_receipt(&contract, &receipt).await?;
}
Ok(ReadyPurchase::Entitlement { contract, receipt })
}
/// Explicit caller cancellation/expired-unfunded recovery, never an automatic
/// interpretation of a failed HTTP call or unknown mint state.
pub(crate) async fn cancel_purchase(
data_dir: &Path,
envelope: &Envelope,
plan: &purchase_plan::PreparedPayment,
transport: &impl PurchaseTransport,
) -> Result<()> {
let contract = envelope.contract()?;
anyhow::ensure!(
contract.seller_did == transport.seller_did(),
"Cancellation seller changed"
);
purchase_plan::cancel_unspent(data_dir, &contract, plan).await?;
{
Journal::open(data_dir)
.await?
.begin_cancellation(&contract)
.await?;
}
let reply = transport.cancel(envelope).await?;
anyhow::ensure!(
reply.envelope_sha256 == envelope.commitment()?,
"Seller cancelled another operation"
);
Journal::open(data_dir)
.await?
.finish_cancellation(&contract, transport.seller_did())
.await
}
@@ -0,0 +1,194 @@
//! Permanent purchase caching. Hash verification occurs inside the stream before
//! owned-cache publication; receipt remains recoverable after any interruption.
use crate::content_purchase::{Contract, Journal, Receipt};
use anyhow::{Context, Result};
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use std::path::Path;
pub(crate) async fn cache(
data_dir: &Path,
onion: &str,
contract: &Contract,
receipt: &Receipt,
) -> Result<crate::content_owned::OwnedItem> {
anyhow::ensure!(
!contract.content_id.starts_with("registered_"),
"Rentals use the scoped playback proxy, not permanent caching"
);
let envelope = {
let journal = Journal::open(data_dir).await?;
let buyer = journal
.buyer(&contract.id)
.await?
.context("Buyer purchase is missing")?;
anyhow::ensure!(
buyer.contract == *contract && buyer.receipt() == Some(receipt),
"Original buyer receipt changed"
);
journal
.protocol_envelope("buyer", &contract.id)
.await?
.context("Original delivery metadata is missing")?
};
let peer = crate::federation::load_unique_payment_peer(data_dir, onion).await?;
anyhow::ensure!(peer.did == contract.seller_did, "Delivery seller changed");
let path = format!("/content/{}/purchase/{}", contract.content_id, contract.id);
let (response, _) =
crate::fips::dial::PeerRequest::new(peer.fips_npub.as_deref(), onion, &path)
.require_fips()
.single_delivery()
.timeout(std::time::Duration::from_secs(900))
.header("X-Content-Capability", receipt.capability.clone())
.send_content_get(data_dir)
.await?;
anyhow::ensure!(
response.status() == reqwest::StatusCode::OK,
"Original purchase delivery is unavailable; no new payment sent"
);
anyhow::ensure!(
response.content_length() == Some(contract.content_size),
"Original snapshot length changed"
);
let stream = verified_stream(
response.bytes_stream(),
contract.content_sha256.clone(),
contract.content_size,
);
let owned = crate::content_owned::record_purchase_stream(
data_dir,
crate::content_owned::OwnedItem {
onion: onion.into(),
content_id: contract.content_id.clone(),
filename: envelope.offer.filename,
mime_type: envelope.offer.mime_type,
size_bytes: contract.content_size,
paid_sats: contract.gross_token_sats,
ecash_backend: "cashu".into(),
purchased_at: chrono::Utc::now().to_rfc3339(),
download_complete: false,
},
Box::pin(stream),
Some(contract.content_size),
)
.await?;
Journal::open(data_dir)
.await?
.record_delivery(contract, &contract.content_sha256, contract.content_size)
.await?;
Ok(owned)
}
fn verified_stream<S, E>(
stream: S,
expected_hash: String,
expected_size: u64,
) -> impl futures_util::Stream<Item = std::io::Result<bytes::Bytes>>
where
S: futures_util::Stream<Item = Result<bytes::Bytes, E>>,
E: std::error::Error + Send + Sync + 'static,
{
futures_util::stream::try_unfold(
(Box::pin(stream), Sha256::new(), 0u64),
move |(mut stream, mut hash, total)| {
let expected_hash = expected_hash.clone();
async move {
match stream.next().await {
Some(chunk) => {
let chunk = chunk.map_err(std::io::Error::other)?;
let total = total
.checked_add(chunk.len() as u64)
.filter(|n| *n <= expected_size)
.ok_or_else(|| {
std::io::Error::other("Snapshot exceeded accepted size")
})?;
hash.update(&chunk);
Ok(Some((chunk, (stream, hash, total))))
}
None => {
if total != expected_size || hex::encode(hash.finalize()) != expected_hash {
return Err(std::io::Error::other(
"Snapshot did not match accepted hash/size",
));
}
Ok::<_, std::io::Error>(None)
}
}
}
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content_owned::{self, OwnedItem};
fn item() -> OwnedItem {
OwnedItem {
onion: "seller.onion".into(),
content_id: "video".into(),
filename: "clip.mp4".into(),
mime_type: "video/mp4".into(),
size_bytes: 4,
paid_sats: 8,
ecash_backend: "cashu".into(),
purchased_at: "now".into(),
download_complete: false,
}
}
#[tokio::test]
async fn corrupted_truncated_and_excess_bytes_remain_recoverable_until_original_hash_arrives() {
let root = tempfile::tempdir().unwrap();
let hash = hex::encode(Sha256::digest(b"good"));
for bytes in [b"evil".as_slice(), b"goo".as_slice(), b"good!".as_slice()] {
let input = futures_util::stream::iter([Ok::<_, std::io::Error>(
bytes::Bytes::copy_from_slice(bytes),
)]);
let stream = Box::pin(verified_stream(input, hash.clone(), 4));
assert!(
content_owned::record_purchase_stream(root.path(), item(), stream, Some(4))
.await
.is_err()
);
let entries = content_owned::list_owned_checked(root.path())
.await
.unwrap();
assert_eq!(entries.len(), 1);
assert!(!entries[0].download_complete);
assert_eq!(entries[0].paid_sats, 8);
let error = content_owned::open_owned(root.path(), "seller.onion", "video")
.await
.err()
.expect("Incomplete paid bytes must not be served");
assert!(
error.to_string().contains("delivery is incomplete"),
"{error:#}"
);
}
let input = futures_util::stream::iter([
Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"go")),
Ok(bytes::Bytes::from_static(b"od")),
]);
let stream = Box::pin(verified_stream(input, hash, 4));
let completed = content_owned::record_purchase_stream(root.path(), item(), stream, Some(4))
.await
.unwrap();
assert!(completed.download_complete);
let (_, mut file) = content_owned::open_owned(root.path(), "seller.onion", "video")
.await
.unwrap()
.unwrap();
let mut bytes = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut file, &mut bytes)
.await
.unwrap();
assert_eq!(bytes, b"good");
assert_eq!(
content_owned::list_owned_checked(root.path())
.await
.unwrap()
.len(),
1
);
}
}
@@ -70,6 +70,7 @@ pub(crate) async fn settle_seller_token(
match record.phase {
SellerPhase::ReceiptSaved(receipt) => return Ok(receipt),
SellerPhase::Settled { .. } => return journal.issue_receipt(contract).await,
SellerPhase::Cancelled => anyhow::bail!("Seller cancelled this purchase"),
SellerPhase::Intent => (),
}
}
@@ -87,3 +88,36 @@ pub(crate) async fn settle_seller_token(
journal.record_settlement(contract, received).await?;
journal.issue_receipt(contract).await
}
pub(crate) async fn prepare_buyer_token_planned(
data_dir: &Path,
contract: &Contract,
plan: &crate::wallet::purchase_fee_plan::FeePlan,
) -> Result<String> {
contract.validate()?;
{
let journal = Journal::open(data_dir).await?;
let record = journal
.buyer(&contract.id)
.await?
.context("Buyer intent is not durable")?;
anyhow::ensure!(&record.contract == contract, "Buyer purchase terms changed");
if let Some(token) = record.token() {
return Ok(token.to_owned());
}
anyhow::ensure!(
record.phase == BuyerPhase::AcceptanceSaved,
"Authenticated seller acceptance is not durable"
);
}
// Deadline is enforced inside the wallet after recovering original results,
// immediately before a fresh exact send or mint POST. Do not pre-reject an
// expired contract here: a previous wallet commit may need to be recovered.
let token = ecash::send_token_preplanned_before(data_dir, contract, plan).await?;
let journal = Journal::open(data_dir).await?;
let record = journal.record_token(contract, &token).await?;
Ok(record
.token()
.context("Prepared buyer token is missing")?
.to_owned())
}
@@ -0,0 +1,636 @@
//! Typed bodies for authenticated, single-delivery purchase POST endpoints.
//! The handler verifies v2 method/path/audience/exact-body proof before calling.
use crate::{
content_purchase::{Acceptance, Contract, Journal, Receipt, SellerPhase},
wallet::{ecash::EcashNetwork, mint_client::MintClient, purchase_fee_plan::FeePlan},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
pub(crate) const OFFER_ROUTE: &str = "/content/purchase/v1/offer";
pub(crate) const ACCEPT_ROUTE: &str = "/content/purchase/v1/accept";
pub(crate) const SETTLE_ROUTE: &str = "/content/purchase/v1/settle";
pub(crate) const STATUS_ROUTE: &str = "/content/purchase/v1/status";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Offer {
pub id: String,
pub buyer_did: String,
pub seller_did: String,
pub content_id: String,
pub filename: String,
pub mime_type: String,
pub content_sha256: String,
pub content_size: u64,
#[serde(default)]
pub viewing_seconds: Option<u64>,
pub terms_sha256: String,
pub network: EcashNetwork,
pub mint_url: String,
pub seller_net_sats: u64,
pub offered_at: i64,
pub expires_at: i64,
}
impl Offer {
pub fn validate(&self) -> Result<()> {
anyhow::ensure!(
if self.content_id.starts_with("registered_") {
self.viewing_seconds
.is_some_and(|seconds| (1..=31_536_000).contains(&seconds))
} else {
self.viewing_seconds.is_none()
},
"Offer rental duration binding is invalid"
);
anyhow::ensure!(
!self.filename.is_empty()
&& self.filename.len() <= 4096
&& !self.filename.chars().any(char::is_control),
"Invalid offer filename"
);
anyhow::ensure!(
self.mime_type.len() <= 128
&& self.mime_type.parse::<hyper::header::HeaderValue>().is_ok(),
"Invalid offer MIME type"
);
let contract = Contract {
version: 1,
id: self.id.clone(),
buyer_did: self.buyer_did.clone(),
seller_did: self.seller_did.clone(),
content_id: self.content_id.clone(),
content_sha256: self.content_sha256.clone(),
content_size: self.content_size,
terms_sha256: self.terms_sha256.clone(),
network: self.network,
mint_url: self.mint_url.clone(),
gross_token_sats: self.seller_net_sats,
minimum_net_sats: self.seller_net_sats,
offered_at: self.offered_at,
expires_at: self.expires_at,
};
contract.validate()
}
pub fn contract(&self, plan: &FeePlan) -> Result<Contract> {
self.validate()?;
plan.validate()?;
anyhow::ensure!(
plan.mint_url == self.mint_url && plan.net_sats >= self.seller_net_sats,
"Payment plan does not cover this offer"
);
let contract = Contract {
version: 1,
id: self.id.clone(),
buyer_did: self.buyer_did.clone(),
seller_did: self.seller_did.clone(),
content_id: self.content_id.clone(),
content_sha256: self.content_sha256.clone(),
content_size: self.content_size,
terms_sha256: self.terms_sha256.clone(),
network: self.network,
mint_url: self.mint_url.clone(),
gross_token_sats: plan.gross_sats,
minimum_net_sats: self.seller_net_sats,
offered_at: self.offered_at,
expires_at: self.expires_at,
};
contract.validate()?;
Ok(contract)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Envelope {
pub offer: Offer,
pub fee_plan: FeePlan,
}
impl Envelope {
pub fn contract(&self) -> Result<Contract> {
self.offer.contract(&self.fee_plan)
}
pub fn commitment(&self) -> Result<String> {
use sha2::{Digest, Sha256};
let contract = self.contract()?;
// Explicit ordered components: never hash an incidental map ordering.
Ok(hex::encode(Sha256::digest(serde_json::to_vec(&(
"archipelago-purchase-envelope-v1",
contract.context_hash()?,
self.fee_plan.commitment()?,
&self.offer.filename,
&self.offer.mime_type,
self.offer.viewing_seconds,
))?)))
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Accepted {
pub envelope_sha256: String,
pub acceptance: Acceptance,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Settlement {
pub envelope: Envelope,
pub token: String,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub(crate) enum SellerStatus {
Accepted,
Cancelled,
Settled { receipt: Receipt },
}
/// Match the policy used by fresh seller redemption before inviting a spend.
/// Settled receipts remain recoverable independently of later wallet settings.
pub(crate) async fn ensure_seller_mint_policy(
data_dir: &Path,
network: crate::wallet::ecash::EcashNetwork,
mint_url: &str,
) -> Result<()> {
use crate::{content_purchase::canonical_mint, wallet::ecash};
anyhow::ensure!(
ecash::load_network(data_dir).await? == network,
"Seller wallet is on another payment network; no new payment should be sent"
);
let accepted = ecash::load_accepted_mints(data_dir).await?;
let mint = canonical_mint(mint_url)?;
anyhow::ensure!(
accepted
.mints
.iter()
.any(|candidate| canonical_mint(candidate).ok().as_deref() == Some(mint.as_str())),
"Seller does not accept the quoted mint; no new payment should be sent"
);
Ok(())
}
/// Caller obtained these fields from a currently shared, immutable verified
/// snapshot (registered_terms/ordinary catalog snapshot), never from client JSON.
/// Save before returning the offer; replay always returns original terms.
pub(crate) async fn save_offer(
data_dir: &Path,
offered: &Offer,
verified_buyer: &str,
now: i64,
) -> Result<Offer> {
anyhow::ensure!(offered.buyer_did == verified_buyer, "Offer buyer mismatch");
ensure_seller_mint_policy(data_dir, offered.network, &offered.mint_url).await?;
let journal = Journal::open(data_dir).await?;
if let Some(saved) = journal.protocol_offer(&offered.id).await? {
anyhow::ensure!(
saved.buyer_did == verified_buyer && saved.content_id == offered.content_id,
"Offer operation changed buyer/content"
);
return Ok(saved);
}
anyhow::ensure!(
now >= offered.offered_at && now < offered.expires_at,
"Offer is expired"
);
journal.save_protocol_offer(offered).await?;
Ok(offered.clone())
}
/// POST ACCEPT_ROUTE. Persist both fee commitment and liability before replying.
pub(crate) async fn accept(
data_dir: &Path,
envelope: &Envelope,
verified_buyer: &str,
now: impl Fn() -> i64,
) -> Result<Accepted> {
let contract = envelope.contract()?;
anyhow::ensure!(
contract.buyer_did == verified_buyer,
"Acceptance buyer mismatch"
);
{
let journal = Journal::open(data_dir).await?;
let offer = journal
.protocol_offer(&contract.id)
.await?
.context("Seller offer is missing")?;
anyhow::ensure!(offer == envelope.offer, "Seller offer changed");
if let Some(previous) = journal.protocol_envelope("seller", &contract.id).await? {
anyhow::ensure!(previous == *envelope, "Accepted payment shape changed");
if let Some(record) = journal.seller(&contract.id).await? {
return Ok(Accepted {
envelope_sha256: envelope.commitment()?,
acceptance: record.acceptance()?,
});
}
}
}
ensure_seller_mint_policy(data_dir, contract.network, &contract.mint_url).await?;
// No database lock across remote mint query. Recheck durable binding/time
// when committing below, so concurrent acceptance cannot alter the plan.
let keysets = MintClient::new(&contract.mint_url)?.get_keysets().await?;
envelope.fee_plan.verify_mint_keysets(&keysets, false)?;
// Same wallet -> purchase lock order as policy updates: an accepted
// liability cannot race removal of its mint or a network switch.
let _wallet = crate::wallet::mutation::guard(data_dir).await?;
ensure_seller_mint_policy(data_dir, contract.network, &contract.mint_url).await?;
let journal = Journal::open(data_dir).await?;
anyhow::ensure!(
journal.protocol_offer(&contract.id).await?.as_ref() == Some(&envelope.offer)
&& !journal.retired_offer_matches(&envelope.offer).await?,
"Original offer retired before acceptance; recover cancellation without spending"
);
journal.save_protocol_envelope("seller", envelope).await?;
let record = journal.prepare_seller(&contract, now()).await?;
Ok(Accepted {
envelope_sha256: envelope.commitment()?,
acceptance: record.acceptance()?,
})
}
/// POST SETTLE_ROUTE. Accepting new liability is deliberately impossible here.
pub(crate) async fn settle(
data_dir: &Path,
request: &Settlement,
verified_buyer: &str,
) -> Result<Receipt> {
let contract = request.envelope.contract()?;
anyhow::ensure!(
contract.buyer_did == verified_buyer,
"Settlement buyer mismatch"
);
{
let journal = Journal::open(data_dir).await?;
let saved = journal
.protocol_envelope("seller", &contract.id)
.await?
.context("Seller acceptance is missing")?;
anyhow::ensure!(
saved == request.envelope,
"Settlement changed accepted payment shape"
);
}
request.envelope.fee_plan.validate_token(&request.token)?;
crate::content_purchase_executor::settle_seller_token(
data_dir,
&contract,
&request.token,
verified_buyer,
)
.await
}
/// POST STATUS_ROUTE. Read-only, bound to buyer and exact accepted envelope.
pub(crate) async fn status(
data_dir: &Path,
envelope: &Envelope,
verified_buyer: &str,
) -> Result<SellerStatus> {
let contract = envelope.contract()?;
anyhow::ensure!(
contract.buyer_did == verified_buyer,
"Status buyer mismatch"
);
let journal = Journal::open(data_dir).await?;
anyhow::ensure!(
journal
.protocol_envelope("seller", &contract.id)
.await?
.as_ref()
== Some(envelope),
"Accepted operation not found"
);
let record = journal
.seller(&contract.id)
.await?
.context("Accepted operation not found")?;
match record.phase {
SellerPhase::ReceiptSaved(receipt) => Ok(SellerStatus::Settled { receipt }),
SellerPhase::Cancelled => Ok(SellerStatus::Cancelled),
_ => {
ensure_seller_mint_policy(data_dir, contract.network, &contract.mint_url).await?;
Ok(SellerStatus::Accepted)
}
}
}
pub(crate) const CANCEL_ROUTE: &str = "/content/purchase/v1/cancel";
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Cancelled {
pub envelope_sha256: String,
}
/// Authenticated buyer sealed its wallet before requesting cancellation. The
/// seller only seals an unfunded intent and rejects every subsequent late accept.
pub(crate) async fn cancel(
data_dir: &Path,
envelope: &Envelope,
verified_buyer: &str,
) -> Result<Cancelled> {
let contract = envelope.contract()?;
anyhow::ensure!(
contract.buyer_did == verified_buyer,
"Cancellation buyer changed"
);
let journal = Journal::open(data_dir).await?;
anyhow::ensure!(
journal.protocol_offer(&contract.id).await?.as_ref() == Some(&envelope.offer)
|| journal.retired_offer_matches(&envelope.offer).await?,
"Original seller offer changed"
);
journal.save_protocol_envelope("seller", envelope).await?;
journal.cancel_seller(&contract).await?;
Ok(Cancelled {
envelope_sha256: envelope.commitment()?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wallet::{
cashu::{CashuToken, Proof},
purchase_fee_plan::KeysetPlan,
};
fn envelope() -> Envelope {
let buyer = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap();
let seller = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap();
let fee_plan = FeePlan::from_shape(
"https://unused-mint.invalid",
vec![KeysetPlan {
keyset_id: "0011223344556677".into(),
denominations: vec![8],
input_fee_ppk: 0,
}],
)
.unwrap();
Envelope {
offer: Offer {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: buyer,
seller_did: seller,
content_id: "registered_film".into(),
filename: "film.mp4".into(),
mime_type: "video/mp4".into(),
content_sha256: "ab".repeat(32),
content_size: 10,
viewing_seconds: Some(60),
terms_sha256: "cd".repeat(32),
network: EcashNetwork::Mainnet,
mint_url: "https://unused-mint.invalid".into(),
seller_net_sats: 8,
offered_at: 1000,
expires_at: 1100,
},
fee_plan,
}
}
#[tokio::test]
async fn unaccepted_mint_cannot_publish_offer_and_cancel_releases_policy_pin() {
use crate::wallet::ecash::{
save_accepted_mints, save_network, AcceptedMints, EcashNetwork,
};
let root = tempfile::tempdir().unwrap();
let value = envelope();
let buyer = &value.offer.buyer_did;
assert!(save_offer(root.path(), &value.offer, buyer, 1001)
.await
.is_err());
assert!(Journal::open(root.path())
.await
.unwrap()
.protocol_offer(&value.offer.id)
.await
.unwrap()
.is_none());
save_accepted_mints(
root.path(),
&AcceptedMints {
mints: vec![value.offer.mint_url.clone()],
},
)
.await
.unwrap();
save_offer(root.path(), &value.offer, buyer, 1001)
.await
.unwrap();
{
let journal = Journal::open(root.path()).await.unwrap();
journal
.save_protocol_envelope("seller", &value)
.await
.unwrap();
journal
.prepare_seller(&value.contract().unwrap(), 1001)
.await
.unwrap();
}
assert!(
save_accepted_mints(root.path(), &AcceptedMints { mints: vec![] })
.await
.is_err()
);
assert!(save_network(root.path(), EcashNetwork::Testnet)
.await
.is_err());
cancel(root.path(), &value, buyer).await.unwrap();
save_accepted_mints(root.path(), &AcceptedMints { mints: vec![] })
.await
.unwrap();
save_network(root.path(), EcashNetwork::Testnet)
.await
.unwrap();
assert!(matches!(
status(root.path(), &value, buyer).await.unwrap(),
SellerStatus::Cancelled
));
}
#[tokio::test]
async fn seller_cancellation_replays_after_lost_reply_and_seals_late_acceptance() {
let root = tempfile::tempdir().unwrap();
let value = envelope();
crate::wallet::ecash::save_accepted_mints(
root.path(),
&crate::wallet::ecash::AcceptedMints {
mints: vec![value.offer.mint_url.clone()],
},
)
.await
.unwrap();
let contract = value.contract().unwrap();
save_offer(root.path(), &value.offer, &contract.buyer_did, 1001)
.await
.unwrap();
{
let journal = Journal::open(root.path()).await.unwrap();
journal
.save_protocol_envelope("seller", &value)
.await
.unwrap();
journal.prepare_seller(&contract, 1001).await.unwrap();
journal.prepare_buyer(&contract, 1001).await.unwrap();
journal.begin_cancellation(&contract).await.unwrap();
}
let lost = cancel(root.path(), &value, &contract.buyer_did)
.await
.unwrap();
assert!(accept(root.path(), &value, &contract.buyer_did, || 1002)
.await
.is_err());
let replay = cancel(root.path(), &value, &contract.buyer_did)
.await
.unwrap();
assert_eq!(lost.envelope_sha256, replay.envelope_sha256);
let journal = Journal::open(root.path()).await.unwrap();
journal
.finish_cancellation(&contract, &contract.seller_did)
.await
.unwrap();
assert_eq!(
journal.buyer(&contract.id).await.unwrap().unwrap().phase,
crate::content_purchase::BuyerPhase::Cancelled
);
let delayed = Acceptance {
contract_hash: contract.context_hash().unwrap(),
accepted_at: 1001,
};
assert!(journal
.record_acceptance(&contract, &delayed, &contract.seller_did)
.await
.is_err());
}
#[tokio::test]
async fn incoming_token_or_saved_settlement_prevents_seller_cancellation() {
let root = tempfile::tempdir().unwrap();
let value = envelope();
crate::wallet::ecash::save_accepted_mints(
root.path(),
&crate::wallet::ecash::AcceptedMints {
mints: vec![value.offer.mint_url.clone()],
},
)
.await
.unwrap();
let contract = value.contract().unwrap();
save_offer(root.path(), &value.offer, &contract.buyer_did, 1001)
.await
.unwrap();
let token = CashuToken::new(
&contract.mint_url,
vec![Proof {
amount: 8,
id: "0011223344556677".into(),
secret: "test-original-payment".into(),
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".into(),
}],
)
.serialize()
.unwrap();
{
let journal = Journal::open(root.path()).await.unwrap();
journal
.save_protocol_envelope("seller", &value)
.await
.unwrap();
journal.prepare_seller(&contract, 1001).await.unwrap();
journal
.record_incoming_token(&contract, &token)
.await
.unwrap();
}
assert!(cancel(root.path(), &value, &contract.buyer_did)
.await
.is_err());
let original = {
let journal = Journal::open(root.path()).await.unwrap();
journal.record_settlement(&contract, 8).await.unwrap();
journal.issue_receipt(&contract).await.unwrap()
};
assert!(cancel(root.path(), &value, &contract.buyer_did)
.await
.is_err());
crate::wallet::ecash::save_accepted_mints(
root.path(),
&crate::wallet::ecash::AcceptedMints { mints: vec![] },
)
.await
.unwrap();
crate::wallet::ecash::save_network(
root.path(),
crate::wallet::ecash::EcashNetwork::Testnet,
)
.await
.unwrap();
match status(root.path(), &value, &contract.buyer_did)
.await
.unwrap()
{
SellerStatus::Settled { receipt } => assert!(receipt == original),
_ => panic!("Original receipt lost"),
}
}
#[tokio::test]
async fn expired_quote_cap_recovers_without_reusing_ids_or_retiring_accepted_liability() {
let root = tempfile::tempdir().unwrap();
let now = chrono::Utc::now().timestamp();
let mut accepted = envelope();
accepted.offer.offered_at = now;
accepted.offer.expires_at = now + 300;
let mut expired = Vec::new();
{
let journal = Journal::open(root.path()).await.unwrap();
journal.save_protocol_offer(&accepted.offer).await.unwrap();
journal
.save_protocol_envelope("seller", &accepted)
.await
.unwrap();
journal
.prepare_seller(&accepted.contract().unwrap(), now)
.await
.unwrap();
for _ in 0..128 {
let mut value = accepted.clone();
value.offer.id = uuid::Uuid::new_v4().to_string();
journal.save_protocol_offer(&value.offer).await.unwrap();
expired.push(value);
}
let mut next = accepted.clone();
next.offer.id = uuid::Uuid::new_v4().to_string();
assert!(journal.save_protocol_offer(&next.offer).await.is_err());
journal.retire_unaccepted_offers(now + 301).await.unwrap();
assert!(journal
.protocol_offer(&accepted.offer.id)
.await
.unwrap()
.is_some());
assert!(journal.seller(&accepted.offer.id).await.unwrap().is_some());
assert!(journal
.protocol_offer(&expired[0].offer.id)
.await
.unwrap()
.is_none());
assert!(journal
.retired_offer_matches(&expired[0].offer)
.await
.unwrap());
journal.save_protocol_offer(&next.offer).await.unwrap();
let mut changed = expired[0].offer.clone();
changed.expires_at += 300;
assert!(journal.save_protocol_offer(&changed).await.is_err());
assert!(!journal.retired_offer_matches(&changed).await.unwrap());
}
let original = &expired[0];
let ack = cancel(root.path(), original, &original.offer.buyer_did)
.await
.unwrap();
assert_eq!(ack.envelope_sha256, original.commitment().unwrap());
assert_eq!(
cancel(root.path(), original, &original.offer.buyer_did)
.await
.unwrap()
.envelope_sha256,
ack.envelope_sha256
);
assert!(
accept(root.path(), original, &original.offer.buyer_did, || now)
.await
.is_err()
);
}
}
@@ -0,0 +1,105 @@
//! Concrete buyer transport. Never redirects, changes route, or automatically
//! resends after an ambiguous delivery; the durable caller owns all replay.
use crate::{
content_purchase::Receipt,
content_purchase_caller::PurchaseTransport,
content_purchase_protocol::{
self as protocol, Accepted, Cancelled, Envelope, Offer, SellerStatus, Settlement,
},
};
use anyhow::{Context, Result};
use serde::{de::DeserializeOwned, Serialize};
use std::{path::PathBuf, time::Duration};
pub(crate) struct FipsPurchaseTransport {
data_dir: PathBuf,
onion: String,
seller_did: String,
fips_npub: String,
}
impl FipsPurchaseTransport {
pub async fn load(data_dir: PathBuf, onion: String) -> Result<Self> {
let peer = crate::federation::load_unique_payment_peer(&data_dir, &onion).await?;
let fips_npub = peer
.fips_npub
.context("Purchase seller has no authenticated mesh binding")?;
anyhow::ensure!(
!fips_npub.is_empty(),
"Purchase seller has no authenticated mesh binding"
);
Ok(Self {
data_dir,
onion,
seller_did: peer.did,
fips_npub,
})
}
async fn post<B: Serialize + Sync, R: DeserializeOwned>(
&self,
route: &str,
body: &B,
) -> Result<R> {
let (mut response, _) =
crate::fips::dial::PeerRequest::new(Some(&self.fips_npub), &self.onion, route)
.require_fips()
.single_delivery()
.timeout(Duration::from_secs(45))
.send_content_json(&self.data_dir, &self.seller_did, body)
.await?;
let status = response.status();
anyhow::ensure!(
status.is_success(),
"Purchase endpoint returned {}; recover the original operation",
status.as_u16()
);
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
bytes
.len()
.checked_add(chunk.len())
.is_some_and(|n| n <= 65536),
"Purchase response is too large"
);
bytes.extend_from_slice(&chunk);
}
serde_json::from_slice(&bytes)
.context("Invalid purchase response; original operation remains recoverable")
}
}
impl PurchaseTransport for FipsPurchaseTransport {
fn seller_onion(&self) -> &str {
&self.onion
}
fn seller_did(&self) -> &str {
&self.seller_did
}
async fn offer(&self, id: &str, content_id: &str) -> Result<Offer> {
#[derive(Serialize)]
struct Request<'a> {
id: &'a str,
content_id: &'a str,
}
self.post(protocol::OFFER_ROUTE, &Request { id, content_id })
.await
}
async fn accept(&self, envelope: &Envelope) -> Result<Accepted> {
self.post(protocol::ACCEPT_ROUTE, envelope).await
}
async fn status(&self, envelope: &Envelope) -> Result<SellerStatus> {
self.post(protocol::STATUS_ROUTE, envelope).await
}
async fn cancel(&self, envelope: &Envelope) -> Result<Cancelled> {
self.post(protocol::CANCEL_ROUTE, envelope).await
}
async fn settle(&self, request: &Settlement) -> Result<Receipt> {
self.post(protocol::SETTLE_ROUTE, request).await
}
}
pub(crate) async fn seller_onion_for_did(data_dir: &std::path::Path, did: &str) -> Result<String> {
Ok(
crate::federation::load_unique_payment_peer_by_did(data_dir, did)
.await?
.onion,
)
}
+26
View File
@@ -1897,3 +1897,29 @@ mod payment_source_tests {
}
}
}
// Make existing content_file_path pub(crate). Add this method in content_server.
pub(crate) async fn publish_snapshot_offer(
data_dir: &Path,
original: &ContentItem,
offer: &crate::content_purchase_protocol::Offer,
) -> Result<crate::content_purchase_protocol::Offer> {
let _held = CATALOG_WRITES.lock().await;
let current = load_catalog(data_dir).await?;
let item = current
.items
.iter()
.find(|item| item.id == original.id)
.context("Content was unshared before this offer")?;
anyhow::ensure!(
serde_json::to_value(item)? == serde_json::to_value(original)?,
"Shared content terms changed before offer publication"
);
crate::content_purchase_protocol::save_offer(
data_dir,
offer,
&offer.buyer_did,
chrono::Utc::now().timestamp(),
)
.await
}
+12 -47
View File
@@ -105,6 +105,7 @@ pub(crate) fn prepare(
"Shared snapshot version budget is full; retain existing purchases"
);
let version = io::private_directory(&root, &key)?;
let budget_operation = format!("shared:{key}");
if let Some(record) = read_manifest(&version)? {
anyhow::ensure!(
record.version == 1 && record.content_id == content_id && record.source == source_stamp,
@@ -117,6 +118,7 @@ pub(crate) fn prepare(
&& file.metadata()?.len() == record.size,
"Retained shared snapshot changed; preserve accepted purchases"
);
crate::snapshot_budget::finish_completed(data_dir, &budget_operation, record.size, limits)?;
return Ok(Snapshot {
file,
key,
@@ -158,6 +160,7 @@ pub(crate) fn prepare(
io::save_record(&version, "snapshot.json", &Envelope { record, checksum })?;
use std::io::{Seek, SeekFrom};
file.seek(SeekFrom::Start(0))?;
crate::snapshot_budget::finish_completed(data_dir, &budget_operation, size, limits)?;
return Ok(Snapshot {
file,
key,
@@ -174,24 +177,14 @@ pub(crate) fn prepare(
}
Err(error) => return Err(error),
}
let used = storage_bytes(&root)?;
anyhow::ensure!(
used.checked_add(size)
.and_then(|v| v.checked_add(128 * 1024))
.is_some_and(|total| total <= max_total_bytes),
"Shared snapshot storage budget is full; no new purchase accepted"
);
let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
anyhow::ensure!(
unsafe { libc::fstatvfs(root.as_raw_fd(), stat.as_mut_ptr()) } == 0,
"Could not verify snapshot disk space"
);
let stat = unsafe { stat.assume_init() };
let free = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64);
anyhow::ensure!(
free >= size.saturating_add(minimum_free_bytes),
"Not enough free storage for a retained purchase snapshot"
);
let reservation = crate::snapshot_budget::reserve(
data_dir,
&budget_operation,
size,
max_total_bytes,
minimum_free_bytes,
limits,
)?;
let (name, mut destination) = io::temporary(&version)?;
let mut temporary = Temporary {
directory: &version,
@@ -218,6 +211,7 @@ pub(crate) fn prepare(
};
let checksum = hash(&serde_json::to_vec(&record)?);
io::save_record(&version, "snapshot.json", &Envelope { record, checksum })?;
reservation.finish(limits)?;
Ok(Snapshot {
file,
key,
@@ -279,35 +273,6 @@ pub(crate) fn open_matching(
anyhow::bail!("Accepted content snapshot is unavailable; retain purchase for recovery")
}
fn storage_bytes(directory: &File) -> Result<u64> {
let mut total = 0u64;
for entry in std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()))? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_str().context("Invalid snapshot filename")?;
let metadata = std::fs::symlink_metadata(entry.path())?;
anyhow::ensure!(
!metadata.file_type().is_symlink(),
"Snapshot storage contains an unexpected symlink"
);
let size = if metadata.is_dir() {
storage_bytes(&io::open_at(
directory,
name,
libc::O_RDONLY | libc::O_DIRECTORY,
0,
)?)?
} else {
anyhow::ensure!(metadata.is_file(), "Unexpected snapshot storage entry");
metadata.len()
};
total = total
.checked_add(size)
.context("Snapshot storage accounting overflow")?;
}
Ok(total)
}
struct Temporary<'a> {
directory: &'a File,
name: String,
+1 -1
View File
@@ -20,7 +20,7 @@ pub(crate) use invites::notify_join;
// Crate-internal: peer-joined resolves the granted trust level by matching
// the acceptor's invite_token against our stored outgoing invites.
pub(crate) use storage::load_invites;
pub(crate) use storage::load_unique_payment_peer;
pub(crate) use storage::{load_unique_payment_peer, load_unique_payment_peer_by_did};
#[allow(unused_imports)]
pub use storage::{
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
@@ -101,6 +101,41 @@ pub(crate) async fn load_unique_payment_peer(
Ok(peer)
}
/// Resolve a purchase seller by raw identity before any display deduplication.
pub(crate) async fn load_unique_payment_peer_by_did(
data_dir: &Path,
did: &str,
) -> Result<FederatedNode> {
let _guard = FEDERATION_STORE_LOCK.lock().await;
let content = fs::read(data_dir.join(FEDERATION_DIR).join(NODES_FILE))
.await
.context("Could not read payment peer bindings")?;
let file: NodesFile =
serde_json::from_slice(&content).context("Invalid payment peer bindings")?;
let matching: Vec<_> = file.nodes.iter().filter(|peer| peer.did == did).collect();
anyhow::ensure!(
matching.len() == 1,
"Payment seller identity binding is missing or ambiguous"
);
let peer = matching[0];
let onion = peer.onion.strip_suffix(".onion").unwrap_or(&peer.onion);
anyhow::ensure!(
!onion.is_empty()
&& file
.nodes
.iter()
.filter(|node| node.onion.strip_suffix(".onion").unwrap_or(&node.onion) == onion)
.count()
== 1,
"Payment seller address binding is missing or ambiguous"
);
anyhow::ensure!(
crate::identity::did_key_from_pubkey_hex(&peer.pubkey)? == peer.did,
"Payment seller identity does not match its public key"
);
Ok(peer.clone())
}
/// Lock-free body of `load_nodes`. Callers that already hold
/// `FEDERATION_STORE_LOCK` (i.e. other functions in this module composing a
/// multi-step critical section) must call this instead of `load_nodes` to
@@ -547,6 +582,41 @@ mod tests {
}
}
#[tokio::test]
async fn payment_did_resolution_rejects_duplicates_hidden_by_display_merging() {
let dir = tempfile::tempdir().unwrap();
let key = hex::encode([1u8; 32]);
let did = crate::identity::did_key_from_pubkey_hex(&key).unwrap();
let mut original = make_node(&did, "a.onion");
original.pubkey = key;
save_nodes(dir.path(), &[original.clone()]).await.unwrap();
assert_eq!(
load_unique_payment_peer_by_did(dir.path(), &did)
.await
.unwrap()
.onion,
"a.onion"
);
let mut alternate = original.clone();
alternate.onion = "b.onion".into();
save_nodes(dir.path(), &[original.clone(), alternate])
.await
.unwrap();
assert!(load_unique_payment_peer_by_did(dir.path(), &did)
.await
.is_err());
let mut collision = original.clone();
collision.did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap();
collision.pubkey = hex::encode([2u8; 32]);
save_nodes(dir.path(), &[collision, original])
.await
.unwrap();
assert_eq!(load_nodes(dir.path()).await.unwrap().len(), 1);
assert!(load_unique_payment_peer_by_did(dir.path(), &did)
.await
.is_err());
}
#[test]
fn test_dedup_nodes_by_onion_collapses_same_onion() {
// Two entries share an onion (same physical node under two dids) — must
+18 -5
View File
@@ -47,12 +47,8 @@ mod content_invoice;
mod content_owned;
mod content_purchase;
mod content_purchase_executor;
mod content_snapshot;
mod media_stream;
mod media_registration;
mod registered_media;
mod prepared_media;
mod content_server;
mod content_snapshot;
mod crash_recovery;
mod credentials;
mod data_model;
@@ -68,6 +64,8 @@ mod host_ip;
mod identity;
mod identity_manager;
mod marketplace;
mod media_registration;
mod media_stream;
mod mesh;
mod mesh_ports;
mod monitoring;
@@ -82,11 +80,14 @@ mod nostr_relays;
mod nostr_security_tests;
mod peers;
mod port_allocator;
mod prepared_media;
mod rate_limit;
mod registered_media;
pub mod seed;
mod server;
mod session;
mod settings;
mod snapshot_budget;
mod state;
mod storage_crypto;
mod streaming;
@@ -584,3 +585,15 @@ async fn main() -> Result<()> {
// crash in `systemctl status`.
std::process::exit(0);
}
mod content_purchase_protocol;
mod content_purchase_caller;
mod content_purchase_transport;
mod content_purchase_download;
mod content_cloud_offer;
mod playback_handles;
+456 -3
View File
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::ffi::CString;
use std::fs::File;
use std::io::{Read, Write};
use std::io::{Read, Seek, SeekFrom, Write};
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
@@ -184,9 +184,8 @@ fn lower_hex(value: &str, length: usize) -> bool {
.bytes()
.all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v))
}
fn validate(
fn validate_intent(
intent: &Intent,
selection: &AuthorizedSelection,
pin: &InstallationPin,
identity: &crate::identity::NodeIdentity,
now: u64,
@@ -221,6 +220,16 @@ fn validate(
&& intent.expires_at - intent.created_at <= 600,
"Invalid registration terms or timestamps"
);
Ok(())
}
fn validate(
intent: &Intent,
selection: &AuthorizedSelection,
pin: &InstallationPin,
identity: &crate::identity::NodeIdentity,
now: u64,
) -> Result<()> {
validate_intent(intent, pin, identity, now)?;
anyhow::ensure!(
!selection.relative_path.as_os_str().is_empty()
&& selection
@@ -507,6 +516,206 @@ fn receipt_for(operation: &Operation, hash: String, size: u64) -> Receipt {
}
}
const RETIREMENT_DOMAIN: &str = "archipelago.indeehub.media-retirement.v1";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Retirement {
pub version: u8,
pub intent: Intent,
pub retired_at: u64,
pub signature: String,
}
impl Retirement {
pub fn preimage(&self) -> Result<Vec<u8>> {
let i = &self.intent;
Ok(serde_json::to_vec(&serde_json::json!([
RETIREMENT_DOMAIN,
i.request_id,
i.nonce,
i.app_audience,
i.node_did,
i.producer,
i.project_id,
i.price_sats,
i.viewing_seconds,
i.created_at,
i.expires_at,
self.retired_at
]))?)
}
fn verify(&self, intent: &Intent, identity: &crate::identity::NodeIdentity) -> Result<()> {
anyhow::ensure!(
self.version == 1
&& self.intent == *intent
&& self.retired_at >= intent.expires_at
&& self.retired_at <= MAX_SAFE_INTEGER
&& lower_hex(&self.signature, 128),
"Registration retirement terms changed"
);
identity.signing_key().verifying_key().verify_strict(
&self.preimage()?,
&Signature::from_slice(&hex::decode(&self.signature)?)?,
)?;
Ok(())
}
}
pub enum Resolution {
Prepared {
prepared: PreparedRegistration,
selection: AuthorizedSelection,
},
Retired(Retirement),
Pending {
request_id: String,
expires_at: u64,
},
}
/// Caller authenticates the producer's intent-only recovery/retirement consent.
/// Under the original operation lock, either verify the completed bytes/receipt,
/// or commit retirement. No source path from this request is opened or copied.
pub fn resolve(
data_dir: &Path,
identity: &crate::identity::NodeIdentity,
pin: &InstallationPin,
intent: &Intent,
now: u64,
limits: &Limits<'_>,
) -> Result<Resolution> {
validate_intent(intent, pin, identity, now)?;
let data_dir = data_dir.canonicalize()?;
let data = open_directory(&data_dir)?;
let root = private_directory(&data, PRIVATE_DIRECTORY)?;
let dir = private_directory(&root, &intent.request_id)?;
lock_operation(&dir, limits, Instant::now() + Duration::from_secs(30))?;
if let Some(retired) = read_record::<Retirement>(&dir, "retirement.json")? {
retired.verify(intent, identity)?;
dir.sync_all()?;
if let Some(operation) = read_record::<Operation>(&dir, "operation.json")? {
anyhow::ensure!(
operation.version == 1 && operation.binding.intent == *intent,
"Retired operation terms changed"
);
crate::snapshot_budget::finish_completed(
&data_dir,
&format!("registered:{}", intent.request_id),
operation.source.size,
limits,
)?;
}
return Ok(Resolution::Retired(retired));
}
let operation: Option<Operation> = read_record(&dir, "operation.json")?;
if let Some(operation) = &operation {
anyhow::ensure!(
operation.version == 1 && operation.binding.intent == *intent,
"Original registration intent changed"
);
validate(intent, &operation.binding.selection, pin, identity, now)?;
}
if let Some(receipt) = read_record::<Receipt>(&dir, "receipt.json")? {
let operation =
operation.context("Receipt has no original operation; preserve recovery data")?;
let saved: SnapshotRecord =
read_record(&dir, "snapshot.json")?.context("Snapshot commitment missing")?;
let mut snapshot = open_at(&dir, "media", libc::O_RDONLY | libc::O_NONBLOCK, 0)?;
anyhow::ensure!(
snapshot.metadata()?.mode() & 0o7777 == 0o400,
"Snapshot permissions changed"
);
let before = SourceStamp::read(&snapshot)?;
let (sha256, size) = hash_file(&mut snapshot, None, limits, &mut |_| Ok(()))?;
anyhow::ensure!(
SourceStamp::read(&snapshot)? == before
&& size == operation.source.size
&& sha256 == saved.sha256
&& size == saved.size,
"Completed registration bytes changed"
);
let mut expected = receipt_for(&operation, sha256, size);
anyhow::ensure!(
lower_hex(&receipt.signature, 128),
"Invalid completed signature"
);
identity.signing_key().verifying_key().verify_strict(
&receipt.preimage()?,
&Signature::from_slice(&hex::decode(&receipt.signature)?)?,
)?;
expected.signature = receipt.signature.clone();
anyhow::ensure!(
expected == receipt,
"Completed registration receipt changed"
);
snapshot.seek(SeekFrom::Start(0))?;
dir.sync_all()?;
crate::snapshot_budget::finish_completed(
&data_dir,
&format!("registered:{}", intent.request_id),
operation.source.size,
limits,
)?;
return Ok(Resolution::Prepared {
prepared: PreparedRegistration {
receipt,
snapshot,
snapshot_path: data_dir
.join(PRIVATE_DIRECTORY)
.join(&intent.request_id)
.join("media"),
},
selection: operation.binding.selection,
});
}
if now < intent.expires_at {
return Ok(Resolution::Pending {
request_id: intent.request_id.clone(),
expires_at: intent.expires_at,
});
}
// Missing operation metadata alongside media is damaged state, not proof
// that an earlier completion never happened. Preserve it for investigation.
if operation.is_none() {
anyhow::ensure!(
read_record::<SnapshotRecord>(&dir, "snapshot.json")?.is_none(),
"Snapshot exists without original intent; preserve recovery data"
);
match open_at(&dir, "media", libc::O_RDONLY | libc::O_NONBLOCK, 0) {
Ok(_) => anyhow::bail!("Media exists without original intent; preserve recovery data"),
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
{
()
}
Err(error) => return Err(error),
}
}
let mut retirement = Retirement {
version: 1,
intent: intent.clone(),
retired_at: now,
signature: String::new(),
};
retirement.signature = hex::encode(
identity
.signing_key()
.sign(&retirement.preimage()?)
.to_bytes(),
);
save_record(&dir, "retirement.json", &retirement)?;
if let Some(operation) = operation {
// Tombstone is durable under the copy lock: no writer can resume. Any
// retained partial bytes stay counted; no media or source is deleted.
crate::snapshot_budget::finish_completed(
&data_dir,
&format!("registered:{}", intent.request_id),
operation.source.size,
limits,
)?;
}
Ok(Resolution::Retired(retirement))
}
/// Performs disk I/O and cross-process locking; run on a blocking worker.
/// `now` is caller-supplied trusted UTC seconds, never a request-body timestamp.
/// `progress` may return an error to cancel; it must not change authorized terms.
@@ -554,6 +763,10 @@ pub fn prepare(
limits,
started + Duration::from_secs(wait_seconds),
)?;
if let Some(retired) = read_record::<Retirement>(&operation_dir, "retirement.json")? {
retired.verify(intent, identity)?;
anyhow::bail!("Registration was authoritatively retired; recover that result before starting a new intent");
}
let operation: Operation =
if let Some(saved) = read_record::<Operation>(&operation_dir, "operation.json")? {
anyhow::ensure!(
@@ -611,6 +824,19 @@ pub fn prepare(
SourceStamp::read(&source)? == operation.source,
"Selected Cloud file changed; use a new reviewed intent"
);
let _reservation = crate::snapshot_budget::reserve_until(
&data_dir,
&format!("registered:{}", intent.request_id),
operation.source.size,
crate::snapshot_budget::DEFAULT_MAX_TOTAL_BYTES,
crate::snapshot_budget::DEFAULT_MIN_FREE_BYTES,
limits,
started + Duration::from_secs(intent.expires_at.saturating_sub(now).min(30)),
)?;
anyhow::ensure!(
now.saturating_add(started.elapsed().as_secs()) < intent.expires_at,
"Registration expired while awaiting snapshot capacity"
);
let (name, mut destination) = temporary(&operation_dir)?;
let (hash, size) =
hash_file(&mut source, Some(&mut destination), limits, &mut progress)?;
@@ -690,6 +916,12 @@ pub fn prepare(
save_record(&operation_dir, "receipt.json", &receipt)?;
}
operation_dir.sync_all()?;
crate::snapshot_budget::finish_completed(
&data_dir,
&format!("registered:{}", intent.request_id),
operation.source.size,
limits,
)?;
// Reopen from the held directory to return a descriptor positioned at zero.
let snapshot = open_at(
&operation_dir,
@@ -832,6 +1064,51 @@ mod tests {
assert_eq!(fixture.run(1000).unwrap().receipt, expected);
}
#[tokio::test]
async fn independent_nodejs_retirement_wire_matches_terminal_record() {
let vector: serde_json::Value = serde_json::from_str(include_str!(
"media_registration/fixtures/retirement-v1.json"
))
.unwrap();
let mut fixture = Fixture::new().await;
std::fs::write(
fixture.root.path().join("identity/node_key"),
hex::decode(vector["testSeedHex"].as_str().unwrap()).unwrap(),
)
.unwrap();
fixture.identity =
crate::identity::NodeIdentity::load_existing(&fixture.root.path().join("identity"))
.await
.unwrap();
fixture.pin = InstallationPin {
node_did: vector["pin"]["nodeDid"].as_str().unwrap().into(),
app_audience: vector["pin"]["appAudience"].as_str().unwrap().into(),
};
fixture.intent = serde_json::from_value(vector["intent"].clone()).unwrap();
let expected: Retirement = serde_json::from_value(vector["retirement"].clone()).unwrap();
assert_eq!(
expected.preimage().unwrap(),
vector["preimageUtf8"].as_str().unwrap().as_bytes()
);
expected.verify(&fixture.intent, &fixture.identity).unwrap();
let result = resolve(
fixture.root.path(),
&fixture.identity,
&fixture.pin,
&fixture.intent,
expected.retired_at,
&Limits {
max_bytes: 1024,
cancelled: &fixture.cancelled,
},
)
.unwrap();
match result {
Resolution::Retired(actual) => assert_eq!(actual, expected),
_ => panic!("expected retirement"),
}
}
#[test]
fn expired_lock_deadline_returns_without_waiting() {
let root = tempfile::tempdir().unwrap();
@@ -1085,4 +1362,180 @@ mod tests {
b"damaged fixture"
);
}
#[tokio::test]
async fn completed_registration_releases_crashed_reservation_without_source_or_new_admission() {
let fixture = Fixture::new().await;
let original = fixture.run(1100).unwrap();
let operation = format!("registered:{}", fixture.intent.request_id);
let limits = Limits {
max_bytes: 1_000_000,
cancelled: &fixture.cancelled,
};
let reservation = crate::snapshot_budget::reserve(
fixture.root.path(),
&operation,
150_000,
4 * 1024 * 1024,
0,
&limits,
)
.unwrap();
drop(reservation); // Crash after durable receipt, before reservation cleanup.
let name = format!("{}.json", hex::encode(Sha256::digest(operation.as_bytes())));
let ledger = fixture.root.path().join("snapshot-reservations").join(name);
assert!(ledger.exists());
std::fs::remove_file(fixture.root.path().join("cloud/film.mp4")).unwrap();
let recovered = fixture.run(1700).unwrap();
assert_eq!(recovered.receipt, original.receipt);
assert!(!ledger.exists());
}
#[tokio::test]
async fn retirement_is_durable_exact_and_prevents_clock_rollback_or_late_prepare_resurrection()
{
let fixture = Fixture::new().await;
let limits = Limits {
max_bytes: 1_000_000,
cancelled: &fixture.cancelled,
};
assert!(matches!(
resolve(
fixture.root.path(),
&fixture.identity,
&fixture.pin,
&fixture.intent,
1100,
&limits
)
.unwrap(),
Resolution::Pending { .. }
));
let first = match resolve(
fixture.root.path(),
&fixture.identity,
&fixture.pin,
&fixture.intent,
1700,
&limits,
)
.unwrap()
{
Resolution::Retired(v) => v,
_ => panic!("expected retirement"),
};
first.verify(&fixture.intent, &fixture.identity).unwrap();
let again = match resolve(
fixture.root.path(),
&fixture.identity,
&fixture.pin,
&fixture.intent,
1800,
&limits,
)
.unwrap()
{
Resolution::Retired(v) => v,
_ => panic!("expected original retirement"),
};
assert_eq!(first, again);
assert!(fixture.run(1100).is_err()); // even a later clock rollback cannot reopen it
assert!(!fixture.operation_dir().join("media").exists());
let mut changed = fixture.intent.clone();
changed.price_sats += 1;
assert!(resolve(
fixture.root.path(),
&fixture.identity,
&fixture.pin,
&changed,
1800,
&limits
)
.is_err());
}
#[tokio::test]
async fn completed_resolution_after_expiry_never_retires_or_copies_removed_source() {
let fixture = Fixture::new().await;
let original = fixture.run(1100).unwrap();
std::fs::remove_file(fixture.root.path().join("cloud/film.mp4")).unwrap();
for now in [1700, 1800] {
let resolved = resolve(
fixture.root.path(),
&fixture.identity,
&fixture.pin,
&fixture.intent,
now,
&Limits {
max_bytes: 1_000_000,
cancelled: &fixture.cancelled,
},
)
.unwrap();
match resolved {
Resolution::Prepared {
prepared,
selection,
} => {
assert_eq!(prepared.receipt, original.receipt);
assert_eq!(selection, fixture.selection);
}
_ => panic!("completed registration must not be retired"),
}
}
assert!(!fixture.operation_dir().join("retirement.json").exists());
}
#[tokio::test]
async fn concurrent_prepare_and_retire_choose_completed_receipt_or_one_durable_retirement() {
use std::sync::mpsc;
for abort_copy in [false, true] {
let fixture = Arc::new(Fixture::new().await);
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let copying = fixture.clone();
let worker = std::thread::spawn(move || {
let mut entered = false;
copying.with_progress(1100, |_| {
if !entered {
entered = true;
entered_tx.send(()).unwrap();
release_rx.recv().unwrap();
}
anyhow::ensure!(!abort_copy, "fixture interrupted copy");
Ok(())
})
});
entered_rx.recv().unwrap();
let resolving = fixture.clone();
let (resolving_tx, resolving_rx) = mpsc::channel();
let resolver = std::thread::spawn(move || {
resolving_tx.send(()).unwrap();
resolve(
resolving.root.path(),
&resolving.identity,
&resolving.pin,
&resolving.intent,
1700,
&Limits {
max_bytes: 1_000_000,
cancelled: &resolving.cancelled,
},
)
});
resolving_rx.recv().unwrap();
release_tx.send(()).unwrap();
let prepared = worker.join().unwrap();
let result = resolver.join().unwrap().unwrap();
if abort_copy {
assert!(prepared.is_err());
assert!(matches!(result, Resolution::Retired(_)));
assert!(fixture.run(1100).is_err());
assert!(!fixture.operation_dir().join("receipt.json").exists());
} else {
let expected = prepared.unwrap().receipt;
match result {
Resolution::Prepared { prepared, .. } => assert_eq!(prepared.receipt, expected),
_ => panic!("completion must win"),
}
assert!(!fixture.operation_dir().join("retirement.json").exists());
}
}
}
}
@@ -0,0 +1,41 @@
{
"description": "Public deterministic test vector only. Seed 07 repeated 32 times is never a node or user key.",
"testSeedHex": "0707070707070707070707070707070707070707070707070707070707070707",
"pin": {
"publicKey": "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c",
"nodeDid": "did:key:z6MkvDqGT54cXesYGvABpF1UapVNwjCqRcafi4Px6Thv5T3Z",
"appAudience": "fixture-indeehub"
},
"intent": {
"version": 1,
"requestId": "00000000-0000-4000-8000-000000000001",
"nonce": "abababababababababababababababababababababababababababababababab",
"appAudience": "fixture-indeehub",
"nodeDid": "did:key:z6MkvDqGT54cXesYGvABpF1UapVNwjCqRcafi4Px6Thv5T3Z",
"producer": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
"projectId": "fixture-project",
"priceSats": 15,
"viewingSeconds": 3600,
"createdAt": 1000,
"expiresAt": 1600
},
"preimageUtf8": "[\"archipelago.indeehub.media-retirement.v1\",\"00000000-0000-4000-8000-000000000001\",\"abababababababababababababababababababababababababababababababab\",\"fixture-indeehub\",\"did:key:z6MkvDqGT54cXesYGvABpF1UapVNwjCqRcafi4Px6Thv5T3Z\",\"cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd\",\"fixture-project\",15,3600,1000,1600,1610]",
"retirement": {
"version": 1,
"intent": {
"version": 1,
"requestId": "00000000-0000-4000-8000-000000000001",
"nonce": "abababababababababababababababababababababababababababababababab",
"appAudience": "fixture-indeehub",
"nodeDid": "did:key:z6MkvDqGT54cXesYGvABpF1UapVNwjCqRcafi4Px6Thv5T3Z",
"producer": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
"projectId": "fixture-project",
"priceSats": 15,
"viewingSeconds": 3600,
"createdAt": 1000,
"expiresAt": 1600
},
"retiredAt": 1610,
"signature": "1ee7c8de044b4bb254a8a659d322958c332aa3a6bfe3f1432c234448996d97b2b1f3827e3d10114a50bf84f13a12edfcb9346bd119593c3e0463a65eea8a5e02"
}
}
+308
View File
@@ -0,0 +1,308 @@
//! Process-local media handles; recovery reissues a handle for the same receipt.
//! No seller capability, wallet token or peer proof is sent to the browser.
use crate::{
container::registration_pin::InstalledAppContext,
content_purchase::{Contract, Journal},
};
use anyhow::{Context, Result};
use rand::RngCore;
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
path::Path,
sync::Mutex,
time::{Duration, Instant},
};
const MAX_HANDLES: usize = 1024;
const HANDLE_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Binding {
pub context: InstalledAppContext,
pub seller_onion: String,
pub contract: Contract,
session: [u8; 32],
}
struct Entry {
binding: Binding,
until: Instant,
lease_expires: Option<u64>,
}
#[derive(Default)]
pub(crate) struct PlaybackHandles {
entries: Mutex<HashMap<String, Entry>>,
}
fn session_fingerprint(session: &str) -> [u8; 32] {
let mut digest = Sha256::new();
digest.update(b"archipelago-local-playback-session-v1\0");
digest.update(session.as_bytes());
digest.finalize().into()
}
fn valid_handle(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}
impl PlaybackHandles {
/// Invoked only after normal owner-session/CSRF checks and the native broker's
/// verified installed-app/account authorization for this saved purchase.
/// It does not contact the seller, spend, open bytes, or start a rental lease.
pub async fn issue(
&self,
data_dir: &Path,
context: InstalledAppContext,
session: &str,
purchase_id: &str,
) -> Result<String> {
anyhow::ensure!(!session.is_empty(), "Owner session required");
let record = Journal::open(data_dir)
.await?
.buyer(purchase_id)
.await?
.context("Original purchase is missing; recover it without paying again")?;
let seller_onion = crate::content_purchase_transport::seller_onion_for_did(
data_dir,
&record.contract.seller_did,
)
.await?;
let peer = crate::federation::load_unique_payment_peer(data_dir, &seller_onion).await?;
anyhow::ensure!(
record.receipt().is_some(),
"Original purchase is not settled"
);
anyhow::ensure!(
record.contract.buyer_did == context.node_did
&& record.contract.seller_did == peer.did
&& record.contract.content_id.starts_with("registered_"),
"Purchase does not match the current node and seller"
);
record.contract.validate()?;
self.insert(
Binding {
context,
seller_onion: seller_onion.trim_end_matches(".onion").to_owned(),
contract: record.contract,
session: session_fingerprint(session),
},
Instant::now(),
)
}
fn insert(&self, binding: Binding, now: Instant) -> Result<String> {
let mut entries = self
.entries
.lock()
.map_err(|_| anyhow::anyhow!("Playback handles unavailable"))?;
entries.retain(|_, entry| now < entry.until);
if let Some((handle, _)) = entries.iter().find(|(_, entry)| entry.binding == binding) {
return Ok(handle.clone());
}
anyhow::ensure!(
entries.len() < MAX_HANDLES,
"Too many active playback handles"
);
let until = now
.checked_add(HANDLE_LIFETIME)
.context("Playback clock overflow")?;
let handle = loop {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
let handle = hex::encode(bytes);
if !entries.contains_key(&handle) {
break handle;
}
};
entries.insert(
handle.clone(),
Entry {
binding,
until,
lease_expires: None,
},
);
Ok(handle)
}
/// Session validity is checked independently on GET and during streaming.
/// Context must be freshly loaded from installed runtime state and its pin.
pub fn lookup(
&self,
handle: &str,
session: &str,
context: &InstalledAppContext,
) -> Result<Binding> {
anyhow::ensure!(valid_handle(handle), "Invalid playback handle");
let mut entries = self
.entries
.lock()
.map_err(|_| anyhow::anyhow!("Playback handles unavailable"))?;
let now = Instant::now();
entries.retain(|_, entry| now < entry.until);
let entry = entries
.get(handle)
.context("Playback handle expired; reopen the original purchase")?;
anyhow::ensure!(
entry.binding.session == session_fingerprint(session)
&& &entry.binding.context == context,
"Playback session or installed app changed"
);
Ok(entry.binding.clone())
}
/// Called only after the authenticated seller response matches receipt/size/range.
pub fn note_expiry(&self, handle: &str, binding: &Binding, expires: u64) -> Result<()> {
let mut entries = self
.entries
.lock()
.map_err(|_| anyhow::anyhow!("Playback handles unavailable"))?;
let entry = entries.get_mut(handle).context("Playback handle expired")?;
anyhow::ensure!(
&entry.binding == binding && Instant::now() < entry.until && expires > 0,
"Playback handle changed"
);
anyhow::ensure!(
entry.lease_expires.is_none_or(|old| old == expires),
"Seller changed the original viewing window"
);
entry.lease_expires = Some(expires);
Ok(())
}
/// Owner-session/native-broker status lookup; only public expiry leaves node.
pub fn expiry(
&self,
handle: &str,
session: &str,
context: &InstalledAppContext,
) -> Result<Option<u64>> {
self.lookup(handle, session, context)?;
let entries = self
.entries
.lock()
.map_err(|_| anyhow::anyhow!("Playback handles unavailable"))?;
Ok(entries
.get(handle)
.context("Playback handle expired")?
.lease_expires)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn binding() -> Binding {
let buyer = crate::identity::did_key_from_pubkey_hex(&hex::encode([1; 32])).unwrap();
Binding {
context: InstalledAppContext {
app_id: "indeedhub".into(),
backend_id: "indeedhub-api".into(),
app_audience: "installed-audience".into(),
node_did: buyer.clone(),
node_public_key: hex::encode([1; 32]),
app_origins: vec!["http://node:7777".into()],
},
seller_onion: "seller".into(),
session: session_fingerprint("original-session"),
contract: Contract {
version: 1,
id: uuid::Uuid::new_v4().to_string(),
buyer_did: buyer,
seller_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([2; 32]))
.unwrap(),
content_id: "registered_media".into(),
content_sha256: "ab".repeat(32),
content_size: 1024,
terms_sha256: "cd".repeat(32),
network: crate::wallet::ecash::EcashNetwork::Mainnet,
mint_url: "https://mint.invalid".into(),
gross_token_sats: 8,
minimum_net_sats: 7,
offered_at: 1000,
expires_at: 2000,
},
}
}
#[test]
fn reissue_keeps_original_contract_and_fixed_expiry_without_extending_handle_lifetime() {
let handles = PlaybackHandles::default();
let binding = binding();
let now = Instant::now();
let handle = handles.insert(binding.clone(), now).unwrap();
handles.note_expiry(&handle, &binding, 12345).unwrap();
assert_eq!(
handles
.insert(binding.clone(), now + Duration::from_secs(3))
.unwrap(),
handle
);
assert_eq!(
handles
.expiry(&handle, "original-session", &binding.context)
.unwrap(),
Some(12345)
);
assert!(handles.note_expiry(&handle, &binding, 12346).is_err());
let refreshed = handles
.insert(binding.clone(), now + HANDLE_LIFETIME)
.unwrap();
assert_ne!(refreshed, handle);
assert!(handles
.lookup(&handle, "original-session", &binding.context)
.is_err());
assert_eq!(
handles
.lookup(&refreshed, "original-session", &binding.context)
.unwrap()
.contract,
binding.contract
);
// No new seller lease is implied by a process-local handle: expiry stays
// unknown until the original receipt's authenticated GET reports it.
assert_eq!(
handles
.expiry(&refreshed, "original-session", &binding.context)
.unwrap(),
None
);
}
#[test]
fn handles_reject_other_sessions_installations_and_malformed_tokens() {
let handles = PlaybackHandles::default();
let binding = binding();
let handle = handles.insert(binding.clone(), Instant::now()).unwrap();
assert!(handles
.lookup(&handle, "other-session", &binding.context)
.is_err());
let mut changed = binding.context.clone();
changed.app_audience = "reinstalled".into();
assert!(handles
.lookup(&handle, "original-session", &changed)
.is_err());
for value in ["", "../secret", &"A".repeat(64), &"a".repeat(63)] {
assert!(handles
.lookup(value, "original-session", &binding.context)
.is_err());
}
assert!(handles
.lookup(&handle, "original-session", &binding.context)
.is_ok());
}
#[tokio::test]
async fn owner_session_revocation_does_not_become_a_new_handle_authorization() {
let root = tempfile::tempdir().unwrap();
let sessions =
crate::session::SessionStore::new_for_tests(root.path().join("sessions.json"));
let token = sessions.create().await;
let mut binding = binding();
binding.session = session_fingerprint(&token);
let handles = PlaybackHandles::default();
let handle = handles.insert(binding.clone(), Instant::now()).unwrap();
assert!(sessions.validate(&token).await);
assert!(handles.lookup(&handle, &token, &binding.context).is_ok());
sessions.remove(&token).await;
assert!(!sessions.validate(&token).await);
let replacement = sessions.create().await;
assert!(handles
.lookup(&handle, &replacement, &binding.context)
.is_err());
}
}
+143 -6
View File
@@ -317,9 +317,18 @@ fn persist_verified(held: &Held, record: &Registered) -> Result<()> {
Ok(())
}
fn ensure_verified(data_dir: &Path, record: &Registered, file: &mut File) -> Result<()> {
ensure_verified_for_use(data_dir, record, file, false)
}
fn ensure_verified_for_use(
data_dir: &Path,
record: &Registered,
file: &mut File,
first_use: bool,
) -> Result<()> {
// This per-registration lock does not hold the mapping/global directory or
// any buyer lease lock while hashing. Normally registration already saved
// the verified stamp, so first-open needs no second read of a large movie.
// any buyer lease lock while hashing. Range opens can reuse the saved
// verification, but a new lease always checks bytes before starting its clock:
// same-size writes within a filesystem timestamp tick can share a stamp.
let held = keyed(data_dir, "verify", &record.receipt.request_id)?;
let path = held
.path
@@ -330,10 +339,13 @@ fn ensure_verified(data_dir: &Path, record: &Registered, file: &mut File) -> Res
saved == expected && Stamp::from_file(file)? == record.stamp,
"Immutable snapshot verification binding changed"
);
return Ok(());
if !first_use {
return Ok(());
}
}
// Recover a missing cache by streaming the original signed hash. Never
// A new lease or missing cache requires the original signed byte hash. Never
// manufacture a positive cache entry from metadata alone after restart.
file.seek(SeekFrom::Start(0))?;
let mut digest = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
@@ -462,6 +474,16 @@ pub(crate) fn register_approved_selection(
limits,
progress,
)?;
commit_prepared(data_dir, identity, &pin, prepared, mime_type)
}
fn commit_prepared(
data_dir: &Path,
identity: &NodeIdentity,
pin: &registration_pin::RegistrationPin,
prepared: media_registration::PreparedRegistration,
mime_type: String,
) -> Result<Receipt> {
let record = Registered {
version: 1,
terms_sha256: terms(&prepared.receipt)?,
@@ -485,6 +507,57 @@ pub(crate) fn register_approved_selection(
Ok(record.receipt)
}
/// Intent-only resolution preserves original file selection; the request cannot
/// choose a new path. Serving metadata is durable before recovered receipt return.
pub(crate) fn resolve_registration(
data_dir: &Path,
identity: &NodeIdentity,
intent: &Intent,
authenticated_producer: &str,
now: u64,
limits: &Limits<'_>,
) -> Result<serde_json::Value> {
anyhow::ensure!(
authenticated_producer == intent.producer,
"Resolution producer changed"
);
let pin = registration_pin::load_existing(data_dir, APP_ID, identity)?;
match media_registration::resolve(
data_dir,
identity,
&media_registration::InstallationPin {
node_did: pin.node_did.clone(),
app_audience: pin.app_audience.clone(),
},
intent,
now,
limits,
)? {
media_registration::Resolution::Prepared {
prepared,
selection,
} => {
let receipt = commit_prepared(
data_dir,
identity,
&pin,
prepared,
selected_mime(&selection)?.into(),
)?;
Ok(serde_json::json!({"phase":"completed", "receipt":receipt}))
}
media_registration::Resolution::Retired(retirement) => {
Ok(serde_json::json!({"phase":"retired", "retirement":retirement}))
}
media_registration::Resolution::Pending {
request_id,
expires_at,
} => Ok(
serde_json::json!({"phase":"pending", "requestId":request_id, "expiresAt":expires_at}),
),
}
}
/// No request chooses app scope or storage path. This is an offer prerequisite,
/// not advertisement: the future offer creator must authenticate the peer and
/// bind all returned terms into the purchase contract before seller acceptance.
@@ -498,8 +571,12 @@ pub(crate) fn registered_terms(
let held = held(data_dir, false)?;
let record: Registered = read(&held.path.join(format!("{id}.json")))?
.context("Registered content is unavailable")?;
drop(held);
verify(&record, &pin, identity)?;
let _file = open_snapshot(data_dir, &record)?;
let mut file = open_snapshot(data_dir, &record)?;
// A quote must not invite payment for altered bytes, including a same-tick
// metadata collision. This scan completes before any offer is accepted.
ensure_verified_for_use(data_dir, &record, &mut file, true)?;
Ok((record.receipt, record.terms_sha256))
}
@@ -579,7 +656,11 @@ fn open_settled(
// Open before recording a first use: unreadable or altered media does not
// start a rental. No bytes leave this descriptor until the lease is durable.
let mut file = open_snapshot(data_dir, &record)?;
ensure_verified(data_dir, &record, &mut file)?;
let lease_path = data_dir
.join(STORE)
.join(format!("lease-{}.json", contract.id));
let first_use = read::<Lease>(&lease_path)?.is_none();
ensure_verified_for_use(data_dir, &record, &mut file, first_use)?;
let held = keyed(data_dir, "lease", &contract.id)?;
let path = held.path.join(format!("lease-{}.json", contract.id));
let contract_hash = contract.context_hash()?;
@@ -1008,6 +1089,32 @@ mod tests {
.join(format!("{}.json", receipt.request_id));
let mut record: Registered = read(&mapping).unwrap().unwrap();
record.stamp = Stamp::from_file(&File::open(&media).unwrap()).unwrap();
// Model an indistinguishable metadata stamp deterministically: both
// unsigned cache/mapping stamps match, while signed bytes do not. A
// positive metadata cache must not authorize an offer or first lease.
std::fs::write(&mapping, serde_json::to_vec(&record).unwrap()).unwrap();
let verified = fixture
.root
.path()
.join(STORE)
.join(format!("verified-{}.json", receipt.request_id));
std::fs::write(
&verified,
serde_json::to_vec(&verification(&record).unwrap()).unwrap(),
)
.unwrap();
assert!(
registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).is_err()
);
assert!(open_settled(
fixture.root.path(),
&fixture.identity,
&contract,
&"ab".repeat(32),
|| Ok(2000)
)
.is_err());
assert!(!lease.exists());
std::fs::remove_file(
fixture
.root
@@ -1141,4 +1248,34 @@ mod tests {
.join(format!("lease-{}.json", contract.id))
.exists());
}
#[tokio::test]
async fn offer_preflight_rebuilds_verified_cache_without_creating_rental_and_rejects_corruption(
) {
let fixture = Fixture::new().await;
let receipt = fixture.register(1100).unwrap();
let path = fixture
.root
.path()
.join(STORE)
.join(format!("verified-{}.json", receipt.request_id));
let original = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).unwrap();
let (terms, _) =
registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).unwrap();
assert_eq!(terms, receipt);
assert_eq!(std::fs::read(&path).unwrap(), original);
assert!(std::fs::read_dir(fixture.root.path().join(STORE))
.unwrap()
.all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with("lease-")));
let mut cache: VerifiedSnapshot = read(&path).unwrap().unwrap();
cache.sha256 = "ff".repeat(32);
std::fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
assert!(
registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).is_err()
);
}
}
+419
View File
@@ -0,0 +1,419 @@
//! Shared admission budget for immutable paid-content snapshots.
//! Blocking worker only. Reservations are durable before copying and intentionally
//! survive process death; orphan cleanup needs operation-aware reconciliation.
use crate::media_registration::{self as io, Limits};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs::File,
os::unix::io::AsRawFd,
path::Path,
time::{Duration, Instant},
};
pub(crate) const DEFAULT_MAX_TOTAL_BYTES: u64 = 64 * 1024 * 1024 * 1024;
pub(crate) const DEFAULT_MIN_FREE_BYTES: u64 = 512 * 1024 * 1024;
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Record {
version: u8,
operation: String,
bytes: u64,
}
pub(crate) struct Reservation {
_claim: File,
root: File,
name: String,
}
fn count(directory: &File) -> Result<u64> {
let mut bytes = 0u64;
for item in std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()))? {
let item = item?;
let name = item.file_name();
let name = name.to_str().context("Invalid snapshot storage filename")?;
let metadata = std::fs::symlink_metadata(item.path())?;
anyhow::ensure!(
!metadata.file_type().is_symlink(),
"Unexpected snapshot symlink"
);
let size = if metadata.is_dir() {
count(&io::open_at(
directory,
name,
libc::O_RDONLY | libc::O_DIRECTORY,
0,
)?)?
} else {
anyhow::ensure!(metadata.is_file(), "Unexpected snapshot storage entry");
metadata.len()
};
bytes = bytes
.checked_add(size)
.context("Snapshot accounting overflow")?;
}
Ok(bytes)
}
fn reserved(root: &File) -> Result<u64> {
let mut total = 0u64;
for item in std::fs::read_dir(format!("/proc/self/fd/{}", root.as_raw_fd()))? {
let item = item?;
let name = item.file_name();
let name = name.to_str().context("Invalid reservation filename")?;
if name == "claims" {
continue;
}
anyhow::ensure!(
name.ends_with(".json"),
"Unknown reservation state; preserve for recovery"
);
let record: Record = io::read_record(root, name)?.context("Reservation disappeared")?;
anyhow::ensure!(
record.version == 1 && record.bytes > 0,
"Invalid reservation; preserve for recovery"
);
total = total
.checked_add(record.bytes)
.context("Reservation accounting overflow")?;
}
Ok(total)
}
/// Operation must be a durable journal identifier selected by the authenticated
/// caller. Do not release an orphan reservation solely because its client left.
pub(crate) fn reserve(
data_dir: &Path,
operation: &str,
bytes: u64,
maximum_total: u64,
minimum_free: u64,
limits: &Limits<'_>,
) -> Result<Reservation> {
reserve_until(
data_dir,
operation,
bytes,
maximum_total,
minimum_free,
limits,
Instant::now() + Duration::from_secs(30),
)
}
pub(crate) fn reserve_until(
data_dir: &Path,
operation: &str,
bytes: u64,
maximum_total: u64,
minimum_free: u64,
limits: &Limits<'_>,
deadline: Instant,
) -> Result<Reservation> {
anyhow::ensure!(
!operation.is_empty() && operation.len() <= 256 && bytes > 0 && bytes <= limits.max_bytes,
"Invalid snapshot admission request"
);
let data = io::open_directory(&data_dir.canonicalize()?)?;
let root = io::private_directory(&data, "snapshot-reservations")?;
let key = hex::encode(Sha256::digest(operation.as_bytes()));
let claims = io::private_directory(&root, "claims")?;
let claim = io::private_directory(&claims, &key)?;
// Wait for this operation without holding the shared admission lock. The
// claim survives as a harmless empty directory; its flock ends on process exit.
io::lock_operation(&claim, limits, deadline)?;
io::lock_operation(&root, limits, deadline)?;
let name = format!("{key}.json");
let existing: Option<Record> = io::read_record(&root, &name)?;
let additional = bytes
.checked_add(128 * 1024)
.context("Reservation overflow")?;
if let Some(saved) = &existing {
anyhow::ensure!(
saved.version == 1 && saved.operation == operation && saved.bytes == additional,
"Original snapshot reservation terms changed; preserve for recovery"
);
}
let newly_reserved = if existing.is_some() { 0 } else { additional };
let mut stored = 0u64;
for name in ["content-snapshots", "media-registration"] {
match io::open_at(&data, name, libc::O_RDONLY | libc::O_DIRECTORY, 0) {
Ok(directory) => {
stored = stored
.checked_add(count(&directory)?)
.context("Storage accounting overflow")?
}
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
{
()
}
Err(error) => return Err(error),
}
}
let outstanding = reserved(&root)?;
anyhow::ensure!(
stored
.checked_add(outstanding)
.and_then(|v| v.checked_add(newly_reserved))
.is_some_and(|total| total <= maximum_total),
"Immutable media storage budget is full"
);
let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
anyhow::ensure!(
unsafe { libc::fstatvfs(data.as_raw_fd(), stat.as_mut_ptr()) } == 0,
"Cannot inspect snapshot storage capacity"
);
let stat = unsafe { stat.assume_init() };
let free = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64);
let required = outstanding
.checked_add(newly_reserved)
.and_then(|v| v.checked_add(minimum_free))
.context("Snapshot free-space requirement overflow")?;
anyhow::ensure!(
free >= required,
"Not enough free storage for immutable media"
);
if existing.is_none() {
io::save_record(
&root,
&name,
&Record {
version: 1,
operation: operation.into(),
bytes: additional,
},
)?;
}
// flock is on this open description; release before expensive media copying.
anyhow::ensure!(
unsafe { libc::flock(root.as_raw_fd(), libc::LOCK_UN) } == 0,
"Cannot release admission lock"
);
Ok(Reservation {
_claim: claim,
root,
name,
})
}
/// Call only after the original operation's immutable bytes and durable record
/// have been verified. This permits recovery/cleanup even when current admission
/// capacity is exhausted; it authorizes no new copy and touches no media bytes.
pub(crate) fn finish_completed(
data_dir: &Path,
operation: &str,
bytes: u64,
limits: &Limits<'_>,
) -> Result<()> {
let data = io::open_directory(&data_dir.canonicalize()?)?;
let root = match io::open_at(
&data,
"snapshot-reservations",
libc::O_RDONLY | libc::O_DIRECTORY,
0,
) {
Ok(root) => root,
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
{
return Ok(())
}
Err(error) => return Err(error),
};
let key = hex::encode(Sha256::digest(operation.as_bytes()));
let claims = io::private_directory(&root, "claims")?;
let claim = io::private_directory(&claims, &key)?;
io::lock_operation(&claim, limits, Instant::now() + Duration::from_secs(30))?;
io::lock_operation(&root, limits, Instant::now() + Duration::from_secs(30))?;
let name = format!("{key}.json");
if let Some(record) = io::read_record::<Record>(&root, &name)? {
anyhow::ensure!(
record.version == 1
&& record.operation == operation
&& bytes.checked_add(128 * 1024) == Some(record.bytes),
"Completed snapshot reservation terms changed; preserve for recovery"
);
let name = std::ffi::CString::new(name)?;
anyhow::ensure!(
unsafe { libc::unlinkat(root.as_raw_fd(), name.as_ptr(), 0) } == 0,
"Cannot release completed snapshot reservation"
);
root.sync_all()?;
}
Ok(())
}
impl Reservation {
/// After success OR a stopped copy, retained/partial files count against the
/// stored-byte budget. Caller must stop writing before returning reservation.
pub(crate) fn finish(self, limits: &Limits<'_>) -> Result<()> {
io::lock_operation(&self.root, limits, Instant::now() + Duration::from_secs(30))?;
let name = std::ffi::CString::new(self.name)?;
anyhow::ensure!(
unsafe { libc::unlinkat(self.root.as_raw_fd(), name.as_ptr(), 0) } == 0,
"Cannot release snapshot reservation; preserve operation for recovery"
);
self.root.sync_all()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicBool;
#[test]
fn reservations_share_both_storage_roots_and_do_not_hold_copy_lock() {
let temp = tempfile::tempdir().unwrap();
let cancelled = AtomicBool::new(false);
let limits = Limits {
max_bytes: 8 * 1024 * 1024,
cancelled: &cancelled,
};
for name in ["content-snapshots", "media-registration"] {
std::fs::create_dir(temp.path().join(name)).unwrap();
File::create(temp.path().join(name).join("retained-media"))
.unwrap()
.set_len(1024 * 1024)
.unwrap();
}
// Two stores already occupy 2MiB. Both guards coexist, proving the
// admission lock does not serialize the subsequent expensive copies.
let first = reserve(
temp.path(),
"first",
1024 * 1024,
5 * 1024 * 1024,
0,
&limits,
)
.unwrap();
let second = reserve(
temp.path(),
"second",
1024 * 1024,
5 * 1024 * 1024,
0,
&limits,
)
.unwrap();
assert!(reserve(
temp.path(),
"third",
1024 * 1024,
5 * 1024 * 1024,
0,
&limits
)
.is_err());
first.finish(&limits).unwrap();
let third = reserve(
temp.path(),
"third",
1024 * 1024,
5 * 1024 * 1024,
0,
&limits,
)
.unwrap();
second.finish(&limits).unwrap();
third.finish(&limits).unwrap();
}
#[test]
fn interrupted_reservation_stays_counted_and_symlink_storage_rejects() {
let temp = tempfile::tempdir().unwrap();
let cancelled = AtomicBool::new(false);
let limits = Limits {
max_bytes: 8 * 1024 * 1024,
cancelled: &cancelled,
};
let held = reserve(
temp.path(),
"interrupted",
2 * 1024 * 1024,
3 * 1024 * 1024,
0,
&limits,
)
.unwrap();
drop(held); // process death does not erase a durable outstanding liability
let resumed = reserve(
temp.path(),
"interrupted",
2 * 1024 * 1024,
3 * 1024 * 1024,
0,
&limits,
)
.unwrap();
drop(resumed);
assert!(reserve(
temp.path(),
"interrupted",
1024 * 1024,
3 * 1024 * 1024,
0,
&limits
)
.is_err());
assert!(reserve(
temp.path(),
"other",
1024 * 1024,
3 * 1024 * 1024,
0,
&limits
)
.is_err());
std::os::unix::fs::symlink(temp.path(), temp.path().join("media-registration")).unwrap();
assert!(reserve(temp.path(), "symlink", 1, 16 * 1024 * 1024, 0, &limits).is_err());
}
#[test]
fn completed_cleanup_works_without_new_admission_and_checks_original_size() {
let temp = tempfile::tempdir().unwrap();
let cancelled = AtomicBool::new(false);
let limits = Limits {
max_bytes: 8 * 1024 * 1024,
cancelled: &cancelled,
};
let original = reserve(
temp.path(),
"complete",
1024 * 1024,
2 * 1024 * 1024,
0,
&limits,
)
.unwrap();
drop(original);
assert!(finish_completed(temp.path(), "complete", 2 * 1024 * 1024, &limits).is_err());
finish_completed(temp.path(), "complete", 1024 * 1024, &limits).unwrap();
finish_completed(temp.path(), "complete", 1024 * 1024, &limits).unwrap();
let root = io::open_directory(&temp.path().join("snapshot-reservations")).unwrap();
assert_eq!(reserved(&root).unwrap(), 0);
}
#[test]
fn expired_admission_deadline_never_records_a_new_reservation() {
let temp = tempfile::tempdir().unwrap();
let cancelled = AtomicBool::new(false);
let limits = Limits {
max_bytes: 1024,
cancelled: &cancelled,
};
assert!(reserve_until(
temp.path(),
"expired",
10,
1024 * 1024,
0,
&limits,
Instant::now()
)
.is_err());
let root = io::open_directory(&temp.path().join("snapshot-reservations")).unwrap();
assert_eq!(reserved(&root).unwrap(), 0);
}
}
+64
View File
@@ -290,6 +290,10 @@ pub async fn load_network(data_dir: &Path) -> Result<EcashNetwork> {
/// disk untouched, so switching is reversible and loses nothing.
pub async fn save_network(data_dir: &Path, network: EcashNetwork) -> Result<()> {
let _mutation = super::mutation::guard(data_dir).await?;
crate::content_purchase::Journal::open(data_dir)
.await?
.ensure_seller_policy_change(network, None)
.await?;
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
@@ -443,6 +447,10 @@ pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
/// Save accepted mints list.
pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Result<()> {
let _mutation = super::mutation::guard(data_dir).await?;
crate::content_purchase::Journal::open(data_dir)
.await?
.ensure_seller_policy_change(load_network(data_dir).await?, Some(&mints.mints))
.await?;
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
@@ -823,6 +831,7 @@ pub async fn send_token_recoverable(
context_hash,
None,
|| chrono::Utc::now().timestamp(),
None,
)
.await
}
@@ -849,6 +858,7 @@ pub async fn send_token_recoverable_before(
context_hash,
Some(expires_at),
|| chrono::Utc::now().timestamp(),
None,
)
.await
}
@@ -868,6 +878,7 @@ async fn send_token_recoverable_with_deadline(
context_hash: &str,
expires_at: Option<i64>,
now: impl Fn() -> i64 + Send + Sync,
plan: Option<&super::purchase_fee_plan::FeePlan>,
) -> Result<String> {
use super::send_journal::{Binding, Journal, Outcome, Phase, Request};
let held = super::mutation::guard(data_dir).await?;
@@ -884,6 +895,17 @@ async fn send_token_recoverable_with_deadline(
};
let journal = Journal::new(&held);
let previous = journal.load(operation_id).await?;
if let Some(plan) = plan {
plan.validate()?;
anyhow::ensure!(
previous.is_some(),
"Original planned inputs are missing; no new proof selection allowed"
);
anyhow::ensure!(
plan.mint_url == mint_url && plan.gross_sats == amount_sats,
"Planned amount or mint changed"
);
}
let recovering = previous.is_some();
let record = if let Some(record) = previous {
anyhow::ensure!(
@@ -927,12 +949,26 @@ async fn send_token_recoverable_with_deadline(
ensure_fresh_payment_allowed(expires_at, now())?;
journal.prepare(binding.clone(), request).await?
};
anyhow::ensure!(
!matches!(record.phase, Phase::Cancelled),
"Payment operation was cancelled"
);
if matches!(record.phase, Phase::Result(_) | Phase::Committed(_)) {
return journal.commit_wallet(&binding).await;
}
// An exact Prepared record has never exposed a token: result durability
// precedes both wallet commit and return. Do not reserve fresh inputs merely
// to reject an already-expired accepted plan.
if matches!(&record.request, Request::Exact { .. }) {
ensure_fresh_payment_allowed(expires_at, now())?;
}
journal.reserve_wallet(&binding).await?;
let (send, change) = match &record.request {
Request::Exact { proofs } => {
if let Some(plan) = plan {
plan.validate_proofs(proofs)?;
plan.verify_mint_keysets(&MintClient::new(mint_url)?.get_keysets().await?, false)?;
}
ensure_fresh_payment_allowed(expires_at, now())?;
(proofs.clone(), vec![])
}
@@ -961,7 +997,11 @@ async fn send_token_recoverable_with_deadline(
"This payment is still pending at the mint; do not pay again"
);
}
if let Some(plan) = plan {
plan.verify_mint_keysets(&client.get_keysets().await?, true)?;
}
ensure_fresh_payment_allowed(expires_at, now())?;
journal.mark_dispatched(&binding).await?;
client.execute_prepared_swap(prepared).await.map_err(|_| anyhow::anyhow!(
"The mint did not confirm this payment; retry this same operation to recover it"))?
};
@@ -985,6 +1025,9 @@ async fn send_token_recoverable_with_deadline(
};
let token = CashuToken::new(&binding.mint_url, send);
let encoded = token.serialize_v4().or_else(|_| token.serialize())?;
if let Some(plan) = plan {
plan.validate_token(&encoded)?;
}
journal
.record_result(
&binding,
@@ -997,6 +1040,27 @@ async fn send_token_recoverable_with_deadline(
journal.commit_wallet(&binding).await
}
/// Executes only a previously pinned private wallet plan. A missing send record
/// rejects instead of silently selecting another set of inputs.
pub(crate) async fn send_token_preplanned_before(
data_dir: &Path,
contract: &crate::content_purchase::Contract,
plan: &super::purchase_fee_plan::FeePlan,
) -> Result<String> {
send_token_recoverable_with_deadline(
data_dir,
&contract.id,
contract.network,
&contract.mint_url,
contract.gross_token_sats,
&contract.context_hash()?,
Some(contract.expires_at),
|| chrono::Utc::now().timestamp(),
Some(plan),
)
.await
}
async fn send_token_at_locked(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = mint_url.to_string();
@@ -83,6 +83,14 @@ impl std::fmt::Debug for PreparedSwap {
}
impl PreparedSwap {
// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed.
pub(super) fn payment_keyset_id(&self) -> &str { &self.keyset.id }
pub(super) fn input_fee_sats(&self) -> Result<u64> {
let inputs = self.inputs.iter().try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)).context("Prepared input sum overflow")?;
let outputs = self.outputs.iter().try_fold(0u64, |sum, output| sum.checked_add(output.amount)).context("Prepared output sum overflow")?;
inputs.checked_sub(outputs).context("Prepared outputs exceed input value")
}
pub(super) fn inputs(&self) -> &[Proof] {
&self.inputs
}
+3 -1
View File
@@ -8,10 +8,12 @@ pub mod ecash;
pub mod fedimint_client;
pub mod minibits;
pub mod mint_client;
mod mutation;
pub(crate) mod mutation;
pub mod nut13;
pub mod profits;
mod send_journal;
mod receive_journal;
pub(crate) mod purchase_fee_plan;
pub(crate) mod purchase_plan;
+2 -2
View File
@@ -30,12 +30,12 @@ async fn canonical_lock(data_dir: &Path) -> Result<(PathBuf, Arc<Mutex<()>>)> {
Ok((canonical, lock))
}
pub(super) struct WalletMutation {
pub(crate) struct WalletMutation {
pub(super) data_dir: PathBuf,
_held: OwnedMutexGuard<()>,
}
pub(super) async fn guard(data_dir: &Path) -> Result<WalletMutation> {
pub(crate) async fn guard(data_dir: &Path) -> Result<WalletMutation> {
let (data_dir, lock) = canonical_lock(data_dir).await?;
Ok(WalletMutation {
data_dir,
@@ -1849,6 +1849,7 @@ async fn purchase_expiring_during_mint_preflight_never_reserves_or_posts_swap()
&"ab".repeat(32),
Some(2000),
|| clock.load(Ordering::SeqCst),
None,
)
.await
.unwrap_err();
@@ -1933,3 +1934,785 @@ async fn stale_planned_inputs_do_not_reselect_wallet_coins_or_post_to_mint() {
);
assert!(mint.requests.lock().unwrap().is_empty());
}
// Add within wallet payment_tests, using its existing fake proof fixtures.
#[tokio::test]
async fn expired_saved_exact_plan_never_reserves_spendable_inputs() {
let root = tempfile::tempdir().unwrap();
let mint = "https://unused-mint.invalid";
let mut wallet = WalletState::default();
wallet.mint_url = mint.into();
wallet.add_proofs(mint, vec![proof(ACTIVE, 8)]);
save_wallet(root.path(), &wallet).await.unwrap();
let original = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
let id = uuid::Uuid::new_v4().to_string();
let context = "ab".repeat(32);
{
let guard = crate::wallet::mutation::guard(root.path()).await.unwrap();
let binding = crate::wallet::send_journal::Binding {
id: id.clone(),
network: EcashNetwork::Mainnet,
mint_url: mint.into(),
amount_sats: 8,
context_hash: context.clone(),
};
crate::wallet::send_journal::Journal::new(&guard)
.prepare(
binding,
crate::wallet::send_journal::Request::Exact {
proofs: vec![proof(ACTIVE, 8)],
},
)
.await
.unwrap();
}
assert!(send_token_recoverable_before(
root.path(),
&id,
EcashNetwork::Mainnet,
mint,
8,
&context,
chrono::Utc::now().timestamp() - 1
)
.await
.is_err());
assert_eq!(
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
original
);
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
}
// Append to wallet/payment_tests.rs after integrating dispatch/cancellation APIs.
#[tokio::test]
async fn cancelled_exact_plan_cannot_later_send_and_releases_only_its_reservation() {
use crate::wallet::{
mutation,
send_journal::{Binding, Journal, Request},
};
let root = tempfile::tempdir().unwrap();
let mint = "https://unused-mint.invalid";
let mut wallet = WalletState::default();
wallet.mint_url = mint.into();
wallet.add_proofs(mint, vec![proof(ACTIVE, 8), proof(ACTIVE, 4)]);
save_wallet(root.path(), &wallet).await.unwrap();
let binding = Binding {
id: uuid::Uuid::new_v4().to_string(),
network: EcashNetwork::Mainnet,
mint_url: mint.into(),
amount_sats: 8,
context_hash: "ab".repeat(32),
};
{
let guard = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&guard);
journal
.prepare(
binding.clone(),
Request::Exact {
proofs: vec![proof(ACTIVE, 8)],
},
)
.await
.unwrap();
journal.reserve_wallet(&binding).await.unwrap();
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4);
journal.cancel_unspent(&binding).await.unwrap();
journal.cancel_unspent(&binding).await.unwrap();
}
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12);
assert!(send_token_recoverable(
root.path(),
&binding.id,
binding.network,
mint,
8,
&binding.context_hash
)
.await
.is_err());
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12);
assert!(load_wallet(root.path())
.await
.unwrap()
.transactions
.is_empty());
}
#[tokio::test]
async fn dispatched_or_legacy_unknown_swap_cannot_cancel_despite_unspent_mint_inputs() {
use crate::wallet::{
mutation,
send_journal::{Binding, Journal, Request},
};
use sha2::{Digest, Sha256};
for legacy in [false, true] {
let mint = Mint::start(0, None).await;
let root = tempfile::tempdir().unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
save_wallet(root.path(), &wallet).await.unwrap();
let binding = Binding {
id: uuid::Uuid::new_v4().to_string(),
network: EcashNetwork::Mainnet,
mint_url: mint.url.clone(),
amount_sats: 4,
context_hash: "ab".repeat(32),
};
let prepared = MintClient::new(&mint.url)
.unwrap()
.prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4)
.await
.unwrap();
{
let guard = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&guard);
journal
.prepare(binding.clone(), Request::Swap(prepared))
.await
.unwrap();
journal.reserve_wallet(&binding).await.unwrap();
if !legacy {
journal.mark_dispatched(&binding).await.unwrap();
}
}
if legacy {
let path = root
.path()
.join("wallet/send-operations")
.join(format!("{}.json", binding.id));
let mut envelope: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let mut payload: serde_json::Value =
serde_json::from_str(envelope["payload"].as_str().unwrap()).unwrap();
payload.as_object_mut().unwrap().remove("dispatch");
let payload = serde_json::to_string(&payload).unwrap();
envelope["checksum"] = json!(hex::encode(Sha256::digest(payload.as_bytes())));
envelope["payload"] = json!(payload);
std::fs::write(path, serde_json::to_vec(&envelope).unwrap()).unwrap();
}
let guard = mutation::guard(root.path()).await.unwrap();
assert!(Journal::new(&guard).cancel_unspent(&binding).await.is_err());
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
assert!(mint.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn planner_rejects_wrong_network_before_creating_intent_or_reservation() {
let root = tempfile::tempdir().unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = "http://127.0.0.1:1".into();
wallet.add_proofs("http://127.0.0.1:1", vec![proof(ACTIVE, 8)]);
save_wallet(root.path(), &wallet).await.unwrap();
let original = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
let error = crate::wallet::purchase_plan::prepare(
root.path(),
"http://127.0.0.1:1",
EcashNetwork::Testnet,
4,
8,
)
.await
.err()
.unwrap();
assert!(error.to_string().contains("another wallet network"));
assert_eq!(
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
original
);
assert!(!root.path().join("content-purchases").exists());
assert!(!root.path().join("wallet/send-operations").exists());
}
#[tokio::test]
async fn planner_skips_unfundable_swaps_and_finds_later_exact_shape_within_budget() {
let mint = Mint::start(2000, None).await;
let root = tempfile::tempdir().unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8), proof(ACTIVE, 4)]);
save_wallet(root.path(), &wallet).await.unwrap();
let plan =
crate::wallet::purchase_plan::prepare(root.path(), &mint.url, EcashNetwork::Mainnet, 7, 12)
.await
.unwrap();
assert_eq!(plan.fee_plan.gross_sats, 12);
assert_eq!(plan.fee_plan.fee_sats, 4);
assert_eq!(plan.fee_plan.net_sats, 8);
assert_eq!(plan.wallet_debit_sats, 12);
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12);
assert!(mint.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn cancellation_seal_wins_against_an_already_waiting_fresh_swap_executor() {
use crate::wallet::{
mutation,
send_journal::{Binding, Journal, Request},
};
let mint = Mint::start(0, None).await;
let root = tempfile::tempdir().unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
save_wallet(root.path(), &wallet).await.unwrap();
let binding = Binding {
id: uuid::Uuid::new_v4().to_string(),
network: EcashNetwork::Mainnet,
mint_url: mint.url.clone(),
amount_sats: 4,
context_hash: "ab".repeat(32),
};
let prepared = MintClient::new(&mint.url)
.unwrap()
.prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4)
.await
.unwrap();
let guard = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&guard);
journal
.prepare(binding.clone(), Request::Swap(prepared))
.await
.unwrap();
journal.reserve_wallet(&binding).await.unwrap();
let waiting = send_token_recoverable(
root.path(),
&binding.id,
binding.network,
&binding.mint_url,
binding.amount_sats,
&binding.context_hash,
);
tokio::pin!(waiting);
assert!(futures_util::poll!(&mut waiting).is_pending());
journal.cancel_unspent(&binding).await.unwrap();
drop(journal);
drop(guard);
assert!(waiting.await.is_err());
assert!(mint.requests.lock().unwrap().is_empty());
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
assert!(load_wallet(root.path())
.await
.unwrap()
.transactions
.is_empty());
}
// Append under wallet/payment_tests.rs: real local journals + fake mint, no real funds.
struct PurchaseTestTransport {
seller_root: std::path::PathBuf,
template: crate::content_purchase_protocol::Offer,
lose_offer: std::sync::atomic::AtomicBool,
lose_accept: std::sync::atomic::AtomicBool,
lose_settle: std::sync::atomic::AtomicBool,
offers: std::sync::Mutex<Vec<String>>,
}
impl crate::content_purchase_caller::PurchaseTransport for PurchaseTestTransport {
fn seller_did(&self) -> &str {
&self.template.seller_did
}
fn seller_onion(&self) -> &str {
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion"
}
async fn offer(
&self,
id: &str,
content_id: &str,
) -> anyhow::Result<crate::content_purchase_protocol::Offer> {
self.offers.lock().unwrap().push(id.into());
let mut offer = self.template.clone();
offer.id = id.into();
offer.content_id = content_id.into();
let saved = crate::content_purchase_protocol::save_offer(
&self.seller_root,
&offer,
&offer.buyer_did,
chrono::Utc::now().timestamp(),
)
.await?;
anyhow::ensure!(
!self
.lose_offer
.swap(false, std::sync::atomic::Ordering::SeqCst),
"Lost offer response"
);
Ok(saved)
}
async fn accept(
&self,
envelope: &crate::content_purchase_protocol::Envelope,
) -> anyhow::Result<crate::content_purchase_protocol::Accepted> {
let reply = crate::content_purchase_protocol::accept(
&self.seller_root,
envelope,
&envelope.offer.buyer_did,
|| chrono::Utc::now().timestamp(),
)
.await?;
anyhow::ensure!(
!self
.lose_accept
.swap(false, std::sync::atomic::Ordering::SeqCst),
"Lost acceptance response"
);
Ok(reply)
}
async fn status(
&self,
envelope: &crate::content_purchase_protocol::Envelope,
) -> anyhow::Result<crate::content_purchase_protocol::SellerStatus> {
crate::content_purchase_protocol::status(
&self.seller_root,
envelope,
&envelope.offer.buyer_did,
)
.await
}
async fn cancel(
&self,
envelope: &crate::content_purchase_protocol::Envelope,
) -> anyhow::Result<crate::content_purchase_protocol::Cancelled> {
crate::content_purchase_protocol::cancel(
&self.seller_root,
envelope,
&envelope.offer.buyer_did,
)
.await
}
async fn settle(
&self,
request: &crate::content_purchase_protocol::Settlement,
) -> anyhow::Result<crate::content_purchase::Receipt> {
let reply = crate::content_purchase_protocol::settle(
&self.seller_root,
request,
&request.envelope.offer.buyer_did,
)
.await?;
anyhow::ensure!(
!self
.lose_settle
.swap(false, std::sync::atomic::Ordering::SeqCst),
"Invalid purchase response after settlement"
);
Ok(reply)
}
}
#[tokio::test]
async fn full_caller_recovers_lost_acceptance_and_settlement_without_another_payment() {
use crate::content_purchase_caller::{purchase, PurchaseConsent, ReadyPurchase};
use std::sync::atomic::{AtomicBool, Ordering};
let mint = Mint::start(0, None).await;
let buyer = tempfile::tempdir().unwrap();
let seller = mint.wallet().await;
let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap();
let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
save_wallet(buyer.path(), &wallet).await.unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
save_wallet(seller.path(), &wallet).await.unwrap();
let now = chrono::Utc::now().timestamp();
let transport = PurchaseTestTransport {
seller_root: seller.path().into(),
template: crate::content_purchase_protocol::Offer {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: buyer_did.clone(),
seller_did,
content_id: "paid-film".into(),
filename: "film.mp4".into(),
mime_type: "video/mp4".into(),
content_sha256: "ab".repeat(32),
content_size: 16,
viewing_seconds: None,
terms_sha256: "cd".repeat(32),
network: EcashNetwork::Mainnet,
mint_url: mint.url.clone(),
seller_net_sats: 8,
offered_at: now,
expires_at: now + 300,
},
lose_offer: AtomicBool::new(true),
lose_accept: AtomicBool::new(true),
lose_settle: AtomicBool::new(true),
offers: Default::default(),
};
assert!(purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport
)
.await
.is_err());
assert!(mint.requests.lock().unwrap().is_empty());
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
let quote = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport,
)
.await
.unwrap();
let consent = match quote {
ReadyPurchase::AwaitingConfirmation {
operation_id,
envelope_sha256,
wallet_debit_sats,
..
} => PurchaseConsent {
operation_id,
envelope_sha256,
wallet_debit_sats,
},
_ => panic!("Fresh purchase spent before confirmation"),
};
let reopened = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
9_007_199_254_740_991,
None,
&transport,
)
.await
.unwrap();
match reopened {
ReadyPurchase::AwaitingConfirmation {
operation_id,
wallet_debit_sats,
..
} => {
assert_eq!(operation_id, consent.operation_id);
assert_eq!(wallet_debit_sats, consent.wallet_debit_sats);
}
_ => panic!("A broad quote budget initiated spending without consent"),
}
assert!(mint.requests.lock().unwrap().is_empty());
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
// A quote alone does not pin seller policy. Removing acceptance must stop
// the buyer before any token is exposed; the same quote can resume later.
save_accepted_mints(seller.path(), &AcceptedMints { mints: vec![] })
.await
.unwrap();
let rejected = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
Some(&consent),
&transport,
)
.await
.err()
.unwrap();
assert!(rejected
.to_string()
.contains("does not accept the quoted mint"));
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
assert!(mint.requests.lock().unwrap().is_empty());
save_accepted_mints(
seller.path(),
&AcceptedMints {
mints: vec![mint.url.clone()],
},
)
.await
.unwrap();
let error = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
Some(&consent),
&transport,
)
.await
.err()
.expect("The simulated lost response must surface");
assert!(
error.to_string().contains("Lost acceptance response"),
"unexpected purchase failure: {error:#}"
);
assert!(mint.requests.lock().unwrap().is_empty());
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
// Durable acceptance now pins redemption policy until cancellation or
// settlement, including when the acceptance reply was lost.
assert!(
save_accepted_mints(seller.path(), &AcceptedMints { mints: vec![] })
.await
.is_err()
);
assert!(save_network(seller.path(), EcashNetwork::Testnet)
.await
.is_err());
assert_eq!(
load_network(seller.path()).await.unwrap(),
EcashNetwork::Mainnet
);
let error = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
Some(&consent),
&transport,
)
.await
.err()
.expect("The simulated lost response must surface");
assert!(
error
.to_string()
.contains("Invalid purchase response after settlement"),
"unexpected purchase failure: {error:#}"
);
assert_eq!(mint.requests.lock().unwrap().len(), 1);
let recovered = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport,
)
.await
.unwrap();
let original = match recovered {
ReadyPurchase::Entitlement { contract, receipt } => {
assert_eq!(contract.id, consent.operation_id);
receipt
}
_ => panic!("Original entitlement was not recovered"),
};
let again = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport,
)
.await
.unwrap();
match again {
ReadyPurchase::Entitlement { receipt, .. } => assert!(receipt == original),
_ => panic!("Receipt replay changed"),
}
assert_eq!(transport.offers.lock().unwrap().len(), 2);
assert_ne!(transport.offers.lock().unwrap()[0], consent.operation_id);
assert_eq!(transport.offers.lock().unwrap()[1], consent.operation_id);
assert_eq!(mint.requests.lock().unwrap().len(), 1);
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0);
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 8);
assert_eq!(
load_wallet(buyer.path()).await.unwrap().transactions.len(),
1
);
assert_eq!(
load_wallet(seller.path()).await.unwrap().transactions.len(),
1
);
assert!(!transport.lose_accept.load(Ordering::SeqCst));
}
#[tokio::test]
async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() {
use crate::content_purchase_caller::{purchase_bound, ExpectedRental};
use std::sync::atomic::AtomicBool;
let buyer = tempfile::tempdir().unwrap();
let seller = tempfile::tempdir().unwrap();
save_accepted_mints(
seller.path(),
&AcceptedMints {
mints: vec!["http://127.0.0.1:1".into()],
},
)
.await
.unwrap();
let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap();
let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap();
let now = chrono::Utc::now().timestamp();
let transport = PurchaseTestTransport {
seller_root: seller.path().into(),
template: crate::content_purchase_protocol::Offer {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: buyer_did.clone(),
seller_did: seller_did.clone(),
content_id: "registered_film".into(),
filename: "film.mp4".into(),
mime_type: "video/mp4".into(),
content_sha256: "ab".repeat(32),
content_size: 16,
viewing_seconds: Some(60),
terms_sha256: "cd".repeat(32),
network: EcashNetwork::Mainnet,
mint_url: "http://127.0.0.1:1".into(),
seller_net_sats: 8,
offered_at: now,
expires_at: now + 300,
},
lose_offer: AtomicBool::new(false),
lose_accept: AtomicBool::new(false),
lose_settle: AtomicBool::new(false),
offers: Default::default(),
};
let expected = ExpectedRental {
seller_did,
content_id: "registered_film".into(),
sha256: "ab".repeat(32),
price_sats: 8,
viewing_seconds: 60,
};
let mut changed_hash = expected.clone();
changed_hash.sha256 = "ef".repeat(32);
let mut changed_price = expected.clone();
changed_price.price_sats = 9;
let mut changed_duration = expected.clone();
changed_duration.viewing_seconds = 120;
for wrong in [changed_hash, changed_price, changed_duration] {
let error = purchase_bound(
buyer.path(),
&buyer_did,
"registered_film",
None,
20,
None,
&transport,
Some(&wrong),
)
.await
.err()
.unwrap();
assert!(error.to_string().contains("Published rental"), "{error:#}");
assert!(!buyer.path().join("wallet/ecash.json").exists());
assert!(!buyer.path().join("wallet/send-operations").exists());
assert!(crate::content_purchase::Journal::open(buyer.path())
.await
.unwrap()
.find_buyers(&buyer_did, &expected.seller_did, "registered_film")
.await
.unwrap()
.is_empty());
}
}
#[tokio::test]
async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() {
use crate::content_purchase_caller::{purchase, ReadyPurchase};
use std::sync::atomic::{AtomicBool, Ordering};
let mint = Mint::start(0, None).await;
let buyer = tempfile::tempdir().unwrap();
let seller = mint.wallet().await;
let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap();
let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
save_wallet(buyer.path(), &wallet).await.unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
save_wallet(seller.path(), &wallet).await.unwrap();
let now = chrono::Utc::now().timestamp();
let transport = PurchaseTestTransport {
seller_root: seller.path().into(),
template: crate::content_purchase_protocol::Offer {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: buyer_did.clone(),
seller_did,
content_id: "paid-film".into(),
filename: "film.mp4".into(),
mime_type: "video/mp4".into(),
content_sha256: "ab".repeat(32),
content_size: 16,
viewing_seconds: None,
terms_sha256: "cd".repeat(32),
network: EcashNetwork::Mainnet,
mint_url: mint.url.clone(),
seller_net_sats: 8,
offered_at: now,
expires_at: now + 300,
},
lose_offer: AtomicBool::new(false),
lose_accept: AtomicBool::new(false),
lose_settle: AtomicBool::new(false),
offers: Default::default(),
};
let quote = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport,
)
.await
.unwrap();
let id = match quote {
ReadyPurchase::AwaitingConfirmation { operation_id, .. } => operation_id,
_ => panic!("Quote spent funds"),
};
assert!(!buyer
.path()
.join("wallet/send-operations")
.join(format!("{id}.json"))
.exists());
let (envelope, plan) = {
let journal = crate::content_purchase::Journal::open(buyer.path())
.await
.unwrap();
(
journal
.protocol_envelope("buyer", &id)
.await
.unwrap()
.unwrap(),
journal.buyer_plan(&id).await.unwrap().unwrap(),
)
};
crate::content_purchase_caller::cancel_purchase(buyer.path(), &envelope, &plan, &transport)
.await
.unwrap();
crate::content_purchase_caller::cancel_purchase(buyer.path(), &envelope, &plan, &transport)
.await
.unwrap();
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
assert!(load_wallet(buyer.path())
.await
.unwrap()
.transactions
.is_empty());
assert!(mint.requests.lock().unwrap().is_empty());
let next = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport,
)
.await
.unwrap();
match next {
ReadyPurchase::AwaitingConfirmation { operation_id, .. } => assert_ne!(operation_id, id),
_ => panic!("Replacement quote spent funds"),
}
assert!(mint.requests.lock().unwrap().is_empty());
}
@@ -0,0 +1,264 @@
//! Local-only deterministic payment planning. No swap POST or reservation here.
//! Serialize this object only into the private buyer journal, never onto the wire.
use super::{
cashu::{KeysetInfo, Proof},
ecash,
mint_client::MintClient,
mutation,
purchase_fee_plan::{FeePlan, KeysetPlan},
send_journal::{Binding, Journal, Request},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::Path};
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct PreparedPayment {
pub fee_plan: FeePlan,
/// Includes any buyer funding-swap input fee, distinct from seller redemption.
pub wallet_debit_sats: u64,
request: Request,
}
fn exact_shape(proofs: &[Proof], keysets: &[KeysetInfo]) -> Result<Vec<KeysetPlan>> {
let mut groups: BTreeMap<String, KeysetPlan> = BTreeMap::new();
for proof in proofs {
let matches: Vec<_> = keysets
.iter()
.filter(|keyset| super::cashu::matches_stored_keyset_id(&proof.id, &keyset.id))
.collect();
anyhow::ensure!(matches.len() == 1, "Missing or ambiguous payment keyset");
let keyset = matches[0];
anyhow::ensure!(keyset.unit == "sat", "Payment keyset has another unit");
groups
.entry(keyset.id.to_ascii_lowercase())
.or_insert_with(|| KeysetPlan {
keyset_id: keyset.id.to_ascii_lowercase(),
denominations: vec![],
input_fee_ppk: keyset.input_fee_ppk,
})
.denominations
.push(proof.amount);
}
Ok(groups.into_values().collect())
}
/// Quote at most 1024 gross amounts. Failure is explicit; never silently increase
/// the user-approved debit limit or substitute another mint/network.
pub(crate) async fn prepare(
data_dir: &Path,
mint: &str,
expected_network: ecash::EcashNetwork,
seller_net_sats: u64,
max_wallet_debit: u64,
) -> Result<PreparedPayment> {
anyhow::ensure!(
seller_net_sats > 0 && max_wallet_debit >= seller_net_sats,
"Invalid payment budget"
);
let _held = mutation::guard(data_dir).await?;
anyhow::ensure!(
ecash::load_network(data_dir).await? == expected_network,
"Offer uses another wallet network; no intent was created"
);
let wallet = ecash::load_wallet(data_dir).await?;
anyhow::ensure!(
wallet.select_proofs(mint, seller_net_sats).is_some(),
"No spendable balance at the seller's mint; no intent was created"
);
let client =
MintClient::new(mint)?.with_recovery(super::nut13::RecoverySource::load(data_dir).await?);
let keysets = client.get_keysets().await?;
let active = client.get_active_sat_keyset().await?;
let active_fee: Vec<_> = keysets
.iter()
.filter(|keyset| keyset.id == active.id && keyset.active && keyset.unit == "sat")
.collect();
anyhow::ensure!(
active_fee.len() == 1,
"Active payment keyset is not uniquely bound"
);
for additional in 0..1024u64 {
let gross = seller_net_sats
.checked_add(additional)
.context("Payment amount overflow")?;
if gross > max_wallet_debit {
break;
}
let Some((indices, excess)) = wallet.select_proofs(mint, gross) else {
break;
};
let proofs: Vec<_> = indices
.iter()
.map(|&index| wallet.proofs[index].proof.clone())
.collect();
let input_shape = exact_shape(&proofs, &keysets)?;
let input_ppk = input_shape
.iter()
.try_fold(0u64, |total, group| {
total.checked_add(
group
.input_fee_ppk
.checked_mul(group.denominations.len() as u64)?,
)
})
.context("Funding fee overflow")?;
let quoted_funding_fee = input_ppk.checked_add(999).context("Funding fee overflow")? / 1000;
if excess > 0
&& (quoted_funding_fee > excess
|| gross
.checked_add(quoted_funding_fee)
.is_none_or(|debit| debit > max_wallet_debit))
{
continue;
}
let shape = if excess == 0 {
input_shape
} else {
vec![KeysetPlan {
keyset_id: active.id.to_ascii_lowercase(),
denominations: super::cashu::amount_to_denominations(gross),
input_fee_ppk: active_fee[0].input_fee_ppk,
}]
};
let ppk = shape
.iter()
.try_fold(0u64, |total, group| {
total.checked_add(
group
.input_fee_ppk
.checked_mul(group.denominations.len() as u64)?,
)
})
.context("Quoted fee overflow")?;
let fee = ppk.checked_add(999).context("Quoted fee overflow")? / 1000;
if gross <= fee || gross - fee < seller_net_sats {
continue;
}
let fee_plan = FeePlan::from_shape(mint, shape)?;
if fee_plan.net_sats < seller_net_sats {
continue;
}
let (request, funding_fee) = if excess == 0 {
(Request::Exact { proofs }, 0)
} else {
let mut amounts = super::cashu::amount_to_denominations(gross);
amounts.extend(super::cashu::amount_to_denominations(excess));
let prepared = client
.prepare_swap_at_least(&proofs, &amounts, gross)
.await?;
anyhow::ensure!(
prepared.payment_keyset_id() == active.id,
"Mint rotated while planning; refresh the offer"
);
let fee = prepared.input_fee_sats()?;
anyhow::ensure!(
fee == quoted_funding_fee,
"Mint funding fee changed while planning; refresh before acceptance"
);
anyhow::ensure!(
client.restore_prepared_swap(&prepared).await?.is_none(),
"Planned outputs already exist; recover the previous operation"
);
(Request::Swap(prepared), fee)
};
let debit = gross
.checked_add(funding_fee)
.context("Payment debit overflow")?;
anyhow::ensure!(
debit <= max_wallet_debit,
"Funding fee exceeds the approved debit limit"
);
return Ok(PreparedPayment {
fee_plan,
wallet_debit_sats: debit,
request,
});
}
anyhow::bail!("No bounded payment plan covers the seller price within the approved debit limit")
}
/// Must precede authenticated seller acceptance. This durably pins the exact
/// inputs/outputs without reserving or spending; stale inputs later reject.
pub(crate) async fn persist(
data_dir: &Path,
contract: &crate::content_purchase::Contract,
plan: &PreparedPayment,
) -> Result<()> {
contract.validate()?;
anyhow::ensure!(
plan.fee_plan.mint_url == contract.mint_url
&& plan.fee_plan.gross_sats == contract.gross_token_sats
&& plan.fee_plan.net_sats >= contract.minimum_net_sats,
"Wallet plan changed purchase amounts"
);
let held = mutation::guard(data_dir).await?;
anyhow::ensure!(
ecash::load_network(data_dir).await? == contract.network,
"Purchase wallet network changed"
);
let binding = Binding {
id: contract.id.clone(),
network: contract.network,
mint_url: contract.mint_url.clone(),
amount_sats: contract.gross_token_sats,
context_hash: contract.context_hash()?,
};
let journal = Journal::new(&held);
if let Some(existing) = journal.load(&contract.id).await? {
anyhow::ensure!(
existing.binding == binding
&& serde_json::to_value(&existing.request)? == serde_json::to_value(&plan.request)?,
"Original wallet preparation changed"
);
}
if journal.load(&contract.id).await?.is_none() {
let buyer = crate::content_purchase::Journal::open(data_dir)
.await?
.buyer(&contract.id)
.await?
.context("Buyer intent is missing")?;
anyhow::ensure!(buyer.phase == crate::content_purchase::BuyerPhase::Intent,
"Accepted operation lost its wallet journal; no new preparation or cancellation is allowed");
}
journal.prepare(binding, plan.request.clone()).await?;
Ok(())
}
/// Seal before contacting seller. Repeating after a lost seller reply is safe.
pub(crate) async fn cancel_unspent(
data_dir: &Path,
contract: &crate::content_purchase::Contract,
plan: &PreparedPayment,
) -> Result<()> {
persist(data_dir, contract, plan).await?;
let held = mutation::guard(data_dir).await?;
let binding = Binding {
id: contract.id.clone(),
network: contract.network,
mint_url: contract.mint_url.clone(),
amount_sats: contract.gross_token_sats,
context_hash: contract.context_hash()?,
};
Journal::new(&held).cancel_unspent(&binding).await
}
/// Publish the buyer intent while holding the wallet network mutation guard, so
/// a concurrent network switch cannot strand a newly published wrong-network row.
pub(crate) async fn persist_intent(
data_dir: &Path,
envelope: &crate::content_purchase_protocol::Envelope,
plan: &PreparedPayment,
) -> Result<()> {
let contract = envelope.contract()?;
anyhow::ensure!(plan.fee_plan == envelope.fee_plan, "Buyer fee plan changed");
let _held = mutation::guard(data_dir).await?;
anyhow::ensure!(
ecash::load_network(data_dir).await? == contract.network,
"Offer uses another wallet network; no intent was created"
);
let journal = crate::content_purchase::Journal::open(data_dir).await?;
journal.save_buyer_plan(&contract.id, plan).await?;
journal.save_protocol_envelope("buyer", envelope).await?;
journal
.prepare_buyer(&contract, chrono::Utc::now().timestamp())
.await?;
Ok(())
}
+72 -1
View File
@@ -450,16 +450,26 @@ pub(super) struct Outcome {
#[derive(Clone, Serialize, Deserialize)]
pub(super) enum Phase {
Prepared,
Cancelled,
Result(Outcome),
Committed(Outcome),
}
#[derive(Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(super) enum DispatchState {
#[default]
Unknown,
NotDispatched,
Started,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Record {
pub binding: Binding,
pub request: Request,
pub phase: Phase,
#[serde(default)]
pub dispatch: DispatchState,
}
impl std::fmt::Debug for Record {
@@ -528,6 +538,9 @@ impl<'a> Journal<'a> {
{
continue;
}
if matches!(record.phase, Phase::Cancelled) {
continue;
}
let Phase::Committed(outcome) = record.phase else {
anyhow::bail!(
"Recover pending payments before restoring this mint from the backup phrase"
@@ -634,6 +647,7 @@ impl<'a> Journal<'a> {
use super::ecash::TransactionType;
let record = self.bound_record(binding).await?;
let (outcome, committed) = match &record.phase {
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
Phase::Prepared => anyhow::bail!("Payment result is not durable yet"),
Phase::Result(outcome) => (outcome, false),
Phase::Committed(outcome) => (outcome, true),
@@ -867,7 +881,7 @@ impl<'a> Journal<'a> {
Self::validate_binding(&record.binding)?;
Self::validate_request(&record.binding, &record.request)?;
match &record.phase {
Phase::Prepared => (),
Phase::Prepared | Phase::Cancelled => (),
Phase::Result(outcome) | Phase::Committed(outcome) => {
Self::validate_outcome(&record, outcome)?
}
@@ -895,6 +909,7 @@ impl<'a> Journal<'a> {
binding,
request,
phase: Phase::Prepared,
dispatch: DispatchState::NotDispatched,
};
self.write(&record).await?;
Ok(record)
@@ -911,6 +926,7 @@ impl<'a> Journal<'a> {
);
Self::validate_outcome(&record, &outcome)?;
match &record.phase {
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
Phase::Prepared => record.phase = Phase::Result(outcome),
Phase::Result(previous) | Phase::Committed(previous) => {
anyhow::ensure!(
@@ -935,6 +951,7 @@ impl<'a> Journal<'a> {
"Payment operation terms changed"
);
record.phase = match record.phase {
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
Phase::Prepared => anyhow::bail!("Payment result is not durable yet"),
Phase::Result(outcome) | Phase::Committed(outcome) => Phase::Committed(outcome),
};
@@ -942,6 +959,60 @@ impl<'a> Journal<'a> {
Ok(record)
}
/// Must finish durably immediately before every fresh mint POST, under the
/// same wallet mutation guard as cancellation and input reservation.
pub async fn mark_dispatched(&self, binding: &Binding) -> Result<()> {
let mut record = self.bound_record(binding).await?;
anyhow::ensure!(
matches!(record.phase, Phase::Prepared),
"Payment cannot be dispatched in this phase"
);
record.dispatch = DispatchState::Started;
self.write(&record).await
}
/// A terminal local seal precedes release. No mint state query can prove an
/// ambiguous old POST will not finish later, so such swaps are never released.
pub async fn cancel_unspent(&self, binding: &Binding) -> Result<()> {
let mut record = self.bound_record(binding).await?;
if !matches!(record.phase, Phase::Cancelled) {
anyhow::ensure!(
matches!(record.phase, Phase::Prepared),
"A prepared token/result cannot be cancelled as unspent"
);
anyhow::ensure!(
matches!(record.request, Request::Exact { .. })
|| record.dispatch == DispatchState::NotDispatched,
"Mint dispatch is possible; recover original results instead of cancelling"
);
record.phase = Phase::Cancelled;
self.write(&record).await?;
}
let mut wallet = super::ecash::load_wallet(&self.guard.data_dir).await?;
let mut changed = false;
for stored in &mut wallet.proofs {
if stored.reserved_by.as_deref() != Some(binding.id.as_str()) {
continue;
}
anyhow::ensure!(
Self::inputs(&record.request)
.iter()
.any(|input| input.secret == stored.proof.secret
&& input.amount == stored.proof.amount
&& input.id == stored.proof.id
&& input.c == stored.proof.c),
"Cancellation reservation differs from original inputs"
);
anyhow::ensure!(!stored.spent, "Cancellation cannot restore spent inputs");
stored.reserved = false;
stored.reserved_by = None;
changed = true;
}
if changed {
super::ecash::save_wallet(&self.guard.data_dir, &wallet).await?;
}
Ok(())
}
async fn write(&self, record: &Record) -> Result<()> {
let payload = serde_json::to_string(record)?;
let checksum = hex::encode(Sha256::digest(payload.as_bytes()));