From ba1de69fc5bbbf7684c79ea42d13fa5fb8e593bd Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 01:06:57 -0400 Subject: [PATCH] Reject changed rental metadata before background verification --- core/archipelago/src/api/handler/purchase.rs | 9 +++++++ .../src/content_purchase_caller.rs | 23 ++++++++++++++-- .../src/content_purchase_transport.rs | 8 ++++-- core/archipelago/src/wallet/payment_tests.rs | 26 ++++++++++++++++++- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/core/archipelago/src/api/handler/purchase.rs b/core/archipelago/src/api/handler/purchase.rs index 74697a10..0eb9fdec 100644 --- a/core/archipelago/src/api/handler/purchase.rs +++ b/core/archipelago/src/api/handler/purchase.rs @@ -67,6 +67,7 @@ impl ApiHandler { content_id: String, #[serde(default)] retry: bool, + expected: Option, } let input: Prepare = serde_json::from_slice(&bytes)?; let identity = std::sync::Arc::new( @@ -76,6 +77,14 @@ impl ApiHandler { let root = data_dir.clone(); serde_json::to_value( 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( root, identity, diff --git a/core/archipelago/src/content_purchase_caller.rs b/core/archipelago/src/content_purchase_caller.rs index d8249b62..3747ee60 100644 --- a/core/archipelago/src/content_purchase_caller.rs +++ b/core/archipelago/src/content_purchase_caller.rs @@ -15,6 +15,7 @@ pub(crate) trait PurchaseTransport: Send + Sync { fn prepare_offer( &self, _content_id: &str, + _expected: Option<&ExpectedRental>, ) -> impl Future>> + Send { async { Ok(None) } } @@ -86,7 +87,8 @@ pub(crate) async fn purchase( .await } -#[derive(Clone)] +#[derive(Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub(crate) struct ExpectedRental { pub seller_did: String, pub content_id: String, @@ -95,6 +97,23 @@ pub(crate) struct ExpectedRental { pub viewing_seconds: u64, } 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<()> { anyhow::ensure!( self.content_id.starts_with("registered_") @@ -222,7 +241,7 @@ pub(crate) async fn purchase_bound( ); if content_id.starts_with("registered_") { if let Some((completed_bytes, total_bytes)) = - transport.prepare_offer(content_id).await? + transport.prepare_offer(content_id, expected).await? { return Ok(ReadyPurchase::Preparing { completed_bytes, diff --git a/core/archipelago/src/content_purchase_transport.rs b/core/archipelago/src/content_purchase_transport.rs index 1d6dfc27..656eb129 100644 --- a/core/archipelago/src/content_purchase_transport.rs +++ b/core/archipelago/src/content_purchase_transport.rs @@ -79,11 +79,15 @@ impl PurchaseTransport for FipsPurchaseTransport { fn seller_did(&self) -> &str { &self.seller_did } - async fn prepare_offer(&self, content_id: &str) -> Result> { + async fn prepare_offer( + &self, + content_id: &str, + expected: Option<&crate::content_purchase_caller::ExpectedRental>, + ) -> Result> { let state: crate::rental_readiness::Status = self .post( 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?; match state { diff --git a/core/archipelago/src/wallet/payment_tests.rs b/core/archipelago/src/wallet/payment_tests.rs index de7d3118..22aed1e9 100644 --- a/core/archipelago/src/wallet/payment_tests.rs +++ b/core/archipelago/src/wallet/payment_tests.rs @@ -2214,7 +2214,14 @@ impl crate::content_purchase_caller::PurchaseTransport for PurchaseTestTransport fn seller_onion(&self) -> &str { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion" } - async fn prepare_offer(&self, content_id: &str) -> anyhow::Result> { + async fn prepare_offer( + &self, + content_id: &str, + expected: Option<&crate::content_purchase_caller::ExpectedRental>, + ) -> anyhow::Result> { + if let Some(expected) = expected { + expected.verify(&self.template)?; + } // A registered movie still being hashed, without asking the fake mint. 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, 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(); changed_hash.sha256 = "ef".repeat(32); 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(); changed_duration.viewing_seconds = 120; for wrong in [changed_hash, changed_price, changed_duration] { + assert!(wrong + .verify_metadata(&expected.seller_did, &metadata) + .is_err()); let error = purchase_bound( buyer.path(), &buyer_did,