Reject changed rental metadata before background verification
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user