From 7aa99304b237fc40a1de42df5bf3c010baae89a3 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:35:36 +0200 Subject: [PATCH] feat(core): add the contacts protocol and grant exchange New /vnidrop/offer/1 ALPN carrying grant delivery and revocation, with a per-connection challenge so a captured proof cannot be replayed onto another connection. Unlike the transfer handshake, this serves nobody without a grant, so an unpaired device cannot raise a prompt on the far side. A delivered grant is never stored on arrival: it waits for the local user's consent, so an unsolicited grant cannot create a contact. Forgetting a contact revokes locally first and notifies the peer best effort. A blocked endpoint is refused indistinguishably from any other refusal. Adds the UniFFI surface for listing, pairing, forgetting, blocking, labels, and grant lifetime. --- crates/vnidrop/src/api.rs | 43 ++++ crates/vnidrop/src/contacts.rs | 4 +- crates/vnidrop/src/grant.rs | 15 +- crates/vnidrop/src/lib.rs | 13 +- crates/vnidrop/src/offer.rs | 192 ++++++++++++++++ crates/vnidrop/src/pairing.rs | 299 +++++++++++++++++++++++++ crates/vnidrop/src/runtime/contacts.rs | 298 ++++++++++++++++++++++++ crates/vnidrop/src/runtime/facade.rs | 84 ++++++- crates/vnidrop/src/runtime/mod.rs | 13 ++ crates/vnidrop/tests/pairing.rs | 252 +++++++++++++++++++++ 10 files changed, 1200 insertions(+), 13 deletions(-) create mode 100644 crates/vnidrop/src/offer.rs create mode 100644 crates/vnidrop/src/pairing.rs create mode 100644 crates/vnidrop/src/runtime/contacts.rs create mode 100644 crates/vnidrop/tests/pairing.rs diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index e387075..2e87d02 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -180,6 +180,8 @@ pub struct CoreLimits { pub max_metadata_bytes: u64, pub max_events: u64, pub max_pending_approvals: u64, + /// Incoming pairing offers awaiting the local user's decision. + pub max_pending_offers: u64, pub max_concurrent_transfers: u64, pub event_queue_capacity: u64, } @@ -198,6 +200,9 @@ impl Default for CoreLimits { max_events: 500, // Bound handshake spam / notification pressure on the sender. max_pending_approvals: 64, + // A pairing prompt needs the user in front of the device, so this + // is far smaller than the handshake queue. + max_pending_offers: 16, max_concurrent_transfers: 8, event_queue_capacity: 1_024, } @@ -215,6 +220,7 @@ impl CoreLimits { ("max_metadata_bytes", self.max_metadata_bytes), ("max_events", self.max_events), ("max_pending_approvals", self.max_pending_approvals), + ("max_pending_offers", self.max_pending_offers), ("max_concurrent_transfers", self.max_concurrent_transfers), ("event_queue_capacity", self.event_queue_capacity), ]; @@ -225,6 +231,7 @@ impl CoreLimits { } for (name, value) in [ ("max_pending_approvals", self.max_pending_approvals), + ("max_pending_offers", self.max_pending_offers), ("max_concurrent_transfers", self.max_concurrent_transfers), ("event_queue_capacity", self.event_queue_capacity), ] { @@ -452,6 +459,42 @@ pub struct TicketInspection { pub metadata: TransferMetadata, } +/// A device the user has chosen to remember. +/// +/// Deliberately carries no grant material: capabilities never cross the UniFFI +/// boundary, only the fact that one exists (`can_send`). +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct ContactSummary { + pub endpoint_id: String, + /// Set locally by the user. Authoritative for display. + pub local_label: Option, + /// Last name the device claimed. Untrusted; never promoted to the label. + pub remote_display_name: Option, + pub last_transfer_at: Option, + pub created_at: i64, + /// Whether this device can currently be sent to, i.e. a live grant is held. + /// False after the peer revoked, expired, or reinstalled. + pub can_send: bool, +} + +/// A device offering to be remembered, awaiting the local user's decision. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct PendingPairing { + pub endpoint_id: String, + pub display_name: Option, + pub received_at: i64, +} + +/// How long a grant survives without use, renewed on every accepted proof. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum GrantLifetimeSetting { + Days30, + #[default] + Days90, + Days365, + Never, +} + #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct ReceiverRequest { pub id: String, diff --git a/crates/vnidrop/src/contacts.rs b/crates/vnidrop/src/contacts.rs index 5f671fc..1baea1b 100644 --- a/crates/vnidrop/src/contacts.rs +++ b/crates/vnidrop/src/contacts.rs @@ -8,8 +8,8 @@ //! tickets: never logged, never emitted in an event, never returned across the //! UniFFI boundary. -// Exercised only by unit tests until the offer protocol consumes it. Remove -// this once that lands. +// A few operations are exercised only by unit tests until send-to-contact +// offers consume them. Remove this once that lands. #![allow(dead_code)] use anyhow::{Context, Result}; diff --git a/crates/vnidrop/src/grant.rs b/crates/vnidrop/src/grant.rs index 57b1827..68341fd 100644 --- a/crates/vnidrop/src/grant.rs +++ b/crates/vnidrop/src/grant.rs @@ -9,8 +9,8 @@ //! This module is pure: no storage, no network, no clock of its own. Callers //! supply `now_ms` so expiry and renewal stay testable. -// Exercised only by unit tests until the contacts repository and the offer -// protocol consume it. Remove this once those land. +// A few helpers are exercised only by unit tests until send-to-contact offers +// consume them. Remove this once that lands. #![allow(dead_code)] use std::fmt; @@ -291,6 +291,17 @@ impl Default for GrantLifetime { } } +impl From for GrantLifetime { + fn from(setting: crate::api::GrantLifetimeSetting) -> Self { + match setting { + crate::api::GrantLifetimeSetting::Days30 => Self::Days(30), + crate::api::GrantLifetimeSetting::Days90 => Self::Days(90), + crate::api::GrantLifetimeSetting::Days365 => Self::Days(365), + crate::api::GrantLifetimeSetting::Never => Self::Never, + } + } +} + /// Build the proof for a grant this device holds. pub(crate) fn prove( grant_id: GrantId, diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 7f2e163..da4ee95 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -8,6 +8,8 @@ mod filesystem; mod grant; mod handshake; mod logging; +mod offer; +mod pairing; mod repository; mod runtime; mod secret; @@ -16,11 +18,12 @@ mod transfer_state; mod util; pub use api::{ - clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent, - CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput, - ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, - RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, - TicketInspection, TransferAccessMode, TransferMetadata, + clear_inactive_transfer_cache, default_core_limits, default_core_network_config, + ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, + CoreStorageUsage, GrantLifetimeSetting, PendingPairing, PublishedOutput, ReceiveOutputSink, + ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, + ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection, + TransferAccessMode, TransferMetadata, }; pub use error::VnidropError; pub use runtime::VnidropCore; diff --git a/crates/vnidrop/src/offer.rs b/crates/vnidrop/src/offer.rs new file mode 100644 index 0000000..1c4f601 --- /dev/null +++ b/crates/vnidrop/src/offer.rs @@ -0,0 +1,192 @@ +//! The contacts protocol: how paired devices reach each other directly. +//! +//! Separate ALPN from the transfer handshake because the trust model differs. +//! `/vnidrop/handshake/2` serves anyone holding a ticket, subject to sender +//! approval. This one serves nobody without a grant (see [`crate::grant`]), so +//! an unpaired device cannot even raise a prompt on the far side. +//! +//! Every request except grant delivery carries a proof over a challenge this +//! connection issued, so a captured proof cannot be replayed onto another +//! connection. + +use std::fmt; + +use anyhow::Result; +use iroh::{ + endpoint::Connection, + protocol::{AcceptError, ProtocolHandler}, + Endpoint, EndpointAddr, +}; +use irpc::{channel::oneshot, rpc_requests, Client, WithChannels}; +use irpc_iroh::{read_request, IrohLazyRemoteConnection}; +use serde::{Deserialize, Serialize}; + +use crate::{ + grant::{Challenge, GrantId}, + pairing::PairingService, +}; + +#[derive(Clone)] +pub(crate) struct OfferService { + pairing: PairingService, +} + +impl fmt::Debug for OfferService { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("OfferService") + } +} + +impl OfferService { + pub(crate) const ALPN: &'static [u8] = b"/vnidrop/offer/1"; + + pub(crate) fn new(pairing: PairingService) -> Self { + Self { pairing } + } + + pub(crate) fn client(endpoint: Endpoint, addr: EndpointAddr) -> OfferClient { + OfferClient { + inner: Client::boxed(IrohLazyRemoteConnection::new( + endpoint, + addr, + Self::ALPN.to_vec(), + )), + } + } +} + +impl ProtocolHandler for OfferService { + /// Accepts inbound connections from paired peers. + /// + /// The challenge is per connection and never leaves this scope, which is + /// what binds a proof to one session: a proof captured from an earlier + /// connection cannot be presented on a later one. + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + let remote_endpoint_id = connection.remote_id().to_string(); + let challenge = Challenge::generate(); + + while let Some(message) = read_request::(&connection).await? { + match message { + OfferMessage::RequestChallenge(message) => { + let WithChannels { tx, .. } = message; + let _ = tx + .send(ChallengeResponse { + challenge: challenge.clone(), + }) + .await; + } + OfferMessage::DeliverGrant(message) => { + let WithChannels { inner, tx, .. } = message; + let response = self + .pairing + .receive_grant(remote_endpoint_id.clone(), inner) + .await; + let _ = tx.send(response).await; + } + OfferMessage::RevokeGrant(message) => { + let WithChannels { inner, tx, .. } = message; + let response = self + .pairing + .receive_revocation(remote_endpoint_id.clone(), inner) + .await; + let _ = tx.send(response).await; + } + } + } + + connection.closed().await; + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct OfferClient { + inner: Client, +} + +impl OfferClient { + pub(crate) async fn deliver_grant( + &self, + grant: DeliverGrant, + ) -> Result { + self.inner.rpc(grant).await + } + + pub(crate) async fn revoke_grant( + &self, + revocation: RevokeGrant, + ) -> Result { + self.inner.rpc(revocation).await + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RequestChallenge; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ChallengeResponse { + pub(crate) challenge: Challenge, +} + +/// Hand a peer the capability to reach this device. +/// +/// Carries the secret itself, which is safe only because the iroh connection is +/// already authenticated and encrypted to the recipient's endpoint key. The +/// recipient still has to consent before it is stored. +#[derive(Clone, Serialize, Deserialize)] +pub(crate) struct DeliverGrant { + pub(crate) grant_id: GrantId, + /// Hex-encoded grant secret. + pub(crate) secret: String, + pub(crate) expires_at: Option, + /// Untrusted display data, shown only after the user consents. + pub(crate) display_name: Option, +} + +// The secret must not reach a log line through a derived Debug. +impl fmt::Debug for DeliverGrant { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DeliverGrant") + .field("grant_id", &self.grant_id) + .field("display_name", &self.display_name) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) enum GrantDeliveryResponse { + /// Held pending the local user's decision. Not yet a contact. + AwaitingConsent, + /// Stored: the local user had already agreed to remember this device. + Stored, + Rejected { + reason: String, + }, +} + +/// Tell a peer that a grant it holds is dead, so its entry disappears promptly +/// rather than at its next attempt. Best effort: revocation is already complete +/// on the issuing side before this is sent. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RevokeGrant { + pub(crate) grant_id: GrantId, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) enum RevocationResponse { + Removed, + /// No such grant held from this peer. Also returned when the grant belongs + /// to someone else, so a stranger cannot probe for grant ids. + Unknown, +} + +#[rpc_requests(message = OfferMessage)] +#[derive(Debug, Serialize, Deserialize)] +enum OfferProtocol { + #[rpc(tx=oneshot::Sender)] + RequestChallenge(RequestChallenge), + #[rpc(tx=oneshot::Sender)] + DeliverGrant(DeliverGrant), + #[rpc(tx=oneshot::Sender)] + RevokeGrant(RevokeGrant), +} diff --git a/crates/vnidrop/src/pairing.rs b/crates/vnidrop/src/pairing.rs new file mode 100644 index 0000000..cb5f352 --- /dev/null +++ b/crates/vnidrop/src/pairing.rs @@ -0,0 +1,299 @@ +//! Consent and grant exchange for device history. +//! +//! Mirrors [`crate::approval`]: the protocol handler stays thin and the +//! decisions live here. The rule this module exists to enforce is that a device +//! is remembered only if *both* sides agree — refusing to issue a grant leaves +//! the peer with a contact entry that cannot do anything. + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use serde_json::json; +use tokio::sync::Mutex; + +use crate::{ + contacts::ContactStore, + event_hub::EventHub, + grant::{GrantLifetime, GrantSecret, HeldGrant, IssuedGrant}, + offer::{DeliverGrant, GrantDeliveryResponse, RevocationResponse, RevokeGrant}, + util::now_ms, +}; + +/// How long an incoming grant waits for the local user's decision. +/// +/// Bounded so a peer cannot park entries in memory indefinitely, and short +/// enough that a stale prompt does not outlive the context the user remembers. +const CONSENT_WINDOW: Duration = Duration::from_secs(10 * 60); + +/// A grant a peer has offered, waiting on the local user. +/// +/// Not persisted: if the app restarts, the prompt is gone and the peer can +/// offer again. Persisting would resurrect prompts whose context the user has +/// long forgotten. +#[derive(Debug, Clone)] +pub(crate) struct PendingGrant { + pub(crate) peer_endpoint_id: String, + pub(crate) display_name: Option, + pub(crate) received_at: i64, + grant: HeldGrant, +} + +#[derive(Clone)] +pub(crate) struct PairingService { + contacts: ContactStore, + event_hub: Arc, + /// Keyed by peer endpoint id: one outstanding offer per peer, so a peer + /// cannot flood the prompt queue by reconnecting. + pending: Arc>>, + max_pending: usize, + max_metadata_bytes: u64, + lifetime: Arc>, +} + +impl PairingService { + pub(crate) fn new( + contacts: ContactStore, + event_hub: Arc, + max_pending: usize, + max_metadata_bytes: u64, + ) -> Self { + Self { + contacts, + event_hub, + pending: Arc::new(Mutex::new(HashMap::new())), + max_pending, + max_metadata_bytes, + lifetime: Arc::new(Mutex::new(GrantLifetime::default())), + } + } + + pub(crate) async fn set_grant_lifetime(&self, lifetime: GrantLifetime) { + *self.lifetime.lock().await = lifetime; + } + + pub(crate) async fn grant_lifetime(&self) -> GrantLifetime { + *self.lifetime.lock().await + } + + // -- inbound ---------------------------------------------------------- + + /// A peer offers this device the capability to reach it. + /// + /// Never stored on arrival: an unsolicited grant would otherwise create a + /// contact the local user never agreed to. It waits for consent instead. + pub(crate) async fn receive_grant( + &self, + peer_endpoint_id: String, + delivery: DeliverGrant, + ) -> GrantDeliveryResponse { + if self + .contacts + .is_blocked(&peer_endpoint_id) + .await + .unwrap_or(false) + { + // Indistinguishable from any other refusal: blocking must not be + // detectable by probing. + return GrantDeliveryResponse::Rejected { + reason: "not-accepted".to_string(), + }; + } + if delivery + .display_name + .as_deref() + .is_some_and(|name| name.len() as u64 > self.max_metadata_bytes) + { + return GrantDeliveryResponse::Rejected { + reason: "metadata-too-large".to_string(), + }; + } + let secret = match GrantSecret::decode(&delivery.secret) { + Ok(secret) => secret, + Err(_) => { + return GrantDeliveryResponse::Rejected { + reason: "malformed-grant".to_string(), + } + } + }; + + let now = now_ms(); + let held = HeldGrant { + grant_id: delivery.grant_id, + secret, + peer_endpoint_id: peer_endpoint_id.clone(), + created_at: now, + expires_at: delivery.expires_at, + }; + + // Already a contact: the user agreed to this relationship, so a refreshed + // grant (re-pairing, or a renewal after reinstall) replaces the old one + // without prompting again. + let already_known = self + .contacts + .find_contact(&peer_endpoint_id) + .await + .ok() + .flatten() + .is_some(); + if already_known { + if self.contacts.insert_held_grant(&held).await.is_err() { + return GrantDeliveryResponse::Rejected { + reason: "storage-error".to_string(), + }; + } + self.emit( + "grant-refreshed", + json!({ "peer_endpoint_id": peer_endpoint_id }), + ); + return GrantDeliveryResponse::Stored; + } + + let mut pending = self.pending.lock().await; + self.drop_expired(&mut pending, now); + if !pending.contains_key(&peer_endpoint_id) && pending.len() >= self.max_pending { + drop(pending); + return GrantDeliveryResponse::Rejected { + reason: "too-many-pending".to_string(), + }; + } + pending.insert( + peer_endpoint_id.clone(), + PendingGrant { + peer_endpoint_id: peer_endpoint_id.clone(), + display_name: delivery.display_name.clone(), + received_at: now, + grant: held, + }, + ); + drop(pending); + + self.emit( + "pairing-requested", + json!({ + "peer_endpoint_id": peer_endpoint_id, + "display_name": delivery.display_name, + }), + ); + GrantDeliveryResponse::AwaitingConsent + } + + /// A peer reports that a grant this device holds is dead. + /// + /// Only the issuer may retire its own grant, so the held record must name + /// this peer. A mismatch answers `Unknown` rather than an error, so a + /// stranger cannot probe for grant ids belonging to someone else. + pub(crate) async fn receive_revocation( + &self, + peer_endpoint_id: String, + revocation: RevokeGrant, + ) -> RevocationResponse { + let held = self + .contacts + .held_grant_for(&peer_endpoint_id) + .await + .ok() + .flatten(); + let Some(held) = held else { + return RevocationResponse::Unknown; + }; + if held.grant_id != revocation.grant_id { + return RevocationResponse::Unknown; + } + if self + .contacts + .delete_held_grant(revocation.grant_id) + .await + .is_err() + { + return RevocationResponse::Unknown; + } + self.emit( + "contact-revoked-by-peer", + json!({ "peer_endpoint_id": peer_endpoint_id }), + ); + RevocationResponse::Removed + } + + // -- local decisions -------------------------------------------------- + + pub(crate) async fn list_pending_grants(&self) -> Vec { + let mut pending = self.pending.lock().await; + self.drop_expired(&mut pending, now_ms()); + pending.values().cloned().collect() + } + + /// Accept a peer's offer to be remembered. + /// + /// Stores their grant and records the contact. Issuing our own grant in + /// return is a separate decision the caller makes, because "I want to reach + /// them" and "they may reach me" are independent. + pub(crate) async fn accept_pending_grant( + &self, + peer_endpoint_id: &str, + ) -> anyhow::Result { + let pending = { + let mut pending = self.pending.lock().await; + self.drop_expired(&mut pending, now_ms()); + pending.remove(peer_endpoint_id) + }; + let Some(pending) = pending else { + return Ok(false); + }; + + self.contacts + .upsert_contact( + peer_endpoint_id, + pending.display_name.as_deref(), + pending.received_at, + ) + .await?; + self.contacts.insert_held_grant(&pending.grant).await?; + self.emit( + "contact-added", + json!({ "peer_endpoint_id": peer_endpoint_id }), + ); + Ok(true) + } + + /// Decline to be reachable through this peer's grant. The grant is dropped + /// unstored, so nothing about the peer is retained. + pub(crate) async fn decline_pending_grant(&self, peer_endpoint_id: &str) -> bool { + let removed = { + let mut pending = self.pending.lock().await; + pending.remove(peer_endpoint_id).is_some() + }; + if removed { + self.emit( + "pairing-declined", + json!({ "peer_endpoint_id": peer_endpoint_id }), + ); + } + removed + } + + /// Mint a grant for a peer: our consent to be reached by them. + /// + /// The caller delivers it over the offer protocol. Persisted before + /// delivery so a grant we may already have handed over is never forgotten. + pub(crate) async fn issue_grant(&self, peer_endpoint_id: &str) -> anyhow::Result { + let lifetime = self.grant_lifetime().await; + let grant = IssuedGrant::mint(peer_endpoint_id.to_string(), now_ms(), lifetime); + self.contacts.insert_issued_grant(&grant).await?; + self.contacts + .upsert_contact(peer_endpoint_id, None, now_ms()) + .await?; + self.emit( + "grant-issued", + json!({ "peer_endpoint_id": peer_endpoint_id }), + ); + Ok(grant) + } + + fn drop_expired(&self, pending: &mut HashMap, now_ms: i64) { + let window = CONSENT_WINDOW.as_millis() as i64; + pending.retain(|_, entry| now_ms - entry.received_at < window); + } + + fn emit(&self, kind: &str, data: serde_json::Value) { + self.event_hub.emit_endpoint("contacts", kind, data); + } +} diff --git a/crates/vnidrop/src/runtime/contacts.rs b/crates/vnidrop/src/runtime/contacts.rs new file mode 100644 index 0000000..cd736dc --- /dev/null +++ b/crates/vnidrop/src/runtime/contacts.rs @@ -0,0 +1,298 @@ +//! Runtime operations for device history: pairing, forgetting, and blocking. +//! +//! The protocol side lives in [`crate::offer`] and the decisions in +//! [`crate::pairing`]; this is where those meet the endpoint and the UniFFI +//! surface. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use iroh::{EndpointAddr, EndpointId}; +use serde_json::json; + +use super::CoreInner; +use crate::{ + api::{ContactSummary, GrantLifetimeSetting, PendingPairing}, + error::VnidropError, + grant::GrantId, + offer::{DeliverGrant, GrantDeliveryResponse, OfferService, RevokeGrant}, + ticket::{encode_persisted_sender_address, parse_persisted_sender_address}, + util::now_ms, +}; + +impl CoreInner { + pub(super) async fn list_contacts(&self) -> Result> { + let contacts = self + .repository + .contacts() + .list_contacts() + .await + .map_err(VnidropError::repository)?; + let store = self.repository.contacts(); + let mut summaries = Vec::with_capacity(contacts.len()); + for contact in contacts { + // "Can I reach them" is exactly "do I hold a live grant", so the two + // never drift apart in the UI. + let can_send = store + .held_grant_for(&contact.endpoint_id) + .await + .map_err(VnidropError::repository)? + .is_some(); + summaries.push(ContactSummary { + endpoint_id: contact.endpoint_id, + local_label: contact.local_label, + remote_display_name: contact.remote_display_name, + last_transfer_at: contact.last_transfer_at, + created_at: contact.created_at, + can_send, + }); + } + Ok(summaries) + } + + pub(super) async fn list_pending_pairings(&self) -> Vec { + self.pairing + .list_pending_grants() + .await + .into_iter() + .map(|pending| PendingPairing { + endpoint_id: pending.peer_endpoint_id, + display_name: pending.display_name, + received_at: pending.received_at, + }) + .collect() + } + + /// Agree to be remembered by a peer, and hand them the capability to reach + /// us. + /// + /// The grant is persisted before delivery: a grant that may already have + /// arrived must never be one we have forgotten issuing, or the peer would + /// hold a capability we cannot validate or revoke. + pub(super) async fn allow_device_to_reach_me( + self: &Arc, + endpoint_id: String, + display_name: Option, + ) -> Result<()> { + self.limits + .validate_metadata_text("display name", display_name.as_deref()) + .map_err(VnidropError::invalid_input)?; + if self + .repository + .contacts() + .is_blocked(&endpoint_id) + .await + .map_err(VnidropError::repository)? + { + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "endpoint is blocked; unblock it before pairing" + )) + .into()); + } + + let grant = self + .pairing + .issue_grant(&endpoint_id) + .await + .map_err(VnidropError::repository)?; + + let addr = self.contact_addr(&endpoint_id).await?; + let client = OfferService::client(self.endpoint.clone(), addr); + let response = client + .deliver_grant(DeliverGrant { + grant_id: grant.grant_id, + secret: grant.secret.encode(), + expires_at: grant.expires_at, + display_name, + }) + .await + .context("failed to deliver grant") + .map_err(VnidropError::transfer)?; + + match response { + GrantDeliveryResponse::AwaitingConsent | GrantDeliveryResponse::Stored => { + self.remember_addr(&endpoint_id).await; + self.emit_endpoint( + "contacts", + "grant-delivered", + json!({ "peer_endpoint_id": endpoint_id }), + ); + Ok(()) + } + GrantDeliveryResponse::Rejected { reason } => { + // The peer would not take it, so the grant we just minted can + // never be used. Retire it rather than leaving a live + // capability nobody holds. + let _ = self + .repository + .contacts() + .revoke_issued_grant(grant.grant_id, now_ms()) + .await; + Err( + VnidropError::transfer(anyhow::anyhow!("peer refused the pairing: {reason}")) + .into(), + ) + } + } + } + + pub(super) async fn respond_to_pairing( + &self, + endpoint_id: String, + accepted: bool, + ) -> Result { + if accepted { + self.pairing + .accept_pending_grant(&endpoint_id) + .await + .map_err(VnidropError::repository) + .map_err(Into::into) + } else { + Ok(self.pairing.decline_pending_grant(&endpoint_id).await) + } + } + + /// Stop a peer from reaching us and drop the relationship locally. + /// + /// Revocation completes locally first: the notification is best effort and + /// the peer losing access must not depend on being online to hear about it. + pub(super) async fn forget_contact(self: &Arc, endpoint_id: String) -> Result<()> { + let store = self.repository.contacts(); + let revoked = store + .delete_contact(&endpoint_id) + .await + .map_err(VnidropError::repository)?; + self.emit_endpoint( + "contacts", + "contact-forgotten", + json!({ "peer_endpoint_id": endpoint_id, "revoked": revoked.len() }), + ); + self.notify_revoked(endpoint_id, revoked).await; + Ok(()) + } + + pub(super) async fn block_contact(self: &Arc, endpoint_id: String) -> Result<()> { + let store = self.repository.contacts(); + let revoked = store + .revoke_issued_grants_for(&endpoint_id, now_ms()) + .await + .map_err(VnidropError::repository)?; + store + .block_endpoint(&endpoint_id, now_ms()) + .await + .map_err(VnidropError::repository)?; + store + .delete_contact(&endpoint_id) + .await + .map_err(VnidropError::repository)?; + self.emit_endpoint( + "contacts", + "contact-blocked", + json!({ "peer_endpoint_id": endpoint_id }), + ); + // A blocked peer is told nothing: silence here is what makes blocking + // undetectable, unlike ordinary revocation. + let _ = revoked; + Ok(()) + } + + pub(super) async fn unblock_contact(&self, endpoint_id: String) -> Result<()> { + self.repository + .contacts() + .unblock_endpoint(&endpoint_id) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(super) async fn list_blocked_contacts(&self) -> Result> { + self.repository + .contacts() + .list_blocked() + .await + .map_err(VnidropError::repository) + .map_err(Into::into) + } + + pub(super) async fn set_contact_label( + &self, + endpoint_id: String, + label: Option, + ) -> Result<()> { + self.limits + .validate_metadata_text("contact label", label.as_deref()) + .map_err(VnidropError::invalid_input)?; + self.repository + .contacts() + .set_contact_label(&endpoint_id, label.as_deref()) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(super) async fn set_grant_lifetime(&self, setting: GrantLifetimeSetting) { + self.pairing.set_grant_lifetime(setting.into()).await; + } + + /// Best-effort "your entry is dead" notification, so the peer's list clears + /// promptly instead of at its next attempt. + async fn notify_revoked(self: &Arc, endpoint_id: String, revoked: Vec) { + if revoked.is_empty() { + return; + } + let Ok(addr) = self.contact_addr(&endpoint_id).await else { + return; + }; + let client = OfferService::client(self.endpoint.clone(), addr); + for grant_id in revoked { + if let Err(error) = client.revoke_grant(RevokeGrant { grant_id }).await { + tracing::debug!(%error, "revocation notice undeliverable; peer will learn on next attempt"); + return; + } + } + } + + /// Where to dial a contact. + /// + /// Prefers the address cached from the last successful connection, which is + /// what keeps contacts usable in relay profiles that do not resolve + /// endpoint ids through public discovery. + async fn contact_addr(&self, endpoint_id: &str) -> Result { + let cached = self + .repository + .contacts() + .find_contact(endpoint_id) + .await + .ok() + .flatten() + .and_then(|contact| contact.last_known_addr) + .and_then(|encoded| parse_persisted_sender_address(&encoded).ok()); + if let Some(addr) = cached { + return Ok(addr); + } + let parsed: EndpointId = endpoint_id + .parse() + .context("contact has an unusable endpoint id") + .map_err(VnidropError::invalid_input)?; + Ok(EndpointAddr::from(parsed)) + } + + /// Refresh the cached address after a successful exchange. + async fn remember_addr(&self, endpoint_id: &str) { + let Ok(parsed) = endpoint_id.parse::() else { + return; + }; + let Some(info) = self.endpoint.remote_info(parsed).await else { + return; + }; + let mut addr = EndpointAddr::from(parsed); + addr.addrs = info.addrs().map(|entry| entry.addr().clone()).collect(); + if let Ok(encoded) = encode_persisted_sender_address(&addr) { + let _ = self + .repository + .contacts() + .set_last_known_addr(endpoint_id, &encoded) + .await; + } + } +} diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index ea8f30a..5a80bd1 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -6,10 +6,10 @@ use serde_json::json; use super::CoreInner; use crate::{ api::{ - CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage, - ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, - ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection, - TransferAccessMode, + ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage, + GrantLifetimeSetting, PendingPairing, ReceiveOutputSink, ReceiveOutputSinkV2, + ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, + ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, }, error::VnidropError, filesystem::platform_path, @@ -271,6 +271,82 @@ impl VnidropCore { .map_err(VnidropError::permission) } + /// Devices the user has chosen to remember. + pub fn list_contacts(&self) -> Result, VnidropError> { + self.block_on(self.inner.list_contacts()) + .map_err(VnidropError::repository) + } + + /// Devices offering to be remembered, awaiting the local user's decision. + pub fn list_pending_pairings(&self) -> Vec { + self.block_on(self.inner.list_pending_pairings()) + } + + /// Agree to be reachable by a device, handing it a revocable capability. + /// + /// Independent of whether that device agrees to be reachable by us: each + /// direction is a separate decision. + pub fn allow_device_to_reach_me( + &self, + endpoint_id: String, + display_name: Option, + ) -> Result<(), VnidropError> { + self.block_on( + self.inner + .allow_device_to_reach_me(endpoint_id, display_name), + ) + .map_err(VnidropError::transfer) + } + + /// Accept or decline a device's offer to be remembered. Returns false when + /// the offer already lapsed. + pub fn respond_to_pairing( + &self, + endpoint_id: String, + accepted: bool, + ) -> Result { + self.block_on(self.inner.respond_to_pairing(endpoint_id, accepted)) + .map_err(VnidropError::repository) + } + + /// Forget a device and revoke its access. Takes effect locally at once; the + /// peer is notified best effort. + pub fn forget_contact(&self, endpoint_id: String) -> Result<(), VnidropError> { + self.block_on(self.inner.forget_contact(endpoint_id)) + .map_err(VnidropError::repository) + } + + /// Refuse a device outright. Unlike forgetting, the peer is told nothing. + pub fn block_contact(&self, endpoint_id: String) -> Result<(), VnidropError> { + self.block_on(self.inner.block_contact(endpoint_id)) + .map_err(VnidropError::repository) + } + + pub fn unblock_contact(&self, endpoint_id: String) -> Result<(), VnidropError> { + self.block_on(self.inner.unblock_contact(endpoint_id)) + .map_err(VnidropError::repository) + } + + pub fn list_blocked_contacts(&self) -> Result, VnidropError> { + self.block_on(self.inner.list_blocked_contacts()) + .map_err(VnidropError::repository) + } + + pub fn set_contact_label( + &self, + endpoint_id: String, + label: Option, + ) -> Result<(), VnidropError> { + self.block_on(self.inner.set_contact_label(endpoint_id, label)) + .map_err(VnidropError::repository) + } + + /// Idle lifetime applied to grants issued from now on. Existing grants keep + /// the lifetime they were issued with until they next renew. + pub fn set_grant_lifetime(&self, lifetime: GrantLifetimeSetting) { + self.block_on(self.inner.set_grant_lifetime(lifetime)); + } + pub fn list_transfers(&self) -> Result, VnidropError> { self.block_on(self.inner.repository.list_transfers()) .map_err(VnidropError::repository) diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index cb13930..90c2950 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -6,7 +6,9 @@ //! - [`receive`] — ticket receive, download, export //! - [`lifecycle`] — cancel/delete/shutdown/status/access //! - [`provider`] — blob provider events and per-connection send progress +//! - [`contacts`] — device history: pairing, forgetting, blocking +mod contacts; mod delivery; mod facade; mod lifecycle; @@ -55,6 +57,8 @@ use crate::{ event_hub::EventHub, handshake::HandshakeService, logging::init_logging, + offer::OfferService, + pairing::PairingService, repository::Repository, secret::load_or_create_secret, ticket::ticket_matches_relay_profile, @@ -90,6 +94,7 @@ pub(super) struct CoreInner { pub(super) repository: Repository, pub(super) event_hub: Arc, pub(super) approval: ApprovalService, + pub(super) pairing: PairingService, pub(super) limits: CoreLimits, pub(super) relay_mode: CoreRelayMode, pub(super) custom_relay_urls: Vec, @@ -321,9 +326,16 @@ impl CoreInner { limits.max_metadata_bytes, ); let handshake = HandshakeService::new(approval.clone()); + let pairing = PairingService::new( + repository.contacts(), + event_hub.clone(), + limits.max_pending_offers as usize, + limits.max_metadata_bytes, + ); let router = Router::builder(endpoint.clone()) .accept(iroh_blobs::ALPN, blobs) .accept(HandshakeService::ALPN, handshake) + .accept(OfferService::ALPN, OfferService::new(pairing.clone())) .spawn(); let inner = Arc::new(Self { @@ -334,6 +346,7 @@ impl CoreInner { repository, event_hub, approval, + pairing, relay_mode, custom_relay_urls: relay_urls, transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize), diff --git a/crates/vnidrop/tests/pairing.rs b/crates/vnidrop/tests/pairing.rs new file mode 100644 index 0000000..745ecef --- /dev/null +++ b/crates/vnidrop/tests/pairing.rs @@ -0,0 +1,252 @@ +//! Device history pairing over the offer ALPN, between two real nodes. + +mod support; + +use std::time::{Duration, Instant}; + +use support::TestNode; +use vnidrop::VnidropCore; + +fn endpoint_id(node: &TestNode) -> String { + node.core.status().endpoint_id +} + +/// The pairing prompt arrives asynchronously on the peer's side. +fn wait_for_pending_pairing(core: &VnidropCore, from_endpoint: &str) { + let started = Instant::now(); + loop { + if core + .list_pending_pairings() + .iter() + .any(|pending| pending.endpoint_id == from_endpoint) + { + return; + } + assert!( + started.elapsed() < Duration::from_secs(10), + "pairing offer from {from_endpoint} never surfaced" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +/// Alice agrees to be reachable by Bob; Bob consents; Bob can now reach Alice. +#[test] +fn a_delivered_grant_becomes_a_contact_only_after_the_peer_consents() { + let alice = TestNode::new(); + let bob = TestNode::new(); + let bob_id = endpoint_id(&bob); + let alice_id = endpoint_id(&alice); + + alice + .core + .allow_device_to_reach_me(bob_id.clone(), Some("Alice Laptop".to_string())) + .expect("grant delivered"); + + // Delivery alone must not create a contact: Bob has not agreed yet. + wait_for_pending_pairing(&bob.core, &alice_id); + assert!( + bob.core.list_contacts().unwrap().is_empty(), + "an undelivered-consent grant must not appear as a contact" + ); + + assert!(bob + .core + .respond_to_pairing(alice_id.clone(), true) + .expect("consent recorded")); + + let contacts = bob.core.list_contacts().unwrap(); + assert_eq!(contacts.len(), 1); + assert_eq!(contacts[0].endpoint_id, alice_id); + assert!( + contacts[0].can_send, + "holding a live grant is what makes a contact reachable" + ); + assert!(bob.core.list_pending_pairings().is_empty()); +} + +/// Declining leaves nothing behind: no contact, no stored capability. +#[test] +fn declining_a_pairing_stores_nothing() { + let alice = TestNode::new(); + let bob = TestNode::new(); + let alice_id = endpoint_id(&alice); + + alice + .core + .allow_device_to_reach_me(endpoint_id(&bob), None) + .expect("grant delivered"); + wait_for_pending_pairing(&bob.core, &alice_id); + + assert!(bob + .core + .respond_to_pairing(alice_id.clone(), false) + .unwrap()); + + assert!(bob.core.list_contacts().unwrap().is_empty()); + assert!(bob.core.list_pending_pairings().is_empty()); + assert!( + !bob.core.respond_to_pairing(alice_id, true).unwrap(), + "a declined offer cannot be accepted afterwards" + ); +} + +/// The pairing is directional: Alice issuing to Bob does not let Alice reach Bob. +#[test] +fn each_direction_is_a_separate_decision() { + let alice = TestNode::new(); + let bob = TestNode::new(); + let alice_id = endpoint_id(&alice); + let bob_id = endpoint_id(&bob); + + alice + .core + .allow_device_to_reach_me(bob_id.clone(), None) + .expect("grant delivered"); + wait_for_pending_pairing(&bob.core, &alice_id); + bob.core.respond_to_pairing(alice_id.clone(), true).unwrap(); + + // Alice recorded Bob as a contact when she issued, but she holds no grant + // from him, so she cannot reach him. + let alice_contacts = alice.core.list_contacts().unwrap(); + assert_eq!(alice_contacts.len(), 1); + assert_eq!(alice_contacts[0].endpoint_id, bob_id); + assert!( + !alice_contacts[0].can_send, + "issuing a grant does not grant the issuer anything in return" + ); +} + +/// Revoking kills the peer's entry without their cooperation, and tells them. +#[test] +fn forgetting_a_contact_revokes_the_peers_access() { + let alice = TestNode::new(); + let bob = TestNode::new(); + let alice_id = endpoint_id(&alice); + let bob_id = endpoint_id(&bob); + + alice + .core + .allow_device_to_reach_me(bob_id.clone(), None) + .expect("grant delivered"); + wait_for_pending_pairing(&bob.core, &alice_id); + bob.core.respond_to_pairing(alice_id.clone(), true).unwrap(); + assert!(bob.core.list_contacts().unwrap()[0].can_send); + + alice.core.forget_contact(bob_id).expect("forgotten"); + + // Best-effort notification: Bob is online, so his dead entry should clear + // promptly rather than at his next attempt. + let started = Instant::now(); + loop { + let contacts = bob.core.list_contacts().unwrap(); + let cleared = contacts.first().is_none_or(|contact| !contact.can_send); + if cleared { + break; + } + assert!( + started.elapsed() < Duration::from_secs(10), + "revocation notice never reached the peer" + ); + std::thread::sleep(Duration::from_millis(25)); + } + assert!(alice.core.list_contacts().unwrap().is_empty()); +} + +/// A blocked device is refused, and cannot tell blocking from any other refusal. +#[test] +fn a_blocked_device_cannot_pair() { + let alice = TestNode::new(); + let bob = TestNode::new(); + let bob_id = endpoint_id(&bob); + + bob.core + .block_contact(endpoint_id(&alice)) + .expect("blocked"); + + let outcome = alice.core.allow_device_to_reach_me(bob_id, None); + + assert!(outcome.is_err(), "a blocked peer must refuse the grant"); + assert!(bob.core.list_pending_pairings().is_empty()); + assert!(bob.core.list_contacts().unwrap().is_empty()); +} + +/// Blocking locally also prevents pairing outward, so the block is symmetric +/// from the user's point of view. +#[test] +fn blocking_prevents_issuing_a_grant_to_that_device() { + let alice = TestNode::new(); + let bob = TestNode::new(); + let bob_id = endpoint_id(&bob); + + alice.core.block_contact(bob_id.clone()).expect("blocked"); + + let outcome = alice.core.allow_device_to_reach_me(bob_id.clone(), None); + assert!(outcome.is_err()); + + alice + .core + .unblock_contact(bob_id.clone()) + .expect("unblocked"); + assert!(alice.core.list_blocked_contacts().unwrap().is_empty()); +} + +/// Re-pairing an existing contact refreshes the grant without a second prompt. +#[test] +fn re_pairing_a_known_contact_does_not_prompt_again() { + let alice = TestNode::new(); + let bob = TestNode::new(); + let alice_id = endpoint_id(&alice); + let bob_id = endpoint_id(&bob); + + alice + .core + .allow_device_to_reach_me(bob_id.clone(), None) + .unwrap(); + wait_for_pending_pairing(&bob.core, &alice_id); + bob.core.respond_to_pairing(alice_id.clone(), true).unwrap(); + + alice + .core + .allow_device_to_reach_me(bob_id, None) + .expect("re-issued"); + + assert!( + bob.core.list_pending_pairings().is_empty(), + "an established contact must not raise a fresh consent prompt" + ); + assert_eq!(bob.core.list_contacts().unwrap().len(), 1); +} + +/// The user's own label survives whatever the remote later calls itself. +#[test] +fn a_local_label_survives_a_remote_rename() { + let alice = TestNode::new(); + let bob = TestNode::new(); + let alice_id = endpoint_id(&alice); + let bob_id = endpoint_id(&bob); + + alice + .core + .allow_device_to_reach_me(bob_id.clone(), Some("Alice Laptop".to_string())) + .unwrap(); + wait_for_pending_pairing(&bob.core, &alice_id); + bob.core.respond_to_pairing(alice_id.clone(), true).unwrap(); + bob.core + .set_contact_label(alice_id.clone(), Some("Work Mac".to_string())) + .unwrap(); + + alice + .core + .allow_device_to_reach_me(bob_id, Some("Totally Not Evil".to_string())) + .unwrap(); + + let contact = bob + .core + .list_contacts() + .unwrap() + .into_iter() + .find(|contact| contact.endpoint_id == alice_id) + .expect("contact"); + assert_eq!(contact.local_label.as_deref(), Some("Work Mac")); +}