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.
This commit is contained in:
2026-08-06 16:35:36 +02:00
parent 9fbcf653e8
commit 7aa99304b2
10 changed files with 1200 additions and 13 deletions

View File

@@ -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<String>,
/// Last name the device claimed. Untrusted; never promoted to the label.
pub remote_display_name: Option<String>,
pub last_transfer_at: Option<i64>,
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<String>,
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,

View File

@@ -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};

View File

@@ -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<crate::api::GrantLifetimeSetting> 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,

View File

@@ -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;

192
crates/vnidrop/src/offer.rs Normal file
View File

@@ -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::<OfferProtocol>(&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<OfferProtocol>,
}
impl OfferClient {
pub(crate) async fn deliver_grant(
&self,
grant: DeliverGrant,
) -> Result<GrantDeliveryResponse, irpc::Error> {
self.inner.rpc(grant).await
}
pub(crate) async fn revoke_grant(
&self,
revocation: RevokeGrant,
) -> Result<RevocationResponse, irpc::Error> {
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<i64>,
/// Untrusted display data, shown only after the user consents.
pub(crate) display_name: Option<String>,
}
// 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<ChallengeResponse>)]
RequestChallenge(RequestChallenge),
#[rpc(tx=oneshot::Sender<GrantDeliveryResponse>)]
DeliverGrant(DeliverGrant),
#[rpc(tx=oneshot::Sender<RevocationResponse>)]
RevokeGrant(RevokeGrant),
}

View File

@@ -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<String>,
pub(crate) received_at: i64,
grant: HeldGrant,
}
#[derive(Clone)]
pub(crate) struct PairingService {
contacts: ContactStore,
event_hub: Arc<EventHub>,
/// Keyed by peer endpoint id: one outstanding offer per peer, so a peer
/// cannot flood the prompt queue by reconnecting.
pending: Arc<Mutex<HashMap<String, PendingGrant>>>,
max_pending: usize,
max_metadata_bytes: u64,
lifetime: Arc<Mutex<GrantLifetime>>,
}
impl PairingService {
pub(crate) fn new(
contacts: ContactStore,
event_hub: Arc<EventHub>,
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<PendingGrant> {
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<bool> {
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<IssuedGrant> {
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<String, PendingGrant>, 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);
}
}

View File

@@ -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<Vec<ContactSummary>> {
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<PendingPairing> {
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<Self>,
endpoint_id: String,
display_name: Option<String>,
) -> 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<bool> {
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<Self>, 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<Self>, 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<Vec<String>> {
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<String>,
) -> 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<Self>, endpoint_id: String, revoked: Vec<GrantId>) {
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<EndpointAddr> {
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::<EndpointId>() 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;
}
}
}

View File

@@ -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<Vec<ContactSummary>, 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<PendingPairing> {
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<String>,
) -> 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<bool, VnidropError> {
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<Vec<String>, VnidropError> {
self.block_on(self.inner.list_blocked_contacts())
.map_err(VnidropError::repository)
}
pub fn set_contact_label(
&self,
endpoint_id: String,
label: Option<String>,
) -> 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<Vec<StoredTransfer>, VnidropError> {
self.block_on(self.inner.repository.list_transfers())
.map_err(VnidropError::repository)

View File

@@ -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<EventHub>,
pub(super) approval: ApprovalService,
pub(super) pairing: PairingService,
pub(super) limits: CoreLimits,
pub(super) relay_mode: CoreRelayMode,
pub(super) custom_relay_urls: Vec<RelayUrl>,
@@ -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),

View File

@@ -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"));
}