diff --git a/crates/vnidrop/src/approval.rs b/crates/vnidrop/src/approval.rs index efcb684..936c30a 100644 --- a/crates/vnidrop/src/approval.rs +++ b/crates/vnidrop/src/approval.rs @@ -178,6 +178,18 @@ impl ApprovalService { remote_endpoint_id: String, request: RequestTransfer, ) -> HandshakeResponse { + if self + .repository + .contacts() + .is_blocked(&remote_endpoint_id) + .await + .unwrap_or(false) + { + // Indistinguishable from other refusals so probing cannot detect blocks. + return self + .deny(request.transfer_id, remote_endpoint_id, "not-accepted") + .await; + } let metadata_values = [ request.transfer_hash.as_str(), request.transfer_name.as_str(), diff --git a/crates/vnidrop/src/device_relationship/lifecycle.rs b/crates/vnidrop/src/device_relationship/lifecycle.rs new file mode 100644 index 0000000..57cfcd7 --- /dev/null +++ b/crates/vnidrop/src/device_relationship/lifecycle.rs @@ -0,0 +1,347 @@ +//! Forget, block, grant rotation, and minimal revocation tombstones (design §7–§8). + +use serde_json::json; +use sqlx::Row; + +use super::{DeviceRelationshipService, RelationshipRow}; +use crate::{ + api::DeviceRelationshipState, error::VnidropError, grant::GrantRejection, + secure_secret::SecretHandle, util::now_ms, +}; + +/// Minimal non-secret tombstone for a revoked relationship generation. +/// +/// Retains only what is needed to reject replay: peer identity, generation, +/// opaque grant ids, and revocation time. No names, filenames, history, or +/// capability material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct GenerationTombstone { + pub(crate) remote_endpoint_id: String, + pub(crate) generation: u64, + pub(crate) issued_grant_id: Option, + pub(crate) held_grant_id: Option, + pub(crate) revoked_at: i64, +} + +#[derive(Debug, Clone)] +pub(crate) struct ForgetOutcome { + pub(crate) had_relationship: bool, + pub(crate) generation: Option, + pub(crate) issued_grant_id: Option, +} + +impl DeviceRelationshipService { + pub(crate) async fn ensure_lifecycle_schema(pool: &sqlx::SqlitePool) -> anyhow::Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS relationship_generation_tombstones ( + remote_endpoint_id TEXT NOT NULL, + generation INTEGER NOT NULL, + issued_grant_id TEXT, + held_grant_id TEXT, + revoked_at INTEGER NOT NULL, + PRIMARY KEY (remote_endpoint_id, generation) + ); + "#, + ) + .execute(pool) + .await?; + Ok(()) + } + + /// Forget a saved (or pending) device: revoke locally first, clean secrets, + /// then the caller sends a best-effort remote notice. Invitation-domain + /// transfers are untouched. + pub(crate) async fn forget( + &self, + peer_endpoint_id: String, + ) -> Result { + let peer_lock = self.lock_peer(&peer_endpoint_id).await; + let _guard = peer_lock.lock().await; + + let Some(row) = self.find_row(&peer_endpoint_id).await? else { + return Ok(ForgetOutcome { + had_relationship: false, + generation: None, + issued_grant_id: None, + }); + }; + + let issued_grant_id = row.issued_grant_id.clone(); + let generation = row.generation; + self.tombstone_generation(&peer_endpoint_id, &row).await?; + self.delete_relationship(&peer_endpoint_id).await?; + self.eligibility.remove_for_peer(&peer_endpoint_id).await?; + self.emit_changed(&peer_endpoint_id, DeviceRelationshipState::Revoked); + drop(_guard); + + Ok(ForgetOutcome { + had_relationship: true, + generation: Some(generation), + issued_grant_id, + }) + } + + /// Identity-wide block: revoke relationship grants, keep deny + tombstones. + /// Caller owns the durable deny record (`blocked_endpoints`). + pub(crate) async fn revoke_for_block( + &self, + peer_endpoint_id: &str, + ) -> Result<(), VnidropError> { + let peer_lock = self.lock_peer(peer_endpoint_id).await; + let _guard = peer_lock.lock().await; + + if let Some(row) = self.find_row(peer_endpoint_id).await? { + self.tombstone_generation(peer_endpoint_id, &row).await?; + self.delete_relationship(peer_endpoint_id).await?; + } + self.eligibility.remove_for_peer(peer_endpoint_id).await?; + self.emit_changed(peer_endpoint_id, DeviceRelationshipState::Blocked); + Ok(()) + } + + /// Activate a replacement grant: invalidate the prior generation first, then + /// mint exactly one new active generation for the issued direction. + pub(crate) async fn rotate_relationship_grant( + &self, + peer_endpoint_id: String, + ) -> Result { + let peer_lock = self.lock_peer(&peer_endpoint_id).await; + let _guard = peer_lock.lock().await; + + let Some(row) = self.find_row(&peer_endpoint_id).await? else { + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "no relationship to rotate" + ))); + }; + if row.state != DeviceRelationshipState::Saved { + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "only saved relationships can rotate grants" + ))); + } + + // Invalidate first: tombstone + secret removal before the new generation + // becomes active, so a concurrent presenter cannot race past revocation. + self.tombstone_generation(&peer_endpoint_id, &row).await?; + self.clear_grant_secrets(&row).await?; + + let new_generation = row.generation.saturating_add(1); + let now = now_ms(); + sqlx::query( + r#" + UPDATE device_relationships + SET generation = ?2, + issued_grant_handle = NULL, + held_grant_handle = NULL, + issued_grant_id = NULL, + held_grant_id = NULL, + updated_at = ?3 + WHERE remote_endpoint_id = ?1 + "#, + ) + .bind(&peer_endpoint_id) + .bind(new_generation as i64) + .bind(now) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + + let _wire = self + .mint_and_store_issued_grant( + &peer_endpoint_id, + new_generation, + row.minimum_protocol_version, + ) + .await?; + + self.event_hub.emit_endpoint( + "pairing", + "relationship-grant-rotated", + json!({ + "peer_endpoint_id": peer_endpoint_id, + "generation": new_generation, + }), + ); + Ok(new_generation) + } + + /// Reject a presented generation when it is tombstoned or not the active one. + pub(crate) async fn reject_replayed_generation( + &self, + peer_endpoint_id: &str, + generation: u64, + _grant_id: Option<&str>, + ) -> Result<(), GrantRejection> { + if self + .find_tombstone(peer_endpoint_id, generation) + .await + .map_err(|_| GrantRejection::Unknown)? + .is_some() + { + return Err(GrantRejection::Revoked); + } + + let Some(row) = self + .find_row(peer_endpoint_id) + .await + .map_err(|_| GrantRejection::Unknown)? + else { + return Err(GrantRejection::Unknown); + }; + // Pending pairing and Saved both use the active row generation; only a + // mismatch (or tombstone above) means the presenter is replaying. + match row.state { + DeviceRelationshipState::PendingOutgoing + | DeviceRelationshipState::PendingIncoming + | DeviceRelationshipState::Saved => { + if row.generation != generation { + return Err(GrantRejection::Unknown); + } + Ok(()) + } + DeviceRelationshipState::Revoked | DeviceRelationshipState::Blocked => { + Err(GrantRejection::Unknown) + } + } + } + + #[cfg(test)] + pub(crate) async fn list_tombstones( + &self, + peer_endpoint_id: &str, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at + FROM relationship_generation_tombstones + WHERE remote_endpoint_id = ?1 + ORDER BY generation ASC + "#, + ) + .bind(peer_endpoint_id) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows + .into_iter() + .map(|row| GenerationTombstone { + remote_endpoint_id: row.get("remote_endpoint_id"), + generation: row.get::("generation") as u64, + issued_grant_id: row.get("issued_grant_id"), + held_grant_id: row.get("held_grant_id"), + revoked_at: row.get("revoked_at"), + }) + .collect()) + } + + #[cfg(test)] + pub(crate) async fn issued_grant_snapshot( + &self, + peer_endpoint_id: &str, + ) -> Result, VnidropError> { + let Some(row) = self.find_row(peer_endpoint_id).await? else { + return Ok(None); + }; + let Some(grant_id) = row.issued_grant_id else { + return Ok(None); + }; + Ok(Some((row.generation, grant_id))) + } + + async fn tombstone_generation( + &self, + peer_endpoint_id: &str, + row: &RelationshipRow, + ) -> Result<(), VnidropError> { + sqlx::query( + r#" + INSERT INTO relationship_generation_tombstones ( + remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(remote_endpoint_id, generation) DO UPDATE SET + issued_grant_id = COALESCE(excluded.issued_grant_id, relationship_generation_tombstones.issued_grant_id), + held_grant_id = COALESCE(excluded.held_grant_id, relationship_generation_tombstones.held_grant_id), + revoked_at = excluded.revoked_at + "#, + ) + .bind(peer_endpoint_id) + .bind(row.generation as i64) + .bind(row.issued_grant_id.as_deref()) + .bind(row.held_grant_id.as_deref()) + .bind(now_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + async fn find_tombstone( + &self, + peer_endpoint_id: &str, + generation: u64, + ) -> Result, VnidropError> { + let row = sqlx::query( + r#" + SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at + FROM relationship_generation_tombstones + WHERE remote_endpoint_id = ?1 AND generation = ?2 + "#, + ) + .bind(peer_endpoint_id) + .bind(generation as i64) + .fetch_optional(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(row.map(|row| GenerationTombstone { + remote_endpoint_id: row.get("remote_endpoint_id"), + generation: row.get::("generation") as u64, + issued_grant_id: row.get("issued_grant_id"), + held_grant_id: row.get("held_grant_id"), + revoked_at: row.get("revoked_at"), + })) + } + + async fn clear_grant_secrets(&self, row: &RelationshipRow) -> Result<(), VnidropError> { + let Some(custody) = &self.custody else { + return Ok(()); + }; + for handle in [&row.issued_grant_handle, &row.held_grant_handle] + .into_iter() + .flatten() + { + let _ = custody + .remove(&SecretHandle::from_stored(handle.clone())) + .await; + } + Ok(()) + } + + /// Apply a best-effort remote revocation notice from a peer. + pub(crate) async fn handle_remote_revoke( + &self, + remote_endpoint_id: String, + generation: u64, + ) -> bool { + let peer_lock = self.lock_peer(&remote_endpoint_id).await; + let _guard = peer_lock.lock().await; + let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else { + return true; + }; + if row.generation != generation + && generation != 0 + && self + .find_tombstone(&remote_endpoint_id, generation) + .await + .ok() + .flatten() + .is_some() + { + return true; + } + let _ = self.tombstone_generation(&remote_endpoint_id, &row).await; + let _ = self.delete_relationship(&remote_endpoint_id).await; + let _ = self.eligibility.remove_for_peer(&remote_endpoint_id).await; + self.emit_changed(&remote_endpoint_id, DeviceRelationshipState::Revoked); + true + } +} diff --git a/crates/vnidrop/src/device_relationship/mod.rs b/crates/vnidrop/src/device_relationship/mod.rs index 346d282..409b397 100644 --- a/crates/vnidrop/src/device_relationship/mod.rs +++ b/crates/vnidrop/src/device_relationship/mod.rs @@ -31,7 +31,12 @@ use crate::{ }; mod crypto; +mod lifecycle; +#[cfg(test)] +pub(crate) use lifecycle::GenerationTombstone; + +use crate::contacts::ContactStore; use crypto::{ encode_relationship_grant_secret, prove_relationship_grant, secret_from_material, verify_relationship_grant, @@ -71,7 +76,7 @@ impl DeviceRelationshipService { } } - async fn lock_peer(&self, peer_endpoint_id: &str) -> Arc> { + pub(super) async fn lock_peer(&self, peer_endpoint_id: &str) -> Arc> { let mut locks = self.peer_locks.lock().await; locks .entry(peer_endpoint_id.to_string()) @@ -115,9 +120,21 @@ impl DeviceRelationshipService { .execute(pool) .await?; } + Self::ensure_lifecycle_schema(pool).await?; Ok(()) } + pub(super) fn contacts(&self) -> ContactStore { + ContactStore::new(self.pool.clone()) + } + + pub(super) async fn is_blocked(&self, endpoint_id: &str) -> bool { + self.contacts() + .is_blocked(endpoint_id) + .await + .unwrap_or(false) + } + /// Drop orphaned relationship grant secrets and disable rows whose secrets are gone. pub(crate) async fn reconcile(&self) -> Result<(), VnidropError> { let Some(custody) = &self.custody else { @@ -139,19 +156,28 @@ impl DeviceRelationshipService { let state = parse_state(&row.get::("state"))?; let issued: Option = row.get("issued_grant_handle"); let held: Option = row.get("held_grant_handle"); - let mut missing = false; - for handle in [&issued, &held].into_iter().flatten() { + let mut issued_missing = issued.is_none(); + if let Some(handle) = &issued { live_handles.insert(handle.clone()); if custody .load(&SecretHandle::from_stored(handle.clone())) .await .is_err() { - missing = true; + issued_missing = true; } } - if missing && state == DeviceRelationshipState::Saved { - // Grants are required for a usable saved device. + if let Some(handle) = &held { + live_handles.insert(handle.clone()); + // Held gaps after rotation are recoverable; orphaned handles are + // still tracked so reconcile does not delete live custody rows. + let _ = custody + .load(&SecretHandle::from_stored(handle.clone())) + .await; + } + // Issued grants authorize the peer. A missing held grant after + // rotation is recoverable while the peer is offline. + if state == DeviceRelationshipState::Saved && issued_missing { self.delete_relationship(&peer).await?; } } @@ -202,6 +228,9 @@ impl DeviceRelationshipService { self: &Arc, peer_endpoint_id: String, ) -> Result { + if self.is_blocked(&peer_endpoint_id).await { + return Ok(false); + } let peer_lock = self.lock_peer(&peer_endpoint_id).await; let _guard = peer_lock.lock().await; @@ -316,6 +345,9 @@ impl DeviceRelationshipService { peer_endpoint_id: String, accepted: bool, ) -> Result { + if self.is_blocked(&peer_endpoint_id).await { + return Ok(false); + } let Some(row) = self.find_row(&peer_endpoint_id).await? else { return Ok(false); }; @@ -442,6 +474,10 @@ impl DeviceRelationshipService { remote_endpoint_id: String, request: PairingRequest, ) -> PairingRequestResponse { + if self.is_blocked(&remote_endpoint_id).await { + // Indistinguishable rejection: do not expose block state. + return PairingRequestResponse::Rejected; + } let peer_lock = self.lock_peer(&remote_endpoint_id).await; let _guard = peer_lock.lock().await; @@ -520,6 +556,9 @@ impl DeviceRelationshipService { remote_endpoint_id: String, consent: PairingConsent, ) -> PairingConsentResponse { + if self.is_blocked(&remote_endpoint_id).await { + return PairingConsentResponse::Rejected; + } let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else { return PairingConsentResponse::Rejected; }; @@ -588,6 +627,9 @@ impl DeviceRelationshipService { remote_endpoint_id: String, ack: PairingAck, ) -> PairingAckResponse { + if self.is_blocked(&remote_endpoint_id).await { + return PairingAckResponse::Rejected; + } let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else { return PairingAckResponse::Rejected; }; @@ -854,6 +896,15 @@ impl DeviceRelationshipService { generation: u64, protocol_version: u16, ) -> Result<(), VnidropError> { + if let Err(rejection) = self + .reject_replayed_generation(peer_endpoint_id, generation, Some(proof.grant_id.as_str())) + .await + { + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "relationship grant {}", + rejection.as_str() + ))); + } let custody = self.custody .as_ref() @@ -1103,6 +1154,19 @@ impl ProtocolHandler for RelationshipProtocol { .await; let _ = tx.send(response).await; } + RelationshipMessage::RevokeNotice(message) => { + let WithChannels { inner, tx, .. } = message; + let acknowledged = self + .relationships + .handle_remote_revoke(remote_endpoint_id.clone(), inner.generation) + .await; + let response = if acknowledged { + RevokeNoticeResponse::Acknowledged + } else { + RevokeNoticeResponse::Rejected + }; + let _ = tx.send(response).await; + } } } connection.closed().await; @@ -1142,6 +1206,13 @@ impl RelationshipClient { async fn pairing_ack(&self, ack: PairingAck) -> Result { self.inner.rpc(ack).await } + + async fn revoke_notice( + &self, + notice: RevokeNotice, + ) -> Result { + self.inner.rpc(notice).await + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1212,6 +1283,18 @@ struct WireProof { challenge: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RevokeNotice { + generation: u64, + issued_grant_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +enum RevokeNoticeResponse { + Acknowledged, + Rejected, +} + #[rpc_requests(message = RelationshipMessage)] #[derive(Debug, Serialize, Deserialize)] #[allow( @@ -1225,6 +1308,8 @@ enum RelationshipMessages { PairingConsent(PairingConsent), #[rpc(tx = oneshot::Sender)] PairingAck(PairingAck), + #[rpc(tx = oneshot::Sender)] + RevokeNotice(RevokeNotice), } struct RelationshipUpsert<'a> { @@ -1255,6 +1340,29 @@ struct RelationshipRow { created_at: i64, } +impl DeviceRelationshipService { + /// Best-effort signed/bound revocation notice; correctness never depends on delivery. + pub(crate) async fn notify_remote_revoke( + &self, + peer_endpoint_id: &str, + generation: u64, + issued_grant_id: Option, + ) { + let Ok(addr) = self.peer_addr(peer_endpoint_id).await else { + return; + }; + let client = RelationshipClient::connect(self.endpoint.clone(), addr); + let _ = tokio::time::timeout( + PAIRING_RPC_TIMEOUT, + client.revoke_notice(RevokeNotice { + generation, + issued_grant_id, + }), + ) + .await; + } +} + fn state_as_str(state: DeviceRelationshipState) -> &'static str { match state { DeviceRelationshipState::PendingOutgoing => "pending_outgoing", diff --git a/crates/vnidrop/src/runtime/contacts.rs b/crates/vnidrop/src/runtime/contacts.rs index 94bba7a..b1ce605 100644 --- a/crates/vnidrop/src/runtime/contacts.rs +++ b/crates/vnidrop/src/runtime/contacts.rs @@ -81,11 +81,130 @@ impl CoreInner { peer_endpoint_id: String, accepted: bool, ) -> Result { + if self + .repository + .contacts() + .is_blocked(&peer_endpoint_id) + .await + .unwrap_or(false) + { + return Ok(false); + } self.device_relationships .respond_to_pairing(peer_endpoint_id, accepted) .await } + pub(super) async fn forget_saved_device( + self: &Arc, + peer_endpoint_id: String, + ) -> Result<(), crate::error::VnidropError> { + let outcome = self + .device_relationships + .forget(peer_endpoint_id.clone()) + .await?; + // Targeted transfers for this relationship only (ticket 10 fills in). + // Invitation-domain shares are deliberately not cancelled here. + self.cancel_targeted_transfers_for_peer(&peer_endpoint_id) + .await; + self.emit_endpoint( + "pairing", + "saved-device-forgotten", + json!({ + "peer_endpoint_id": peer_endpoint_id, + "had_relationship": outcome.had_relationship, + }), + ); + if outcome.had_relationship { + if let Some(generation) = outcome.generation { + self.device_relationships + .notify_remote_revoke(&peer_endpoint_id, generation, outcome.issued_grant_id) + .await; + } + } + Ok(()) + } + + pub(super) async fn block_device( + self: &Arc, + peer_endpoint_id: String, + ) -> Result<(), crate::error::VnidropError> { + let now = now_ms(); + self.repository + .contacts() + .block_endpoint(&peer_endpoint_id, now) + .await + .map_err(VnidropError::repository)?; + self.device_relationships + .revoke_for_block(&peer_endpoint_id) + .await?; + self.cancel_targeted_transfers_for_peer(&peer_endpoint_id) + .await; + self.offers.discard_from(&peer_endpoint_id).await; + self.emit_endpoint( + "pairing", + "device-blocked", + json!({ "peer_endpoint_id": peer_endpoint_id }), + ); + // Silence: blocked peers are not notified (design §8). + Ok(()) + } + + pub(super) async fn unblock_device( + &self, + peer_endpoint_id: String, + ) -> Result<(), crate::error::VnidropError> { + self.repository + .contacts() + .unblock_endpoint(&peer_endpoint_id) + .await + .map_err(VnidropError::repository)?; + // Unblock removes only the deny rule; grants/relationships stay gone. + Ok(()) + } + + pub(super) async fn list_blocked_devices( + &self, + ) -> Result, crate::error::VnidropError> { + self.repository + .contacts() + .list_blocked() + .await + .map_err(VnidropError::repository) + } + + pub(super) async fn rotate_relationship_grant( + &self, + peer_endpoint_id: String, + ) -> Result { + self.device_relationships + .rotate_relationship_grant(peer_endpoint_id) + .await + } + + /// Cancels active or resumable targeted transfers for a peer relationship. + /// + /// Stub until ticket 10 lands targeted-transfer cancellation. Forget/block + /// call this for immediate local effect without touching invitation shares. + pub(super) async fn cancel_targeted_transfers_for_peer(&self, peer_endpoint_id: &str) { + #[cfg(test)] + { + self.targeted_cancel_log + .lock() + .expect("targeted cancel log") + .push(peer_endpoint_id.to_string()); + } + let _ = peer_endpoint_id; + } + + #[cfg(test)] + pub(super) fn targeted_cancel_log_for_test(&self) -> Vec { + self.targeted_cancel_log + .lock() + .expect("targeted cancel log") + .clone() + } + #[cfg(test)] pub(super) async fn submit_pairing_eligibility_for_test( &self, diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index 3c69861..8064a96 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -118,6 +118,47 @@ impl VnidropCore { capability, )) } + + pub(crate) fn relationship_issued_grant_for_test( + &self, + peer_endpoint_id: String, + ) -> Result, VnidropError> { + self.block_on( + self.inner + .device_relationships + .issued_grant_snapshot(&peer_endpoint_id), + ) + } + + pub(crate) fn relationship_tombstones_for_test( + &self, + peer_endpoint_id: String, + ) -> Result, VnidropError> { + self.block_on( + self.inner + .device_relationships + .list_tombstones(&peer_endpoint_id), + ) + } + + pub(crate) fn reject_relationship_generation_for_test( + &self, + peer_endpoint_id: String, + generation: u64, + grant_id: Option, + ) -> Result<(), String> { + self.block_on(async { + self.inner + .device_relationships + .reject_replayed_generation(&peer_endpoint_id, generation, grant_id.as_deref()) + .await + .map_err(|rejection| rejection.as_str().to_string()) + }) + } + + pub(crate) fn targeted_cancel_log_for_test(&self) -> Vec { + self.inner.targeted_cancel_log_for_test() + } } #[uniffi::export] @@ -418,6 +459,31 @@ impl VnidropCore { ) } + /// Forget a saved device: revoke locally, clean secrets, cancel that + /// relationship's targeted transfers, and best-effort notify the peer. + pub fn forget_saved_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> { + self.block_on(self.inner.forget_saved_device(peer_endpoint_id)) + } + + /// Identity-wide deny across pairing, targeted transfer, invitation, and handshake. + pub fn block_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> { + self.block_on(self.inner.block_device(peer_endpoint_id)) + } + + /// Remove only the deny rule; does not restore grants or relationships. + pub fn unblock_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> { + self.block_on(self.inner.unblock_device(peer_endpoint_id)) + } + + pub fn list_blocked_devices(&self) -> Result, VnidropError> { + self.block_on(self.inner.list_blocked_devices()) + } + + /// Invalidate the prior relationship generation, then activate a replacement grant. + pub fn rotate_relationship_grant(&self, peer_endpoint_id: String) -> Result { + self.block_on(self.inner.rotate_relationship_grant(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 1890ae3..d5b5e86 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -129,6 +129,9 @@ pub(super) struct CoreInner { pub(super) delivery_receipt_notify: Notify, pub(super) delivery_receipt_task: TokioMutex>>, pub(super) shutdown_started: AtomicBool, + /// Test-only log of peers passed to [`Self::cancel_targeted_transfers_for_peer`]. + #[cfg(test)] + targeted_cancel_log: std::sync::Mutex>, } pub(super) struct ActiveTransfer { @@ -435,6 +438,8 @@ impl CoreInner { delivery_receipt_notify: Notify::new(), delivery_receipt_task: TokioMutex::new(None), shutdown_started: AtomicBool::new(false), + #[cfg(test)] + targeted_cancel_log: std::sync::Mutex::new(Vec::new()), }); inner.emit_endpoint( diff --git a/crates/vnidrop/src/tests/device_relationship.rs b/crates/vnidrop/src/tests/device_relationship.rs index dece2fc..251c20a 100644 --- a/crates/vnidrop/src/tests/device_relationship.rs +++ b/crates/vnidrop/src/tests/device_relationship.rs @@ -315,3 +315,228 @@ fn request_timeout_leaves_recoverable_pending_not_saved() { "timed-out pairing must not surface as saved" ); } + +fn reach_saved(alice: &ProtectedNode, bob: &ProtectedNode, transfer_id: u64) { + let alice_id = alice.core.status().endpoint_id.clone(); + let bob_id = bob.core.status().endpoint_id.clone(); + complete_transfer(alice, bob, transfer_id); + assert!(alice + .core + .request_saved_device_pairing(bob_id.clone()) + .unwrap()); + wait_for_relationship( + &bob.core, + &alice_id, + DeviceRelationshipState::PendingIncoming, + ); + assert!(bob + .core + .respond_to_device_pairing(alice_id.clone(), true) + .unwrap()); + wait_for_relationship(&alice.core, &bob_id, DeviceRelationshipState::Saved); + wait_for_relationship(&bob.core, &alice_id, DeviceRelationshipState::Saved); +} + +#[test] +fn rotating_grant_invalidates_prior_generation_and_leaves_one_active() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + let bob_id = bob.core.status().endpoint_id.clone(); + reach_saved(&alice, &bob, 90_001); + + let (old_generation, old_grant_id) = alice + .core + .relationship_issued_grant_for_test(bob_id.clone()) + .unwrap() + .expect("issued grant before rotate"); + assert_eq!(old_generation, 1); + + let new_generation = alice + .core + .rotate_relationship_grant(bob_id.clone()) + .unwrap(); + assert_eq!(new_generation, 2); + + let relationships = alice.core.list_device_relationships().unwrap(); + assert_eq!(relationships.len(), 1); + assert_eq!(relationships[0].generation, 2); + assert_eq!(relationships[0].state, DeviceRelationshipState::Saved); + + let (active_generation, active_grant_id) = alice + .core + .relationship_issued_grant_for_test(bob_id.clone()) + .unwrap() + .expect("issued grant after rotate"); + assert_eq!(active_generation, 2); + assert_ne!(active_grant_id, old_grant_id); + + let err = alice + .core + .reject_relationship_generation_for_test( + bob_id.clone(), + old_generation, + Some(old_grant_id.clone()), + ) + .expect_err("tombstoned generation must be rejected"); + assert_eq!(err, "revoked"); + + alice + .core + .reject_relationship_generation_for_test(bob_id.clone(), new_generation, None) + .expect("active generation remains usable"); + + let tombstones = alice.core.relationship_tombstones_for_test(bob_id).unwrap(); + assert_eq!(tombstones.len(), 1); + assert_eq!(tombstones[0].generation, old_generation); + assert_eq!( + tombstones[0].issued_grant_id.as_deref(), + Some(old_grant_id.as_str()) + ); + // Minimal non-secret payload only. + assert!(tombstones[0].revoked_at > 0); +} + +#[test] +fn forget_saved_device_revokes_locally_and_hooks_targeted_cancel() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + let bob_id = bob.core.status().endpoint_id.clone(); + reach_saved(&alice, &bob, 90_010); + + alice.core.forget_saved_device(bob_id.clone()).unwrap(); + + assert!(alice.core.list_saved_devices().unwrap().is_empty()); + let relationships = alice.core.list_device_relationships().unwrap(); + assert!( + relationships.is_empty(), + "forgotten relationship must not remain listed" + ); + let cancels = alice.core.targeted_cancel_log_for_test(); + assert_eq!(cancels, vec![bob_id.clone()]); + + let tombstones = alice + .core + .relationship_tombstones_for_test(bob_id.clone()) + .unwrap(); + assert_eq!(tombstones.len(), 1); + assert!(alice + .core + .reject_relationship_generation_for_test(bob_id, tombstones[0].generation, None) + .is_err()); +} + +#[test] +fn forget_does_not_cancel_active_invitation_transfer() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + let bob_id = bob.core.status().endpoint_id.clone(); + reach_saved(&alice, &bob, 90_020); + + 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"invitation continues after forget").unwrap(); + let share = share_path(&alice.core, &source_path, 90_021); + let output = output_dir.path().to_string_lossy().to_string(); + let receiver_core = bob.core.clone(); + let ticket = share.ticket.clone(); + let handle = std::thread::spawn(move || { + receiver_core.receive(ticket, output, Some("receiver".to_string())) + }); + let request = wait_for_receiver_request(&alice.core, share.transfer_id); + alice + .core + .respond_receiver_request(request.id, true, None) + .unwrap(); + + // Forget after the invitation is approved; the share-domain transfer must finish. + alice.core.forget_saved_device(bob_id).unwrap(); + handle.join().unwrap().unwrap(); + + let transfers = alice.core.list_transfers().unwrap(); + let invitation = transfers + .iter() + .find(|entry| entry.transfer_id == share.transfer_id) + .expect("invitation transfer retained"); + assert_ne!( + invitation.status.to_lowercase(), + "cancelled", + "forget must not cancel independently approved invitation transfers" + ); +} + +#[test] +fn block_rejects_pairing_and_invitation_handshake_unblock_restores_neither() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + let alice_id = alice.core.status().endpoint_id.clone(); + reach_saved(&alice, &bob, 90_030); + + bob.core.block_device(alice_id.clone()).unwrap(); + assert_eq!( + bob.core.list_blocked_devices().unwrap(), + vec![alice_id.clone()] + ); + assert!(bob.core.list_saved_devices().unwrap().is_empty()); + assert!(bob.core.list_device_relationships().unwrap().is_empty()); + + // Outbound pairing toward a blocked identity is refused locally. + assert!(!bob + .core + .request_saved_device_pairing(alice_id.clone()) + .unwrap()); + + // Invitation handshake from the blocked identity is refused. + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("blocked.txt"); + std::fs::write(&source_path, b"blocked").unwrap(); + let share = share_path(&bob.core, &source_path, 90_032); + let receive_result = alice.core.receive( + share.ticket, + output_dir.path().to_string_lossy().into_owned(), + Some("alice".to_string()), + ); + assert!( + receive_result.is_err(), + "blocked endpoint must fail invitation handshake" + ); + + bob.core.unblock_device(alice_id).unwrap(); + assert!(bob.core.list_blocked_devices().unwrap().is_empty()); + assert!( + bob.core.list_saved_devices().unwrap().is_empty(), + "unblock must not restore the relationship" + ); + assert!(bob.core.list_device_relationships().unwrap().is_empty()); +} + +#[test] +fn reinstalled_peer_is_never_merged_by_name_or_metadata() { + let alice = ProtectedNode::new(); + let bob = ProtectedNode::new(); + let charlie = ProtectedNode::new(); + let bob_id = bob.core.status().endpoint_id.clone(); + let charlie_id = charlie.core.status().endpoint_id.clone(); + assert_ne!(bob_id, charlie_id); + + reach_saved(&alice, &bob, 90_040); + // Same display-facing label on a different endpoint identity must not merge. + alice + .core + .set_contact_label(bob_id.clone(), Some("Kitchen Tablet".to_string())) + .ok(); + reach_saved(&alice, &charlie, 90_041); + alice + .core + .set_contact_label(charlie_id.clone(), Some("Kitchen Tablet".to_string())) + .ok(); + + let saved = alice.core.list_saved_devices().unwrap(); + assert_eq!(saved.len(), 2); + let ids: std::collections::HashSet<_> = + saved.into_iter().map(|device| device.endpoint_id).collect(); + assert!(ids.contains(&bob_id)); + assert!(ids.contains(&charlie_id)); + assert_eq!(alice.core.list_device_relationships().unwrap().len(), 2); +}