//! V2 binding envelope around the existing immutable registration/serving journal. //! V1 receipt and recovery codecs remain unchanged for existing Cloud originals. use crate::{ app_master_admission::{self, Binding}, identity::NodeIdentity, media_registration::{ self as io, AuthorizedSelection, InstallationPin, Intent, Limits, Receipt, Retirement, }, }; use anyhow::{Context, Result}; use ed25519_dalek::Signer; use serde::{Deserialize, Serialize}; use std::{ fs::File, path::Path, time::{Duration, Instant}, }; pub(crate) const APPROVAL: &str = "archipelago.media-master.approval.v2"; pub(crate) const RESOLUTION: &str = "archipelago.media-master.resolution.v2"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct Operation { pub intent: Intent, pub binding: Binding, pub payment_methods: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct BoundReceipt { pub version: u8, pub binding: Binding, pub registration: Receipt, pub signature: String, } impl BoundReceipt { pub(crate) fn preimage(&self) -> Result> { Ok(serde_json::to_vec(&serde_json::json!([ "archipelago.indeehub.app-master.receipt.v2", self.binding.canonical()?, String::from_utf8(self.registration.preimage()?)?, self.registration.signature ]))?) } fn validate(&self, identity: &NodeIdentity, op: &Operation) -> Result<()> { let r = &self.registration; let i = &op.intent; anyhow::ensure!( self.version == 2 && self.binding == op.binding && r.version == 1 && r.request_id == i.request_id && r.nonce == i.nonce && r.project_id == i.project_id && r.producer == i.producer && r.node_did == i.node_did && r.app_audience == i.app_audience && r.price_sats == i.price_sats && r.viewing_seconds == i.viewing_seconds && r.expires_at == i.expires_at && r.sha256 == op.binding.sha256 && r.size_bytes == op.binding.size_bytes && r.payment_methods == op.payment_methods && NodeIdentity::verify(&identity.pubkey_hex(), &r.preimage()?, &r.signature)? && NodeIdentity::verify( &identity.pubkey_hex(), &self.preimage()?, &self.signature )?, "Original master receipt changed" ); Ok(()) } } fn checked_binding(op: &Operation, pin: &InstallationPin) -> Result<()> { op.binding.validate(pin)?; let i = &op.intent; let b = &op.binding; let id = uuid::Uuid::parse_str(&i.request_id)?; anyhow::ensure!( id.to_string() == i.request_id && id.get_version_num() == 4 && i.version == 1 && matches!(b.version, 2 | 3) && i.app_audience == pin.app_audience && i.node_did == pin.node_did && b.app_audience == i.app_audience && b.node_did == i.node_did && b.producer == i.producer && b.asset.project_id == i.project_id && !op.payment_methods.is_empty() && op.payment_methods.len() <= 3 && op .payment_methods .iter() .all(|method| matches!(method.as_str(), "cashu" | "lightning" | "onchain")) && op.payment_methods.windows(2).all(|pair| pair[0] < pair[1]), "Master approval binding changed" ); Ok(()) } fn checked(op: &Operation, pin: &InstallationPin) -> Result<()> { checked_binding(op, pin)?; anyhow::ensure!( !op.payment_methods.iter().any(|m| m == "onchain") || op.intent.price_sats >= 546, "On-chain receiving requires at least 546 sats" ); Ok(()) } fn operation_dir(data: &Path, op: &Operation, limits: &Limits<'_>) -> Result { let root = io::open_directory(data)?; let store = io::private_directory(&root, "app-master-registrations")?; let dir = io::private_directory(&store, &op.intent.request_id)?; io::lock_operation(&dir, limits, Instant::now() + Duration::from_secs(30))?; if let Some(saved) = io::read_record::(&dir, "operation.json")? { anyhow::ensure!( saved == *op, "Original master, price or receiving methods changed; return to review" ); } else { io::save_record(&dir, "operation.json", op)?; } Ok(dir) } fn complete( dir: &File, identity: &NodeIdentity, op: &Operation, registration: Receipt, ) -> Result { let mut receipt = BoundReceipt { version: 2, binding: op.binding.clone(), registration, signature: String::new(), }; receipt.signature = hex::encode(identity.signing_key().sign(&receipt.preimage()?).to_bytes()); receipt.validate(identity, op)?; if let Some(saved) = io::read_record::(dir, "receipt.json")? { anyhow::ensure!(saved == receipt, "Original master receipt changed"); } else { io::save_record(dir, "receipt.json", &receipt)?; } dir.sync_all()?; Ok(receipt) } pub(crate) async fn prepare( data: &Path, identity: &NodeIdentity, pin: &InstallationPin, op: &Operation, now: u64, limits: &Limits<'_>, ) -> Result { checked(op, pin)?; let dir = operation_dir(data, op, limits)?; if let Some(receipt) = io::read_record::(&dir, "receipt.json")? { receipt.validate(identity, op)?; let recovered = crate::registered_media::resolve_registration( data, identity, &op.intent, &op.intent.producer, now, limits, )?; anyhow::ensure!( recovered["phase"] == "completed" && recovered["receipt"] == serde_json::to_value(&receipt.registration)?, "Durable master media changed" ); return Ok(receipt); } // Recover a crash after durable v1 serving commit, before the v2 envelope. let recovered = crate::registered_media::resolve_registration( data, identity, &op.intent, &op.intent.producer, now, limits, )?; if recovered["phase"] == "completed" { return complete( &dir, identity, op, serde_json::from_value(recovered["receipt"].clone())?, ); } anyhow::ensure!( now < op.intent.expires_at && recovered["phase"] != "retired", "Original preparation expired; recover or retire it" ); // Revalidate at explicit confirmation, even if the browser review is fresh. let fresh = app_master_admission::resolve( data, identity, pin, &op.binding.owner_id, &op.binding.producer, &op.binding.asset, now, false, ) .await?; anyhow::ensure!( fresh == op.binding, "Selected master changed; return to review" ); let source = app_master_admission::cache_source(data, identity, pin, &op.binding, now, limits).await?; let current = u64::try_from(chrono::Utc::now().timestamp())?; let fresh = app_master_admission::resolve( data, identity, pin, &op.binding.owner_id, &op.binding.producer, &op.binding.asset, current, false, ) .await?; anyhow::ensure!( fresh == op.binding && current < op.intent.expires_at, "Master changed or preparation expired; return to review" ); let selection = AuthorizedSelection { relative_path: source.file_name().context("Private source missing")?.into(), payment_methods: op.payment_methods.clone(), }; let registration = crate::registered_media::register_approved_selection( data, source.parent().context("Private cache missing")?, identity, &crate::registered_media::ApprovedSelection { authenticated_producer: &op.intent.producer, authenticated_project: &op.intent.project_id, intent: &op.intent, selection: &selection, }, current, limits, |_| Ok(()), )?; complete(&dir, identity, op, registration) } pub(crate) fn resolve( data: &Path, identity: &NodeIdentity, pin: &InstallationPin, op: &Operation, now: u64, limits: &Limits<'_>, ) -> Result { let invalid_onchain_dust = op.intent.price_sats < 546 && op.payment_methods.iter().any(|method| method == "onchain"); if invalid_onchain_dust { // A previously signed, unconsumed underpriced operation cannot be // prepared, but must be retireable after its exact intent expires. // This path never resolves a source, creates a receipt or publishes. checked_binding(op, pin)?; anyhow::ensure!( now >= op.intent.expires_at, "Original preparation is still active" ); let dir = operation_dir(data, op, limits)?; anyhow::ensure!( io::read_record::(&dir, "receipt.json")?.is_none(), "Invalid payment methods cannot complete a master" ); let retirement = io::retire_unconsumed(data, identity, pin, &op.intent, now, limits)?; return signed_retirement(&dir, identity, op, retirement); } checked(op, pin)?; let dir = operation_dir(data, op, limits)?; if let Some(receipt) = io::read_record::(&dir, "receipt.json")? { receipt.validate(identity, op)?; let recovered = crate::registered_media::resolve_registration( data, identity, &op.intent, &op.intent.producer, now, limits, )?; anyhow::ensure!( recovered["phase"] == "completed" && recovered["receipt"] == serde_json::to_value(&receipt.registration)?, "Durable master media changed" ); return Ok(serde_json::json!({"phase":"completed","receipt":receipt})); } let outcome = crate::registered_media::resolve_registration( data, identity, &op.intent, &op.intent.producer, now, limits, )?; if outcome["phase"] == "completed" { let receipt = complete( &dir, identity, op, serde_json::from_value(outcome["receipt"].clone())?, )?; return Ok(serde_json::json!({"phase":"completed","receipt":receipt})); } if outcome["phase"] == "retired" { let retirement: Retirement = serde_json::from_value(outcome["retirement"].clone())?; return signed_retirement(&dir, identity, op, retirement); } Ok(outcome) } fn signed_retirement( dir: &File, identity: &NodeIdentity, op: &Operation, retirement: Retirement, ) -> Result { let preimage = serde_json::to_vec(&serde_json::json!([ "archipelago.indeehub.app-master.retirement.v2", op.binding.canonical()?, String::from_utf8(retirement.preimage()?)?, retirement.signature ]))?; let signed = serde_json::json!({"version":2,"binding":op.binding,"retirement":retirement, "signature":hex::encode(identity.signing_key().sign(&preimage).to_bytes())}); if let Some(saved) = io::read_record::(dir, "retirement.json")? { anyhow::ensure!(saved == signed, "Original retirement changed"); } else { io::save_record(dir, "retirement.json", &signed)?; } Ok(serde_json::json!({"phase":"retired","retirement":signed})) } #[cfg(test)] mod tests { use super::*; use sha2::{Digest, Sha256}; use std::{ os::unix::fs::PermissionsExt, sync::{atomic::AtomicBool, Arc}, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; async fn fixture( corrupt: bool, current: bool, ) -> ( tempfile::TempDir, NodeIdentity, InstallationPin, Operation, tokio::task::JoinHandle<()>, ) { let root = tempfile::tempdir().unwrap(); let identity = NodeIdentity::load_or_create(&root.path().join("identity")) .await .unwrap(); let installed = crate::container::registration_pin::ensure_for_installation( root.path(), "indeedhub-api", &identity, ) .unwrap(); let pin = InstallationPin { node_did: installed.node_did, app_audience: installed.app_audience, }; let uuid = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().timestamp() as u64; let mut binding:Binding=serde_json::from_value(serde_json::json!({"version":2,"appAudience":pin.app_audience,"nodeDid":pin.node_did, "producer":"aa".repeat(32),"ownerId":"owner","asset":{"projectId":"project","backendScopeId":uuid,"contentId":"content", "file":"projects/project/file/master.mp4","revision":{"configuration":uuid,"authority":uuid}}, "storage":{"version":1,"backendScopeId":uuid,"storageProfileId":uuid,"storageGeneration":uuid,"bucket":"private"}, "source":{"uploadIdentity":uuid,"etag":"\"original\"","size":4,"versionId":null},"uploadCommandId":uuid,"uploadReceiptId":uuid, "fileDigest":format!("sha256-chunks-v1:{}","bb".repeat(32)),"sha256":hex::encode(Sha256::digest(b"test")),"sizeBytes":"4"})).unwrap(); if current { binding.version = 3; binding.storage.version = 2; binding.storage.storage_profile_id = "installed-private-s3-current-v1".into(); binding.storage.storage_generation = "ee".repeat(32); binding.source.upload_identity = None; binding.upload_command_id = None; binding.upload_receipt_id = None; binding.current_verification = Some(crate::app_master_admission::CurrentVerification { kind: "owner-current-source-v1".into(), verification_id: uuid::Uuid::new_v4().to_string(), confirmation_id: uuid::Uuid::new_v4().to_string(), verified_at: now, }); } let op = Operation { intent: Intent { version: 1, request_id: uuid::Uuid::new_v4().to_string(), nonce: "cc".repeat(32), app_audience: pin.app_audience.clone(), node_did: pin.node_did.clone(), producer: binding.producer.clone(), project_id: "project".into(), price_sats: 15, viewing_seconds: 3600, created_at: now, expires_at: now + 600, }, binding: binding.clone(), payment_methods: vec!["cashu".into()], }; let key = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let config = serde_json::json!({"version":1,"node_did":pin.node_did,"app_audience":pin.app_audience, "base_url":format!("http://{}/",listener.local_addr().unwrap()),"service_public_key":hex::encode(key.verifying_key().to_bytes())}); let path = root.path().join("app-master-admission.json"); std::fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap(); std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); let task = tokio::spawn(async move { loop { let (mut socket, _) = listener.accept().await.unwrap(); let mut bytes = Vec::new(); let (header_end, length) = loop { let mut part = [0u8; 1024]; let n = socket.read(&mut part).await.unwrap(); if n == 0 { return; } bytes.extend_from_slice(&part[..n]); if let Some(index) = bytes.windows(4).position(|v| v == b"\r\n\r\n") { let text = String::from_utf8_lossy(&bytes[..index]); let length = text .lines() .find_map(|line| { line.to_ascii_lowercase() .strip_prefix("content-length:") .map(|v| v.trim().parse::().unwrap()) }) .unwrap(); break (index + 4, length); } }; while bytes.len() < header_end + length { let mut part = [0u8; 1024]; let n = socket.read(&mut part).await.unwrap(); bytes.extend_from_slice(&part[..n]); } let r: serde_json::Value = serde_json::from_slice(&bytes[header_end..header_end + length]).unwrap(); let body = if r["action"] == "source" { if corrupt { b"evil".to_vec() } else { b"test".to_vec() } } else { let a = &r["asset"]; let preimage = serde_json::to_string(&serde_json::json!([ "archipelago.indeehub.app-master.request.v2", 2, r["action"], r["issuedAt"], r["nonce"], binding.app_audience, binding.node_did, r["ownerId"], r["producer"], a["projectId"], a["backendScopeId"], a["contentId"], a["file"], a["revision"]["configuration"], a["revision"]["authority"], r["bindingHash"], r["proofVersions"] ])) .unwrap(); let request_hash = hex::encode(Sha256::digest( serde_json::to_vec(&serde_json::json!([preimage, r["signature"]])).unwrap(), )); let at = chrono::Utc::now().timestamp() as u64; let proof = serde_json::to_vec(&serde_json::json!([ "archipelago.indeehub.app-master.response.v2", request_hash, at, binding.canonical().unwrap() ])) .unwrap(); serde_json::to_vec(&serde_json::json!({"version":2,"requestHash":request_hash,"issuedAt":at,"binding":binding,"signature":hex::encode(key.sign(&proof).to_bytes())})).unwrap() }; let header = format!( "HTTP/1.1 201 Created\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); socket.write_all(header.as_bytes()).await.unwrap(); socket.write_all(&body).await.unwrap(); } }); (root, identity, pin, op, task) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn receiving_methods_are_canonical_and_pending_selection_is_immutable() { let (root, _identity, pin, mut op, task) = fixture(false, false).await; op.payment_methods = vec!["lightning".into()]; checked(&op, &pin).unwrap(); let cancelled = AtomicBool::new(false); let limits = Limits { max_bytes: 1024, cancelled: &cancelled, }; drop(operation_dir(root.path(), &op, &limits).unwrap()); op.payment_methods = vec!["cashu".into(), "lightning".into()]; checked(&op, &pin).unwrap(); assert!(operation_dir(root.path(), &op, &limits).is_err()); for methods in [ vec![], vec!["onchain"], vec!["cashu", "cashu"], vec!["lightning", "cashu"], ] { op.payment_methods = methods.into_iter().map(str::to_owned).collect(); assert!(checked(&op, &pin).is_err()); } task.abort(); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn expired_underpriced_onchain_master_can_only_retire_if_unconsumed() { let (root, identity, pin, mut op, task) = fixture(false, false).await; op.intent.price_sats = 2; op.payment_methods = vec!["cashu".into(), "lightning".into(), "onchain".into()]; let cancelled = AtomicBool::new(false); let limits = Limits { max_bytes: 1024, cancelled: &cancelled, }; assert!(checked_binding(&op, &pin).is_ok()); assert!(checked(&op, &pin).is_err()); assert!(prepare( root.path(), &identity, &pin, &op, op.intent.created_at, &limits ) .await .is_err()); assert!(resolve( root.path(), &identity, &pin, &op, op.intent.created_at, &limits ) .is_err()); let retired = resolve( root.path(), &identity, &pin, &op, op.intent.expires_at + 1, &limits, ) .unwrap(); assert_eq!(retired["phase"], "retired"); assert_eq!( retired["retirement"]["binding"], serde_json::to_value(&op.binding).unwrap() ); assert_eq!( retired, resolve( root.path(), &identity, &pin, &op, op.intent.expires_at + 2, &limits ) .unwrap() ); let dir = root .path() .join("app-master-registrations") .join(&op.intent.request_id); assert!(!dir.join("receipt.json").exists()); assert!(dir.join("retirement.json").is_file()); let media = root .path() .join("media-registration") .join(&op.intent.request_id); assert!(!media.join("operation.json").exists()); assert!(!media.join("media").exists()); let mut changed = op.clone(); changed.payment_methods = vec!["cashu".into(), "onchain".into()]; assert!(resolve( root.path(), &identity, &pin, &changed, changed.intent.expires_at + 2, &limits ) .is_err()); task.abort(); let (root, identity, pin, mut op, task) = fixture(false, false).await; op.intent.price_sats = 2; op.payment_methods = vec!["cashu".into(), "lightning".into(), "onchain".into()]; let media = root .path() .join("media-registration") .join(&op.intent.request_id); std::fs::create_dir_all(&media).unwrap(); std::fs::write(media.join("media"), b"retained partial media").unwrap(); assert!(resolve( root.path(), &identity, &pin, &op, op.intent.expires_at + 1, &limits ) .is_err()); assert!(!media.join("retirement.json").exists()); task.abort(); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exact_source_prepares_durable_bound_receipt_reopens_and_rejects_substitution() { let (root, identity, pin, op, task) = fixture(false, false).await; let cancelled = AtomicBool::new(false); let limits = Limits { max_bytes: 1024, cancelled: &cancelled, }; let now = op.intent.created_at; let receipt = prepare(root.path(), &identity, &pin, &op, now, &limits) .await .unwrap(); assert_eq!(receipt.registration.sha256, op.binding.sha256); task.abort(); // Recovery must work without app/source availability, after expiry. let recovered = resolve(root.path(), &identity, &pin, &op, now + 1000, &limits).unwrap(); assert_eq!( recovered["receipt"], serde_json::to_value(&receipt).unwrap() ); for changed in ["asset", "price", "source"] { let mut other = op.clone(); match changed { "asset" => other.binding.asset.content_id = "other".into(), "price" => other.intent.price_sats += 1, _ => other.binding.source.etag = "\"other\"".into(), }; assert!(resolve(root.path(), &identity, &pin, &other, now + 1000, &limits).is_err()); } let media = root .path() .join("media-registration") .join(&op.intent.request_id) .join("media"); std::fs::set_permissions(&media, std::fs::Permissions::from_mode(0o600)).unwrap(); std::fs::write(&media, b"evil").unwrap(); std::fs::set_permissions(&media, std::fs::Permissions::from_mode(0o400)).unwrap(); assert!(resolve(root.path(), &identity, &pin, &op, now + 1000, &limits).is_err()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn corrupt_source_never_issues_receipt_and_expired_original_retires() { let (root, identity, pin, op, task) = fixture(true, false).await; let cancelled = AtomicBool::new(false); let limits = Limits { max_bytes: 1024, cancelled: &cancelled, }; assert!(prepare( root.path(), &identity, &pin, &op, op.intent.created_at, &limits ) .await .is_err()); task.abort(); assert!(!root .path() .join("app-master-registrations") .join(&op.intent.request_id) .join("receipt.json") .exists()); let retired = resolve( root.path(), &identity, &pin, &op, op.intent.expires_at + 1, &limits, ) .unwrap(); assert_eq!(retired["phase"], "retired"); assert_eq!( retired["retirement"]["binding"], serde_json::to_value(&op.binding).unwrap() ); assert_eq!( retired, resolve( root.path(), &identity, &pin, &op, op.intent.expires_at + 50, &limits ) .unwrap() ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn current_owner_verification_prepares_and_recovers_without_claiming_original_upload_history( ) { let (root, identity, pin, op, task) = fixture(false, true).await; let cancelled = AtomicBool::new(false); let limits = Limits { max_bytes: 1024, cancelled: &cancelled, }; let receipt = prepare( root.path(), &identity, &pin, &op, op.intent.created_at, &limits, ) .await .unwrap(); task.abort(); assert_eq!(receipt.binding.version, 3); assert!(receipt.binding.upload_receipt_id.is_none()); assert!(String::from_utf8(receipt.preimage().unwrap()) .unwrap() .contains("owner-current-source-v1")); assert_eq!( resolve( root.path(), &identity, &pin, &op, op.intent.expires_at + 10, &limits ) .unwrap()["receipt"], serde_json::to_value(receipt).unwrap() ); let mut false_history = op.clone(); false_history.binding.upload_receipt_id = Some(uuid::Uuid::new_v4().to_string()); assert!(resolve( root.path(), &identity, &pin, &false_history, op.intent.expires_at + 10, &limits ) .is_err()); let mut changed = op.clone(); changed .binding .current_verification .as_mut() .unwrap() .verification_id = uuid::Uuid::new_v4().to_string(); assert!(resolve( root.path(), &identity, &pin, &changed, op.intent.expires_at + 10, &limits ) .is_err()); } }