diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index aa9fcde..e684446 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -27,6 +27,18 @@ pub fn experimental_saved_device_capabilities() -> ExperimentalSavedDeviceCapabi } } +/// Public view of a single-use pairing window after a completed transfer. +/// +/// The eligibility capability itself never crosses this boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct PairingEligibilitySummary { + pub peer_endpoint_id: String, + pub session_id: String, + pub protocol_version: u16, + pub created_at: i64, + pub expires_at: i64, +} + /// A remote VniDrop app-installation identity that completed mutual consent. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] pub struct SavedDevice { diff --git a/crates/vnidrop/src/approval.rs b/crates/vnidrop/src/approval.rs index d310409..efcb684 100644 --- a/crates/vnidrop/src/approval.rs +++ b/crates/vnidrop/src/approval.rs @@ -12,6 +12,7 @@ use crate::{ DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer, }, + pairing_eligibility::PairingEligibilityService, repository::{ReceiverRequestInsert, Repository}, transfer_state::ReceiverRequestStatus, util::now_ms, @@ -35,6 +36,7 @@ pub(crate) struct ApprovalService { pending: Arc>>>, max_pending: usize, max_metadata_bytes: u64, + pairing_eligibility: Option, } impl ApprovalService { @@ -55,6 +57,18 @@ impl ApprovalService { .await { Ok(()) => { + if let Some(eligibility) = &self.pairing_eligibility { + if let Err(error) = eligibility + .activate_after_completed_transfer( + &remote_endpoint_id, + &receipt.request_id, + &receipt.token, + ) + .await + { + tracing::warn!(%error, "failed to activate pairing eligibility after delivery"); + } + } self.event_hub.emit_transfer( receipt.transfer_id, "send", @@ -119,6 +133,7 @@ impl ApprovalService { access_policy: Arc, max_pending: usize, max_metadata_bytes: u64, + pairing_eligibility: Option, ) -> Self { Self { repository, @@ -127,6 +142,7 @@ impl ApprovalService { pending: Arc::new(Mutex::new(HashMap::new())), max_pending, max_metadata_bytes, + pairing_eligibility, } } diff --git a/crates/vnidrop/src/event_hub.rs b/crates/vnidrop/src/event_hub.rs index 61c734b..c04ec88 100644 --- a/crates/vnidrop/src/event_hub.rs +++ b/crates/vnidrop/src/event_hub.rs @@ -47,6 +47,10 @@ enum EventPhase { Transfer, /// Delivery receipts from receivers (completed download acknowledgements). Delivery, + /// Saved-device pairing eligibility and consent prompts. + Pairing, + /// Prototype contact lifecycle notifications. + Contacts, } impl EventPhase { @@ -68,6 +72,8 @@ impl EventPhase { "approval" => Some(Self::Approval), "transfer" => Some(Self::Transfer), "delivery" => Some(Self::Delivery), + "pairing" => Some(Self::Pairing), + "contacts" => Some(Self::Contacts), _ => None, } } @@ -90,6 +96,8 @@ impl EventPhase { Self::Approval => "approval", Self::Transfer => "transfer", Self::Delivery => "delivery", + Self::Pairing => "pairing", + Self::Contacts => "contacts", } } } diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 23da0f1..db48fbe 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -11,6 +11,7 @@ mod logging; mod offer; mod offer_inbox; mod pairing; +mod pairing_eligibility; mod repository; mod runtime; mod secret; @@ -29,11 +30,11 @@ pub use api::{ experimental_saved_device_capabilities, ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, DeviceRelationship, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, - GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PendingPairing, PublishedOutput, - ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, - RuntimeStatus, SavedDevice, ShareMetadataInput, ShareResult, ShareSource, SourceKind, - StoredTransfer, TargetedTransfer, TargetedTransferState, TicketInspection, TransferAccessMode, - TransferMetadata, + GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PairingEligibilitySummary, + PendingPairing, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, + ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice, ShareMetadataInput, + ShareResult, ShareSource, SourceKind, StoredTransfer, TargetedTransfer, TargetedTransferState, + TicketInspection, TransferAccessMode, TransferMetadata, }; pub use error::VnidropError; pub use runtime::VnidropCore; diff --git a/crates/vnidrop/src/pairing_eligibility.rs b/crates/vnidrop/src/pairing_eligibility.rs new file mode 100644 index 0000000..9de55f6 --- /dev/null +++ b/crates/vnidrop/src/pairing_eligibility.rs @@ -0,0 +1,338 @@ +//! Pairing eligibility after completed authenticated invitation transfers. +//! +//! The capability is derived from the shared approval session token and becomes +//! usable only after the transfer reaches a durable completed state. Public APIs +//! expose eligibility state, never the capability bytes. + +use std::collections::HashSet; +use std::sync::Arc; + +use serde_json::json; + +use crate::{ + api::{experimental_saved_device_capabilities, PairingEligibilitySummary}, + error::VnidropError, + event_hub::EventHub, + repository::Repository, + secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial}, + util::now_ms, +}; + +const ELIGIBILITY_TTL_MS: i64 = 24 * 60 * 60 * 1_000; +const CAPABILITY_CONTEXT: &str = "vnidrop-pairing-eligibility-v1"; + +#[derive(Clone)] +pub(crate) struct PairingEligibilityService { + repository: Repository, + custody: Option>, + event_hub: Arc, + local_endpoint_id: String, +} + +impl PairingEligibilityService { + pub(crate) fn new( + repository: Repository, + custody: Option>, + event_hub: Arc, + local_endpoint_id: String, + ) -> Self { + Self { + repository, + custody, + event_hub, + local_endpoint_id, + } + } + + /// Removes orphaned eligibility secrets and rows whose secrets are missing. + pub(crate) async fn reconcile(&self) -> Result<(), VnidropError> { + let records = self.repository.list_pairing_eligibility_records().await?; + let mut referenced = HashSet::new(); + for entry in records { + referenced.insert(entry.secret_handle.clone()); + let Some(custody) = &self.custody else { + continue; + }; + let handle = SecretHandle::from_stored(entry.secret_handle.clone()); + if custody.load(&handle).await.is_err() { + self.delete_entry_silent(&entry).await?; + } + } + if let Some(custody) = &self.custody { + for handle in custody + .list_active_handles(SecretKind::PairingEligibility) + .await? + { + if !referenced.contains(handle.as_str()) { + let _ = custody.remove(&handle).await; + } + } + } + self.expire_due(true).await + } + + pub(crate) async fn list(&self) -> Result, VnidropError> { + self.expire_due(true).await?; + self.repository.list_pairing_eligibilities().await + } + + /// Activates eligibility after a durable completed authenticated transfer. + pub(crate) async fn activate_after_completed_transfer( + &self, + peer_endpoint_id: &str, + session_id: &str, + approval_token: &str, + ) -> Result<(), VnidropError> { + let Some(custody) = &self.custody else { + return Ok(()); + }; + if peer_endpoint_id.is_empty() || session_id.is_empty() || approval_token.is_empty() { + return Ok(()); + } + if self + .repository + .find_pairing_eligibility_by_session(session_id) + .await? + .is_some() + { + return Ok(()); + } + + let protocol_version = + experimental_saved_device_capabilities().relationship_protocol_version; + let capability = derive_capability( + approval_token, + &self.local_endpoint_id, + peer_endpoint_id, + session_id, + protocol_version, + )?; + // Credential custody already stages then activates the secret. Domain + // metadata is written only after that verify; a crash leaves an orphan + // secret that reconcile() removes on the next start. + let handle = custody + .protect(SecretKind::PairingEligibility, capability, None) + .await?; + let created_at = now_ms(); + let expires_at = created_at + ELIGIBILITY_TTL_MS; + if let Err(error) = self + .repository + .insert_pairing_eligibility(PairingEligibilityInsert { + peer_endpoint_id, + session_id, + protocol_version, + secret_handle: handle.as_str(), + created_at, + expires_at, + }) + .await + { + let _ = custody.remove(&handle).await; + return Err(error); + } + + self.event_hub.emit_endpoint( + "pairing", + "eligibility-available", + json!({ + "peer_endpoint_id": peer_endpoint_id, + "session_id": session_id, + "protocol_version": protocol_version, + "expires_at": expires_at, + }), + ); + Ok(()) + } + + /// Starts a local pairing attempt when eligibility exists. + /// + /// Returns `false` when eligibility is missing/expired (silent reject). A + /// successful start consumes the single-use eligibility for that session. + pub(crate) async fn request_pairing( + &self, + peer_endpoint_id: &str, + ) -> Result { + self.expire_due(true).await?; + let entries = self + .repository + .list_pairing_eligibilities_for_peer(peer_endpoint_id) + .await?; + let Some(entry) = entries.into_iter().next() else { + return Ok(false); + }; + if entry.expires_at <= now_ms() { + self.delete_entry_silent(&entry).await?; + return Ok(false); + } + self.delete_entry(&entry).await?; + Ok(true) + } + + /// Validates an inbound eligibility presentation without prompts or events on failure. + #[allow( + dead_code, + reason = "inbound pairing wire acceptance lands with mutual-consent ticket 08" + )] + pub(crate) async fn accept_presented_eligibility( + &self, + peer_endpoint_id: &str, + session_id: &str, + capability: &SecretMaterial, + ) -> Result { + let Some(entry) = self + .validate_presented_capability(peer_endpoint_id, session_id, capability) + .await? + else { + return Ok(false); + }; + self.delete_entry(&entry).await?; + Ok(true) + } + + pub(crate) async fn decline(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> { + self.remove_for_peer(peer_endpoint_id).await + } + + pub(crate) async fn remove_for_peer(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> { + let entries = self + .repository + .list_pairing_eligibilities_for_peer(peer_endpoint_id) + .await?; + for entry in entries { + self.delete_entry(&entry).await?; + } + Ok(()) + } + + pub(crate) async fn remove_all(&self) -> Result<(), VnidropError> { + let entries = self.repository.list_pairing_eligibility_records().await?; + for entry in entries { + self.delete_entry(&entry).await?; + } + Ok(()) + } + + /// Returns the matching record when the capability is valid; otherwise `None` + /// without emitting prompts or eligibility-removed events for the reject path. + #[allow( + dead_code, + reason = "inbound pairing wire acceptance lands with mutual-consent ticket 08" + )] + pub(crate) async fn validate_presented_capability( + &self, + peer_endpoint_id: &str, + session_id: &str, + capability: &SecretMaterial, + ) -> Result, VnidropError> { + self.expire_due(false).await?; + let Some(custody) = &self.custody else { + return Ok(None); + }; + let Some(entry) = self + .repository + .find_pairing_eligibility_by_session(session_id) + .await? + else { + return Ok(None); + }; + if entry.peer_endpoint_id != peer_endpoint_id || entry.expires_at <= now_ms() { + return Ok(None); + } + let stored = match custody + .load(&SecretHandle::from_stored(entry.secret_handle.clone())) + .await + { + Ok(material) => material, + Err(_) => return Ok(None), + }; + if stored == *capability { + Ok(Some(entry)) + } else { + Ok(None) + } + } + + async fn expire_due(&self, emit_events: bool) -> Result<(), VnidropError> { + let now = now_ms(); + let expired = self + .repository + .list_expired_pairing_eligibilities(now) + .await?; + for entry in expired { + if emit_events { + self.delete_entry(&entry).await?; + } else { + self.delete_entry_silent(&entry).await?; + } + } + Ok(()) + } + + async fn delete_entry(&self, entry: &PairingEligibilityRecord) -> Result<(), VnidropError> { + self.delete_entry_silent(entry).await?; + self.event_hub.emit_endpoint( + "pairing", + "eligibility-removed", + json!({ + "peer_endpoint_id": entry.peer_endpoint_id, + "session_id": entry.session_id, + }), + ); + Ok(()) + } + + async fn delete_entry_silent( + &self, + entry: &PairingEligibilityRecord, + ) -> Result<(), VnidropError> { + if let Some(custody) = &self.custody { + let handle = SecretHandle::from_stored(entry.secret_handle.clone()); + let _ = custody.remove(&handle).await; + } + self.repository + .delete_pairing_eligibility(&entry.session_id) + .await + } +} + +pub(crate) struct PairingEligibilityInsert<'a> { + pub(crate) peer_endpoint_id: &'a str, + pub(crate) session_id: &'a str, + pub(crate) protocol_version: u16, + pub(crate) secret_handle: &'a str, + pub(crate) created_at: i64, + pub(crate) expires_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PairingEligibilityRecord { + pub(crate) peer_endpoint_id: String, + pub(crate) session_id: String, + pub(crate) protocol_version: u16, + pub(crate) secret_handle: String, + pub(crate) created_at: i64, + pub(crate) expires_at: i64, +} + +fn derive_capability( + approval_token: &str, + local_endpoint_id: &str, + peer_endpoint_id: &str, + session_id: &str, + protocol_version: u16, +) -> Result { + let mut endpoints = [local_endpoint_id, peer_endpoint_id]; + endpoints.sort_unstable(); + let mut hasher = blake3::Hasher::new_derive_key(CAPABILITY_CONTEXT); + hasher.update(approval_token.as_bytes()); + hasher.update(&[0]); + hasher.update(endpoints[0].as_bytes()); + hasher.update(&[0]); + hasher.update(endpoints[1].as_bytes()); + hasher.update(&[0]); + hasher.update(session_id.as_bytes()); + hasher.update(&[0]); + hasher.update(&protocol_version.to_le_bytes()); + let bytes = *hasher.finalize().as_bytes(); + SecretMaterial::new(bytes.to_vec()) +} diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index b2e5a08..d715dfe 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -15,13 +15,18 @@ use uuid::Uuid; use crate::{ access_policy::mode_from_storage, - api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer}, + api::{ + CoreEvent, PairingEligibilitySummary, ReceivedArtifact, ReceivedLocatorKind, + ReceiverRequest, StoredTransfer, + }, contacts::ContactStore, + error::VnidropError, + pairing_eligibility::{PairingEligibilityInsert, PairingEligibilityRecord}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, util::now_ms, }; -const SCHEMA_VERSION: i64 = 10; +const SCHEMA_VERSION: i64 = 11; #[derive(Debug, Clone)] pub(crate) struct Repository { @@ -318,6 +323,28 @@ impl Repository { crate::contacts::ensure_schema(&self.pool).await?; crate::secure_secret::ensure_schema(&self.pool).await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS pairing_eligibilities ( + session_id TEXT PRIMARY KEY, + peer_endpoint_id TEXT NOT NULL, + protocol_version INTEGER NOT NULL, + secret_handle TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + "#, + ) + .execute(&self.pool) + .await?; + sqlx::query( + r#" + CREATE INDEX IF NOT EXISTS pairing_eligibilities_peer + ON pairing_eligibilities(peer_endpoint_id); + "#, + ) + .execute(&self.pool) + .await?; sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}")) .execute(&self.pool) @@ -339,6 +366,150 @@ impl Repository { crate::secure_secret::SecretMetadataStore::new(self.pool.clone()) } + pub(crate) async fn insert_pairing_eligibility( + &self, + entry: PairingEligibilityInsert<'_>, + ) -> Result<(), VnidropError> { + sqlx::query( + r#" + INSERT INTO pairing_eligibilities ( + session_id, peer_endpoint_id, protocol_version, secret_handle, created_at, expires_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "#, + ) + .bind(entry.session_id) + .bind(entry.peer_endpoint_id) + .bind(i64::from(entry.protocol_version)) + .bind(entry.secret_handle) + .bind(entry.created_at) + .bind(entry.expires_at) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(crate) async fn list_pairing_eligibilities( + &self, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT peer_endpoint_id, session_id, protocol_version, created_at, expires_at + FROM pairing_eligibilities + ORDER BY created_at DESC + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows + .into_iter() + .map(|row| PairingEligibilitySummary { + peer_endpoint_id: row.get("peer_endpoint_id"), + session_id: row.get("session_id"), + protocol_version: row.get::("protocol_version") as u16, + created_at: row.get("created_at"), + expires_at: row.get("expires_at"), + }) + .collect()) + } + + pub(crate) async fn list_pairing_eligibility_records( + &self, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at + FROM pairing_eligibilities + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows.into_iter().map(row_to_pairing_eligibility).collect()) + } + + pub(crate) async fn list_pairing_eligibilities_for_peer( + &self, + peer_endpoint_id: &str, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at + FROM pairing_eligibilities + WHERE peer_endpoint_id = ?1 + "#, + ) + .bind(peer_endpoint_id) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows.into_iter().map(row_to_pairing_eligibility).collect()) + } + + pub(crate) async fn list_expired_pairing_eligibilities( + &self, + now_ms: i64, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at + FROM pairing_eligibilities + WHERE expires_at <= ?1 + "#, + ) + .bind(now_ms) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows.into_iter().map(row_to_pairing_eligibility).collect()) + } + + pub(crate) async fn find_pairing_eligibility_by_session( + &self, + session_id: &str, + ) -> Result, VnidropError> { + let row = sqlx::query( + r#" + SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at + FROM pairing_eligibilities + WHERE session_id = ?1 + "#, + ) + .bind(session_id) + .fetch_optional(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(row.map(row_to_pairing_eligibility)) + } + + pub(crate) async fn delete_pairing_eligibility( + &self, + session_id: &str, + ) -> Result<(), VnidropError> { + sqlx::query("DELETE FROM pairing_eligibilities WHERE session_id = ?1") + .bind(session_id) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + #[cfg(test)] + pub(crate) async fn force_pairing_eligibility_expiry_for_test( + &self, + session_id: &str, + expires_at: i64, + ) -> Result<(), VnidropError> { + sqlx::query("UPDATE pairing_eligibilities SET expires_at = ?2 WHERE session_id = ?1") + .bind(session_id) + .bind(expires_at) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + #[cfg(test)] pub(crate) async fn schema_version(&self) -> Result { let row = sqlx::query("PRAGMA user_version") @@ -1267,3 +1438,14 @@ fn row_to_receiver_request(row: sqlx::sqlite::SqliteRow) -> ReceiverRequest { completed_at: row.get("completed_at"), } } + +fn row_to_pairing_eligibility(row: sqlx::sqlite::SqliteRow) -> PairingEligibilityRecord { + PairingEligibilityRecord { + peer_endpoint_id: row.get("peer_endpoint_id"), + session_id: row.get("session_id"), + protocol_version: row.get::("protocol_version") as u16, + secret_handle: row.get("secret_handle"), + created_at: row.get("created_at"), + expires_at: row.get("expires_at"), + } +} diff --git a/crates/vnidrop/src/runtime/contacts.rs b/crates/vnidrop/src/runtime/contacts.rs index 23eebe6..d5e7052 100644 --- a/crates/vnidrop/src/runtime/contacts.rs +++ b/crates/vnidrop/src/runtime/contacts.rs @@ -42,6 +42,41 @@ pub(crate) fn should_poll(last_polled_ms: Option, now_ms: i64) -> bool { } impl CoreInner { + pub(super) async fn list_pairing_eligibilities( + &self, + ) -> Result, crate::error::VnidropError> { + self.pairing_eligibility.list().await + } + + pub(super) async fn decline_pairing_eligibility( + &self, + peer_endpoint_id: String, + ) -> Result<(), crate::error::VnidropError> { + self.pairing_eligibility.decline(&peer_endpoint_id).await + } + + pub(super) async fn request_saved_device_pairing( + &self, + peer_endpoint_id: String, + ) -> Result { + self.pairing_eligibility + .request_pairing(&peer_endpoint_id) + .await + } + + #[cfg(test)] + pub(super) async fn submit_pairing_eligibility_for_test( + &self, + peer_endpoint_id: String, + session_id: String, + capability: Vec, + ) -> Result { + let material = crate::secure_secret::SecretMaterial::new(capability)?; + self.pairing_eligibility + .accept_presented_eligibility(&peer_endpoint_id, &session_id, &material) + .await + } + pub(super) async fn list_contacts(&self) -> Result> { let contacts = self .repository @@ -531,6 +566,10 @@ impl CoreInner { // A prompt on screen from a device we just forgot would be actionable // with a grant that no longer exists. self.offers.discard_from(&endpoint_id).await; + self.pairing_eligibility + .remove_for_peer(&endpoint_id) + .await + .map_err(anyhow::Error::from)?; self.emit_endpoint( "contacts", "contact-forgotten", @@ -555,6 +594,10 @@ impl CoreInner { for contact in &contacts { self.offers.discard_from(&contact.endpoint_id).await; } + self.pairing_eligibility + .remove_all() + .await + .map_err(anyhow::Error::from)?; self.emit_endpoint( "contacts", "contacts-cleared", @@ -582,6 +625,10 @@ impl CoreInner { .await .map_err(VnidropError::repository)?; self.offers.discard_from(&endpoint_id).await; + self.pairing_eligibility + .remove_for_peer(&endpoint_id) + .await + .map_err(anyhow::Error::from)?; self.emit_endpoint( "contacts", "contact-blocked", diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index b370659..58fae6b 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -7,10 +7,10 @@ use super::{CoreInner, IdentityMode}; use crate::{ api::{ ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, - CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PendingPairing, - ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, - ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection, - TransferAccessMode, + CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, + PairingEligibilitySummary, PendingPairing, ReceiveOutputSink, ReceiveOutputSinkV2, + ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, + ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, }, error::VnidropError, filesystem::platform_path, @@ -19,6 +19,9 @@ use crate::{ transfer_state::{TransferDirection, TransferStatus}, }; +#[cfg(test)] +use crate::secure_secret::unlocked_profile_for_test; + #[derive(uniffi::Object)] pub struct VnidropCore { runtime: tokio::runtime::Runtime, @@ -66,6 +69,57 @@ impl VnidropCore { } } +#[cfg(test)] +impl VnidropCore { + /// Test-only protected identity with an injected secret store. + pub(crate) fn initialize_with_test_secret_store( + app_data_dir: String, + event_sink: Arc, + store: Arc, + ) -> Result, VnidropError> { + let app_data_path = PathBuf::from(&app_data_dir); + std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?; + // In-process restart tests reopen the same directory immediately after + // drop; skip exclusive locking and rely on the injected store instead. + let profile_lock = unlocked_profile_for_test(&app_data_path)?; + Self::initialize_with_identity_mode( + app_data_dir, + event_sink, + CoreLimits::default(), + CoreNetworkConfig::default(), + IdentityMode::Protected { + store, + profile_lock, + }, + ) + } + + pub(crate) fn force_pairing_eligibility_expiry_for_test( + &self, + session_id: String, + expires_at: i64, + ) -> Result<(), VnidropError> { + self.block_on( + self.inner + .repository + .force_pairing_eligibility_expiry_for_test(&session_id, expires_at), + ) + } + + pub(crate) fn submit_pairing_eligibility_for_test( + &self, + peer_endpoint_id: String, + session_id: String, + capability: Vec, + ) -> Result { + self.block_on(self.inner.submit_pairing_eligibility_for_test( + peer_endpoint_id, + session_id, + capability, + )) + } +} + #[uniffi::export] impl VnidropCore { #[uniffi::constructor] @@ -315,6 +369,34 @@ impl VnidropCore { .map_err(VnidropError::permission) } + /// Single-use pairing windows created by completed authenticated transfers. + /// + /// Returns eligibility state only — never the capability material. + pub fn list_pairing_eligibilities( + &self, + ) -> Result, VnidropError> { + self.block_on(self.inner.list_pairing_eligibilities()) + } + + /// Declines and removes pairing eligibility for a peer. Idempotent. + pub fn decline_pairing_eligibility( + &self, + peer_endpoint_id: String, + ) -> Result<(), VnidropError> { + self.block_on(self.inner.decline_pairing_eligibility(peer_endpoint_id)) + } + + /// Initiates saved-device pairing when local eligibility exists. + /// + /// Returns `false` when eligibility is missing or already consumed. Invalid + /// attempts produce no pairing prompt. + pub fn request_saved_device_pairing( + &self, + peer_endpoint_id: String, + ) -> Result { + self.block_on(self.inner.request_saved_device_pairing(peer_endpoint_id)) + } + /// Devices the user has chosen to remember. pub fn list_contacts(&self) -> Result, VnidropError> { self.block_on(self.inner.list_contacts()) diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index 3c42ac6..e2baca7 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -62,9 +62,10 @@ use crate::{ offer::OfferService, offer_inbox::OfferInbox, pairing::PairingService, + pairing_eligibility::PairingEligibilityService, repository::Repository, secret::load_or_create_secret, - secure_secret::{start_endpoint_identity, ProfileLock, SecretCustody, SecureSecretStore}, + secure_secret::{start_endpoint_identity, ProfileLock, SecureSecretStore}, ticket::ticket_matches_relay_profile, transfer_state::{TransferDirection, TransferStatus}, }; @@ -100,11 +101,11 @@ pub(super) struct CoreInner { pub(super) router: Router, pub(super) store: FsStore, pub(super) repository: Repository, - _secret_custody: Option, _profile_lock: Option, pub(super) event_hub: Arc, pub(super) approval: ApprovalService, pub(super) pairing: PairingService, + pub(super) pairing_eligibility: PairingEligibilityService, pub(super) offers: OfferInbox, /// Endpoint → last poll time, for the rate limit above. pub(super) last_polled: TokioMutex>, @@ -165,7 +166,7 @@ impl CoreInner { &app_data_dir.join("iroh.secret"), ) .await?; - (secret_key, Some(custody), Some(profile_lock)) + (secret_key, Some(Arc::new(custody)), Some(profile_lock)) } }; let store_root = app_data_dir.join("blobs"); @@ -354,12 +355,19 @@ impl CoreInner { store.tags().delete(name).await?; } } + let pairing_eligibility = PairingEligibilityService::new( + repository.clone(), + secret_custody.clone(), + event_hub.clone(), + endpoint.id().to_string(), + ); let approval = ApprovalService::new( repository.clone(), event_hub.clone(), access_policy.clone(), limits.max_pending_approvals as usize, limits.max_metadata_bytes, + Some(pairing_eligibility.clone()), ); let handshake = HandshakeService::new(approval.clone()); let pairing = PairingService::new( @@ -392,11 +400,11 @@ impl CoreInner { router, store, repository, - _secret_custody: secret_custody, _profile_lock: profile_lock, event_hub, approval, pairing, + pairing_eligibility, offers, last_polled: TokioMutex::new(HashMap::new()), relay_mode, @@ -425,6 +433,9 @@ impl CoreInner { ); inner.spawn_provider_event_task(event_rx).await; inner.spawn_delivery_receipt_task().await; + if let Err(error) = inner.pairing_eligibility.reconcile().await { + tracing::warn!(%error, "failed to reconcile pairing eligibility"); + } Ok(inner) } diff --git a/crates/vnidrop/src/runtime/receive.rs b/crates/vnidrop/src/runtime/receive.rs index d4b5671..f6dc73e 100644 --- a/crates/vnidrop/src/runtime/receive.rs +++ b/crates/vnidrop/src/runtime/receive.rs @@ -419,6 +419,18 @@ impl CoreInner { }) .await .map_err(VnidropError::repository)?; + let peer_endpoint_id = sender_addr.id.to_string(); + if let Err(error) = self + .pairing_eligibility + .activate_after_completed_transfer( + &peer_endpoint_id, + &delivery_receipt.request_id, + &delivery_receipt.token, + ) + .await + { + tracing::warn!(%error, "failed to activate pairing eligibility after receive"); + } pending_delivery_receipt .lock() .expect("pending_delivery_receipt") diff --git a/crates/vnidrop/src/secure_secret.rs b/crates/vnidrop/src/secure_secret.rs index b683e3c..bd27cb8 100644 --- a/crates/vnidrop/src/secure_secret.rs +++ b/crates/vnidrop/src/secure_secret.rs @@ -22,6 +22,8 @@ pub(crate) mod windows; #[cfg(test)] pub(crate) use platform::scope_store; +#[cfg(test)] +pub(crate) use platform::unlocked_profile_for_test; pub(crate) use platform::{lock_profile, platform_secret_store, ProfileLock}; const SECRET_BYTES: usize = 32; @@ -70,6 +72,10 @@ impl SecretHandle { )) } + pub(crate) fn from_stored(value: String) -> Self { + Self(value) + } + pub(crate) fn as_str(&self) -> &str { &self.0 } @@ -422,6 +428,28 @@ impl SecretCustody { Ok(material) } + /// Removes protected material and disables its metadata. Idempotent. + pub(crate) async fn remove(&self, handle: &SecretHandle) -> Result<(), VnidropError> { + if self.metadata.find(handle).await?.is_some() { + self.metadata.disable(handle).await?; + } + self.delete_if_present(handle) + } + + pub(crate) async fn list_active_handles( + &self, + kind: SecretKind, + ) -> Result, VnidropError> { + Ok(self + .metadata + .list() + .await? + .into_iter() + .filter(|entry| entry.kind == kind && entry.state == SecretMetadataState::Active) + .map(|entry| entry.handle) + .collect()) + } + pub(crate) async fn migrate_legacy_endpoint_identity( &self, legacy_path: &Path, diff --git a/crates/vnidrop/src/secure_secret/platform.rs b/crates/vnidrop/src/secure_secret/platform.rs index 1f969ba..e7a01d4 100644 --- a/crates/vnidrop/src/secure_secret/platform.rs +++ b/crates/vnidrop/src/secure_secret/platform.rs @@ -36,6 +36,20 @@ pub(crate) fn lock_profile(app_data_dir: &Path) -> Result Result { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(app_data_dir.join("protected-secrets.lock")) + .map_err(VnidropError::filesystem)?; + Ok(ProfileLock { _file: file }) +} + impl ScopedSecretStore { fn new(app_data_dir: &Path, inner: Arc) -> Self { let profile = blake3::hash(app_data_dir.to_string_lossy().as_bytes()).to_hex(); diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 27f5ab2..b0f9865 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -16,6 +16,8 @@ mod handshake_tests; mod limits_tests; #[path = "tests/network_config.rs"] mod network_config_tests; +#[path = "tests/pairing_eligibility.rs"] +mod pairing_eligibility_tests; #[path = "tests/repository.rs"] mod repository_tests; #[path = "tests/runtime.rs"] diff --git a/crates/vnidrop/src/tests/pairing_eligibility.rs b/crates/vnidrop/src/tests/pairing_eligibility.rs new file mode 100644 index 0000000..18860e8 --- /dev/null +++ b/crates/vnidrop/src/tests/pairing_eligibility.rs @@ -0,0 +1,535 @@ +use std::{ + path::Path, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use crate::{ + experimental_saved_device_capabilities, secure_secret::FaultInjectingSecretStore, CoreEvent, + CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode, VnidropCore, + VnidropError, +}; + +struct RecordingSink { + events: Mutex>, +} + +impl CoreEventSink for RecordingSink { + fn on_event(&self, event: CoreEvent) { + self.events.lock().unwrap().push(event); + } +} + +impl RecordingSink { + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +struct ProtectedNode { + _data_dir: tempfile::TempDir, + core: Arc, + sink: Arc, +} + +impl ProtectedNode { + fn new() -> Self { + let data_dir = tempfile::tempdir().unwrap(); + let sink = Arc::new(RecordingSink { + events: Mutex::new(Vec::new()), + }); + let store = Arc::new(FaultInjectingSecretStore::default()); + let core = VnidropCore::initialize_with_test_secret_store( + data_dir.path().to_string_lossy().into_owned(), + sink.clone(), + store, + ) + .expect("protected test core"); + Self { + _data_dir: data_dir, + core, + sink, + } + } +} + +impl Drop for ProtectedNode { + fn drop(&mut self) { + self.core.shutdown(); + } +} + +fn share_path(core: &VnidropCore, source: &Path, transfer_id: u64) -> crate::ShareResult { + core.share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source.to_string_lossy().into_owned(), + display_name: Some("hello.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id, + transfer_name: Some("hello.txt".to_string()), + sender_name: Some("sender".to_string()), + access_mode: TransferAccessMode::ApprovalRequired, + }, + ) + .unwrap() +} + +fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> crate::ReceiverRequest { + let started = Instant::now(); + loop { + if let Some(request) = sender + .list_receiver_requests(transfer_id) + .unwrap() + .into_iter() + .find(|request| request.status == "requested") + { + return request; + } + assert!( + started.elapsed() < Duration::from_secs(15), + "timed out waiting for receiver request" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn receive_with_response( + sender: &VnidropCore, + transfer_id: u64, + receiver: Arc, + ticket: String, + output_dir: &Path, + accepted: bool, +) -> Result<(), VnidropError> { + let output_dir = output_dir.to_string_lossy().to_string(); + let handle = std::thread::spawn(move || { + receiver.receive(ticket, output_dir, Some("receiver".to_string())) + }); + let request = wait_for_receiver_request(sender, transfer_id); + sender + .respond_receiver_request( + request.id, + accepted, + (!accepted).then(|| "sender-refused".to_string()), + ) + .unwrap(); + handle.join().unwrap() +} + +fn wait_for_eligibility(core: &VnidropCore, peer_endpoint_id: &str) { + let started = Instant::now(); + loop { + let found = core + .list_pairing_eligibilities() + .unwrap() + .into_iter() + .any(|entry| entry.peer_endpoint_id == peer_endpoint_id); + if found { + return; + } + assert!( + started.elapsed() < Duration::from_secs(10), + "eligibility for {peer_endpoint_id} never appeared" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +#[test] +fn completed_authenticated_transfer_creates_pairing_eligibility_on_both_sides() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("hello.txt"); + std::fs::write(&source_path, b"eligible after completion").unwrap(); + + let sender = ProtectedNode::new(); + let receiver = ProtectedNode::new(); + let sender_id = sender.core.status().endpoint_id.clone(); + let receiver_id = receiver.core.status().endpoint_id.clone(); + + let share = share_path(&sender.core, &source_path, 70_001); + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.clone(), + share.ticket, + output_dir.path(), + true, + ) + .unwrap(); + + wait_for_eligibility(&sender.core, &receiver_id); + wait_for_eligibility(&receiver.core, &sender_id); + + let protocol = experimental_saved_device_capabilities().relationship_protocol_version; + let sender_entry = sender + .core + .list_pairing_eligibilities() + .unwrap() + .into_iter() + .find(|entry| entry.peer_endpoint_id == receiver_id) + .unwrap(); + let receiver_entry = receiver + .core + .list_pairing_eligibilities() + .unwrap() + .into_iter() + .find(|entry| entry.peer_endpoint_id == sender_id) + .unwrap(); + + assert_eq!(sender_entry.session_id, receiver_entry.session_id); + assert_eq!(sender_entry.protocol_version, protocol); + assert_eq!(receiver_entry.protocol_version, protocol); + assert!(sender_entry.expires_at > sender_entry.created_at); + assert_eq!( + sender_entry.expires_at - sender_entry.created_at, + 24 * 60 * 60 * 1_000 + ); + + let sender_events = sender.sink.events(); + assert!( + sender_events + .iter() + .any(|event| { event.phase == "pairing" && event.kind == "eligibility-available" }), + "sender should emit eligibility-available without capability material" + ); + assert!( + !sender_events + .iter() + .any(|event| event.data_json.contains("capability")), + "events must not expose the eligibility capability" + ); +} + +#[test] +fn declined_cancelled_and_failed_transfers_create_no_eligibility() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("hello.txt"); + std::fs::write(&source_path, b"not eligible").unwrap(); + + // Declined approval + { + let output_dir = tempfile::tempdir().unwrap(); + let sender = ProtectedNode::new(); + let receiver = ProtectedNode::new(); + let share = share_path(&sender.core, &source_path, 70_010); + let _ = receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.clone(), + share.ticket, + output_dir.path(), + false, + ); + assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty()); + assert!(receiver + .core + .list_pairing_eligibilities() + .unwrap() + .is_empty()); + } + + // Failed export on the receiver + { + let sender = ProtectedNode::new(); + let receiver = ProtectedNode::new(); + let share = share_path(&sender.core, &source_path, 70_011); + let sink = Arc::new(FailingOutputSink); + let handle = { + let receiver = receiver.core.clone(); + let ticket = share.ticket.clone(); + std::thread::spawn(move || { + receiver.receive_with_output_sink(ticket, sink, Some("receiver".to_string())) + }) + }; + let request = wait_for_receiver_request(&sender.core, share.transfer_id); + sender + .core + .respond_receiver_request(request.id, true, None) + .unwrap(); + let _ = handle.join().unwrap(); + std::thread::sleep(Duration::from_millis(200)); + assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty()); + assert!(receiver + .core + .list_pairing_eligibilities() + .unwrap() + .is_empty()); + } +} + +#[test] +fn eligibility_survives_restart_without_filenames_or_history_payload() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("secret-name.txt"); + std::fs::write(&source_path, b"persist eligibility").unwrap(); + + let data_dir = tempfile::tempdir().unwrap(); + let store = Arc::new(FaultInjectingSecretStore::default()); + let sink = Arc::new(RecordingSink { + events: Mutex::new(Vec::new()), + }); + let sender = VnidropCore::initialize_with_test_secret_store( + data_dir.path().to_string_lossy().into_owned(), + sink.clone(), + store.clone(), + ) + .unwrap(); + let receiver = ProtectedNode::new(); + let receiver_id = receiver.core.status().endpoint_id.clone(); + + let share = share_path(&sender, &source_path, 70_020); + receive_with_response( + &sender, + share.transfer_id, + receiver.core.clone(), + share.ticket, + output_dir.path(), + true, + ) + .unwrap(); + wait_for_eligibility(&sender, &receiver_id); + let before = sender.list_pairing_eligibilities().unwrap(); + assert_eq!(before.len(), 1); + sender.shutdown(); + drop(sender); + + let restarted = VnidropCore::initialize_with_test_secret_store( + data_dir.path().to_string_lossy().into_owned(), + sink, + store, + ) + .unwrap(); + let after = restarted.list_pairing_eligibilities().unwrap(); + assert_eq!(after, before); + assert!(!serde_json::to_string(&after) + .unwrap() + .contains("secret-name")); + assert!(!serde_json::to_string(&after) + .unwrap() + .contains("persist eligibility")); + restarted.shutdown(); +} + +#[test] +fn expired_eligibility_is_removed_and_cannot_authorize_pairing() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("hello.txt"); + std::fs::write(&source_path, b"expires").unwrap(); + + let sender = ProtectedNode::new(); + let receiver = ProtectedNode::new(); + let receiver_id = receiver.core.status().endpoint_id.clone(); + let share = share_path(&sender.core, &source_path, 70_050); + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.clone(), + share.ticket, + output_dir.path(), + true, + ) + .unwrap(); + wait_for_eligibility(&sender.core, &receiver_id); + let session_id = sender.core.list_pairing_eligibilities().unwrap()[0] + .session_id + .clone(); + sender + .core + .force_pairing_eligibility_expiry_for_test(session_id, 1) + .unwrap(); + assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty()); + assert!(!sender + .core + .request_saved_device_pairing(receiver_id) + .unwrap()); +} + +#[test] +fn decline_forget_block_and_replay_remove_eligibility_idempotently() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("hello.txt"); + std::fs::write(&source_path, b"remove eligibility").unwrap(); + + let sender = ProtectedNode::new(); + let receiver = ProtectedNode::new(); + let receiver_id = receiver.core.status().endpoint_id.clone(); + let share = share_path(&sender.core, &source_path, 70_030); + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.clone(), + share.ticket, + output_dir.path(), + true, + ) + .unwrap(); + wait_for_eligibility(&sender.core, &receiver_id); + + sender + .core + .decline_pairing_eligibility(receiver_id.clone()) + .unwrap(); + assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty()); + sender + .core + .decline_pairing_eligibility(receiver_id.clone()) + .unwrap(); + assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty()); + + // Fresh eligibility for forget/block coverage on the receiver side. + let sender2 = ProtectedNode::new(); + let output_dir2 = tempfile::tempdir().unwrap(); + let share2 = share_path(&sender2.core, &source_path, 70_031); + receive_with_response( + &sender2.core, + share2.transfer_id, + receiver.core.clone(), + share2.ticket, + output_dir2.path(), + true, + ) + .unwrap(); + wait_for_eligibility(&receiver.core, &sender2.core.status().endpoint_id); + receiver + .core + .forget_contact(sender2.core.status().endpoint_id.clone()) + .unwrap(); + assert!(receiver + .core + .list_pairing_eligibilities() + .unwrap() + .iter() + .all(|entry| entry.peer_endpoint_id != sender2.core.status().endpoint_id)); + + let sender3 = ProtectedNode::new(); + let output_dir3 = tempfile::tempdir().unwrap(); + let share3 = share_path(&sender3.core, &source_path, 70_032); + receive_with_response( + &sender3.core, + share3.transfer_id, + receiver.core.clone(), + share3.ticket, + output_dir3.path(), + true, + ) + .unwrap(); + wait_for_eligibility(&receiver.core, &sender3.core.status().endpoint_id); + receiver + .core + .block_contact(sender3.core.status().endpoint_id.clone()) + .unwrap(); + assert!(receiver + .core + .list_pairing_eligibilities() + .unwrap() + .iter() + .all(|entry| entry.peer_endpoint_id != sender3.core.status().endpoint_id)); +} + +#[test] +fn missing_expired_replayed_and_fabricated_eligibility_are_silently_rejected() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("hello.txt"); + std::fs::write(&source_path, b"silent reject").unwrap(); + + let sender = ProtectedNode::new(); + let receiver = ProtectedNode::new(); + let receiver_id = receiver.core.status().endpoint_id.clone(); + let events_before = receiver.sink.events().len(); + + // Missing eligibility: request produces no pending pairing prompt/event. + assert!(!receiver + .core + .request_saved_device_pairing(sender.core.status().endpoint_id.clone()) + .unwrap()); + assert!(receiver.core.list_pending_pairings().is_empty()); + assert_eq!( + receiver + .sink + .events() + .iter() + .filter(|event| event.phase == "pairing" && event.kind.contains("pending")) + .count(), + 0 + ); + + let share = share_path(&sender.core, &source_path, 70_040); + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.clone(), + share.ticket, + output_dir.path(), + true, + ) + .unwrap(); + wait_for_eligibility(&sender.core, &receiver_id); + + // Consume once, then replay must not create a second prompt. + assert!(sender + .core + .request_saved_device_pairing(receiver_id.clone()) + .unwrap()); + assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty()); + assert!(!sender + .core + .request_saved_device_pairing(receiver_id) + .unwrap()); + assert!(sender.core.list_pending_pairings().is_empty()); + assert!(receiver.core.list_pending_pairings().is_empty()); + + // Fabricated peer identity is rejected without growing pairing events. + let pairing_events_before = receiver + .sink + .events() + .iter() + .filter(|event| event.phase == "pairing") + .count(); + assert!(!receiver + .core + .submit_pairing_eligibility_for_test( + "fabricated-endpoint".to_string(), + "fabricated-session".to_string(), + vec![7u8; 32], + ) + .unwrap()); + assert!(receiver.core.list_pending_pairings().is_empty()); + let pairing_events_after = receiver + .sink + .events() + .iter() + .filter(|event| event.phase == "pairing") + .count(); + assert_eq!(pairing_events_before, pairing_events_after); + let _ = events_before; +} + +struct FailingOutputSink; + +impl crate::ReceiveOutputSink for FailingOutputSink { + fn start_file(&self, _relative_path: String) -> Result<(), VnidropError> { + Ok(()) + } + + fn write_chunk(&self, _relative_path: String, _bytes: Vec) -> Result<(), VnidropError> { + Err(VnidropError::Filesystem { + reason: "export failed".to_string(), + }) + } + + fn finish_file(&self, _relative_path: String) -> Result<(), VnidropError> { + Ok(()) + } + + fn abort_file(&self, _relative_path: String, _reason: String) -> Result<(), VnidropError> { + Ok(()) + } +} diff --git a/crates/vnidrop/src/tests/repository.rs b/crates/vnidrop/src/tests/repository.rs index 2a7e2d1..d963bf0 100644 --- a/crates/vnidrop/src/tests/repository.rs +++ b/crates/vnidrop/src/tests/repository.rs @@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() { async fn persists_transfers_and_events_across_reopen() { let temp = tempfile::tempdir().unwrap(); let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 10); + assert_eq!(repository.schema_version().await.unwrap(), 11); repository .insert_transfer(transfer( 7, @@ -645,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() { pool.close().await; let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 10); + assert_eq!(repository.schema_version().await.unwrap(), 11); let stored = repository.list_transfers().await.unwrap().remove(0); assert_eq!(stored.transfer_id, 7); assert_eq!(stored.local_id, "legacy-7-send");