309 lines
11 KiB
Rust
309 lines
11 KiB
Rust
//! 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());
|
|
}
|
|
}
|