Reject changed rental metadata before background verification

This commit is contained in:
archipelago
2026-10-07 01:06:57 -04:00
parent cffb74326a
commit ba1de69fc5
4 changed files with 61 additions and 5 deletions
@@ -67,6 +67,7 @@ impl ApiHandler {
content_id: String, content_id: String,
#[serde(default)] #[serde(default)]
retry: bool, retry: bool,
expected: Option<crate::content_purchase_caller::ExpectedRental>,
} }
let input: Prepare = serde_json::from_slice(&bytes)?; let input: Prepare = serde_json::from_slice(&bytes)?;
let identity = std::sync::Arc::new( let identity = std::sync::Arc::new(
@@ -76,6 +77,14 @@ impl ApiHandler {
let root = data_dir.clone(); let root = data_dir.clone();
serde_json::to_value( serde_json::to_value(
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if let Some(expected) = &input.expected {
let (receipt, _) = crate::registered_media::registered_metadata(
&root,
&identity,
&input.content_id,
)?;
expected.verify_metadata(&identity.did_key()?, &receipt)?;
}
crate::registered_media::prepare_registered( crate::registered_media::prepare_registered(
root, root,
identity, identity,
@@ -15,6 +15,7 @@ pub(crate) trait PurchaseTransport: Send + Sync {
fn prepare_offer( fn prepare_offer(
&self, &self,
_content_id: &str, _content_id: &str,
_expected: Option<&ExpectedRental>,
) -> impl Future<Output = Result<Option<(u64, u64)>>> + Send { ) -> impl Future<Output = Result<Option<(u64, u64)>>> + Send {
async { Ok(None) } async { Ok(None) }
} }
@@ -86,7 +87,8 @@ pub(crate) async fn purchase(
.await .await
} }
#[derive(Clone)] #[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ExpectedRental { pub(crate) struct ExpectedRental {
pub seller_did: String, pub seller_did: String,
pub content_id: String, pub content_id: String,
@@ -95,6 +97,23 @@ pub(crate) struct ExpectedRental {
pub viewing_seconds: u64, pub viewing_seconds: u64,
} }
impl ExpectedRental { impl ExpectedRental {
pub(crate) fn verify_metadata(
&self,
seller_did: &str,
receipt: &crate::media_registration::Receipt,
) -> Result<()> {
anyhow::ensure!(
self.content_id.starts_with("registered_")
&& receipt.content_id == self.content_id
&& seller_did == self.seller_did
&& receipt.sha256 == self.sha256
&& receipt.price_sats == self.price_sats
&& receipt.viewing_seconds == self.viewing_seconds,
"Published rental hash, price, duration or seller changed; no payment started"
);
Ok(())
}
pub fn verify(&self, offer: &Offer) -> Result<()> { pub fn verify(&self, offer: &Offer) -> Result<()> {
anyhow::ensure!( anyhow::ensure!(
self.content_id.starts_with("registered_") self.content_id.starts_with("registered_")
@@ -222,7 +241,7 @@ pub(crate) async fn purchase_bound(
); );
if content_id.starts_with("registered_") { if content_id.starts_with("registered_") {
if let Some((completed_bytes, total_bytes)) = if let Some((completed_bytes, total_bytes)) =
transport.prepare_offer(content_id).await? transport.prepare_offer(content_id, expected).await?
{ {
return Ok(ReadyPurchase::Preparing { return Ok(ReadyPurchase::Preparing {
completed_bytes, completed_bytes,
@@ -79,11 +79,15 @@ impl PurchaseTransport for FipsPurchaseTransport {
fn seller_did(&self) -> &str { fn seller_did(&self) -> &str {
&self.seller_did &self.seller_did
} }
async fn prepare_offer(&self, content_id: &str) -> Result<Option<(u64, u64)>> { async fn prepare_offer(
&self,
content_id: &str,
expected: Option<&crate::content_purchase_caller::ExpectedRental>,
) -> Result<Option<(u64, u64)>> {
let state: crate::rental_readiness::Status = self let state: crate::rental_readiness::Status = self
.post( .post(
protocol::PREPARE_OFFER_ROUTE, protocol::PREPARE_OFFER_ROUTE,
&serde_json::json!({"content_id":content_id,"retry":self.retry_preparation}), &serde_json::json!({"content_id":content_id,"retry":self.retry_preparation,"expected":expected}),
) )
.await?; .await?;
match state { match state {
+25 -1
View File
@@ -2214,7 +2214,14 @@ impl crate::content_purchase_caller::PurchaseTransport for PurchaseTestTransport
fn seller_onion(&self) -> &str { fn seller_onion(&self) -> &str {
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion"
} }
async fn prepare_offer(&self, content_id: &str) -> anyhow::Result<Option<(u64, u64)>> { async fn prepare_offer(
&self,
content_id: &str,
expected: Option<&crate::content_purchase_caller::ExpectedRental>,
) -> anyhow::Result<Option<(u64, u64)>> {
if let Some(expected) = expected {
expected.verify(&self.template)?;
}
// A registered movie still being hashed, without asking the fake mint. // A registered movie still being hashed, without asking the fake mint.
Ok(content_id.starts_with("registered_").then_some((3, 16))) Ok(content_id.starts_with("registered_").then_some((3, 16)))
} }
@@ -2613,6 +2620,20 @@ async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() {
price_sats: 8, price_sats: 8,
viewing_seconds: 60, viewing_seconds: 60,
}; };
// Preparation checks already-verified signed metadata, without scanning bytes
// or allocating a quote. Keep this independent of the fake offer response.
let metadata: crate::media_registration::Receipt = serde_json::from_value(json!({
"version":1,"request_id":uuid::Uuid::new_v4().to_string(),"nonce":"fixture",
"app_audience":"indeedhub","node_did":expected.seller_did,
"producer":"fixture","project_id":"fixture","price_sats":8,
"viewing_seconds":60,"expires_at":now+300,"content_id":"registered_film",
"sha256":"ab".repeat(32),"size_bytes":"16","payment_methods":["cashu"],
"issued_at":now,"signature":"verified by registration boundary"
}))
.unwrap();
expected
.verify_metadata(&expected.seller_did, &metadata)
.unwrap();
let mut changed_hash = expected.clone(); let mut changed_hash = expected.clone();
changed_hash.sha256 = "ef".repeat(32); changed_hash.sha256 = "ef".repeat(32);
let mut changed_price = expected.clone(); let mut changed_price = expected.clone();
@@ -2620,6 +2641,9 @@ async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() {
let mut changed_duration = expected.clone(); let mut changed_duration = expected.clone();
changed_duration.viewing_seconds = 120; changed_duration.viewing_seconds = 120;
for wrong in [changed_hash, changed_price, changed_duration] { for wrong in [changed_hash, changed_price, changed_duration] {
assert!(wrong
.verify_metadata(&expected.seller_did, &metadata)
.is_err());
let error = purchase_bound( let error = purchase_bound(
buyer.path(), buyer.path(),
&buyer_did, &buyer_did,