mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
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:
@@ -180,6 +180,8 @@ pub struct CoreLimits {
|
|||||||
pub max_metadata_bytes: u64,
|
pub max_metadata_bytes: u64,
|
||||||
pub max_events: u64,
|
pub max_events: u64,
|
||||||
pub max_pending_approvals: 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 max_concurrent_transfers: u64,
|
||||||
pub event_queue_capacity: u64,
|
pub event_queue_capacity: u64,
|
||||||
}
|
}
|
||||||
@@ -198,6 +200,9 @@ impl Default for CoreLimits {
|
|||||||
max_events: 500,
|
max_events: 500,
|
||||||
// Bound handshake spam / notification pressure on the sender.
|
// Bound handshake spam / notification pressure on the sender.
|
||||||
max_pending_approvals: 64,
|
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,
|
max_concurrent_transfers: 8,
|
||||||
event_queue_capacity: 1_024,
|
event_queue_capacity: 1_024,
|
||||||
}
|
}
|
||||||
@@ -215,6 +220,7 @@ impl CoreLimits {
|
|||||||
("max_metadata_bytes", self.max_metadata_bytes),
|
("max_metadata_bytes", self.max_metadata_bytes),
|
||||||
("max_events", self.max_events),
|
("max_events", self.max_events),
|
||||||
("max_pending_approvals", self.max_pending_approvals),
|
("max_pending_approvals", self.max_pending_approvals),
|
||||||
|
("max_pending_offers", self.max_pending_offers),
|
||||||
("max_concurrent_transfers", self.max_concurrent_transfers),
|
("max_concurrent_transfers", self.max_concurrent_transfers),
|
||||||
("event_queue_capacity", self.event_queue_capacity),
|
("event_queue_capacity", self.event_queue_capacity),
|
||||||
];
|
];
|
||||||
@@ -225,6 +231,7 @@ impl CoreLimits {
|
|||||||
}
|
}
|
||||||
for (name, value) in [
|
for (name, value) in [
|
||||||
("max_pending_approvals", self.max_pending_approvals),
|
("max_pending_approvals", self.max_pending_approvals),
|
||||||
|
("max_pending_offers", self.max_pending_offers),
|
||||||
("max_concurrent_transfers", self.max_concurrent_transfers),
|
("max_concurrent_transfers", self.max_concurrent_transfers),
|
||||||
("event_queue_capacity", self.event_queue_capacity),
|
("event_queue_capacity", self.event_queue_capacity),
|
||||||
] {
|
] {
|
||||||
@@ -452,6 +459,42 @@ pub struct TicketInspection {
|
|||||||
pub metadata: TransferMetadata,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||||
pub struct ReceiverRequest {
|
pub struct ReceiverRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
//! tickets: never logged, never emitted in an event, never returned across the
|
//! tickets: never logged, never emitted in an event, never returned across the
|
||||||
//! UniFFI boundary.
|
//! UniFFI boundary.
|
||||||
|
|
||||||
// Exercised only by unit tests until the offer protocol consumes it. Remove
|
// A few operations are exercised only by unit tests until send-to-contact
|
||||||
// this once that lands.
|
// offers consume them. Remove this once that lands.
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
//! This module is pure: no storage, no network, no clock of its own. Callers
|
//! This module is pure: no storage, no network, no clock of its own. Callers
|
||||||
//! supply `now_ms` so expiry and renewal stay testable.
|
//! supply `now_ms` so expiry and renewal stay testable.
|
||||||
|
|
||||||
// Exercised only by unit tests until the contacts repository and the offer
|
// A few helpers are exercised only by unit tests until send-to-contact offers
|
||||||
// protocol consume it. Remove this once those land.
|
// consume them. Remove this once that lands.
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use std::fmt;
|
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.
|
/// Build the proof for a grant this device holds.
|
||||||
pub(crate) fn prove(
|
pub(crate) fn prove(
|
||||||
grant_id: GrantId,
|
grant_id: GrantId,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ mod filesystem;
|
|||||||
mod grant;
|
mod grant;
|
||||||
mod handshake;
|
mod handshake;
|
||||||
mod logging;
|
mod logging;
|
||||||
|
mod offer;
|
||||||
|
mod pairing;
|
||||||
mod repository;
|
mod repository;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
mod secret;
|
mod secret;
|
||||||
@@ -16,11 +18,12 @@ mod transfer_state;
|
|||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
pub use api::{
|
pub use api::{
|
||||||
clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent,
|
clear_inactive_transfer_cache, default_core_limits, default_core_network_config,
|
||||||
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput,
|
ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode,
|
||||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
|
CoreStorageUsage, GrantLifetimeSetting, PendingPairing, PublishedOutput, ReceiveOutputSink,
|
||||||
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
|
ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus,
|
||||||
TicketInspection, TransferAccessMode, TransferMetadata,
|
ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection,
|
||||||
|
TransferAccessMode, TransferMetadata,
|
||||||
};
|
};
|
||||||
pub use error::VnidropError;
|
pub use error::VnidropError;
|
||||||
pub use runtime::VnidropCore;
|
pub use runtime::VnidropCore;
|
||||||
|
|||||||
192
crates/vnidrop/src/offer.rs
Normal file
192
crates/vnidrop/src/offer.rs
Normal 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),
|
||||||
|
}
|
||||||
299
crates/vnidrop/src/pairing.rs
Normal file
299
crates/vnidrop/src/pairing.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
298
crates/vnidrop/src/runtime/contacts.rs
Normal file
298
crates/vnidrop/src/runtime/contacts.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,10 @@ use serde_json::json;
|
|||||||
use super::CoreInner;
|
use super::CoreInner;
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
|
ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
|
||||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus,
|
GrantLifetimeSetting, PendingPairing, ReceiveOutputSink, ReceiveOutputSinkV2,
|
||||||
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
|
ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
|
||||||
TransferAccessMode,
|
ShareSource, StoredTransfer, TicketInspection, TransferAccessMode,
|
||||||
},
|
},
|
||||||
error::VnidropError,
|
error::VnidropError,
|
||||||
filesystem::platform_path,
|
filesystem::platform_path,
|
||||||
@@ -271,6 +271,82 @@ impl VnidropCore {
|
|||||||
.map_err(VnidropError::permission)
|
.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> {
|
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
||||||
self.block_on(self.inner.repository.list_transfers())
|
self.block_on(self.inner.repository.list_transfers())
|
||||||
.map_err(VnidropError::repository)
|
.map_err(VnidropError::repository)
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
//! - [`receive`] — ticket receive, download, export
|
//! - [`receive`] — ticket receive, download, export
|
||||||
//! - [`lifecycle`] — cancel/delete/shutdown/status/access
|
//! - [`lifecycle`] — cancel/delete/shutdown/status/access
|
||||||
//! - [`provider`] — blob provider events and per-connection send progress
|
//! - [`provider`] — blob provider events and per-connection send progress
|
||||||
|
//! - [`contacts`] — device history: pairing, forgetting, blocking
|
||||||
|
|
||||||
|
mod contacts;
|
||||||
mod delivery;
|
mod delivery;
|
||||||
mod facade;
|
mod facade;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
@@ -55,6 +57,8 @@ use crate::{
|
|||||||
event_hub::EventHub,
|
event_hub::EventHub,
|
||||||
handshake::HandshakeService,
|
handshake::HandshakeService,
|
||||||
logging::init_logging,
|
logging::init_logging,
|
||||||
|
offer::OfferService,
|
||||||
|
pairing::PairingService,
|
||||||
repository::Repository,
|
repository::Repository,
|
||||||
secret::load_or_create_secret,
|
secret::load_or_create_secret,
|
||||||
ticket::ticket_matches_relay_profile,
|
ticket::ticket_matches_relay_profile,
|
||||||
@@ -90,6 +94,7 @@ pub(super) struct CoreInner {
|
|||||||
pub(super) repository: Repository,
|
pub(super) repository: Repository,
|
||||||
pub(super) event_hub: Arc<EventHub>,
|
pub(super) event_hub: Arc<EventHub>,
|
||||||
pub(super) approval: ApprovalService,
|
pub(super) approval: ApprovalService,
|
||||||
|
pub(super) pairing: PairingService,
|
||||||
pub(super) limits: CoreLimits,
|
pub(super) limits: CoreLimits,
|
||||||
pub(super) relay_mode: CoreRelayMode,
|
pub(super) relay_mode: CoreRelayMode,
|
||||||
pub(super) custom_relay_urls: Vec<RelayUrl>,
|
pub(super) custom_relay_urls: Vec<RelayUrl>,
|
||||||
@@ -321,9 +326,16 @@ impl CoreInner {
|
|||||||
limits.max_metadata_bytes,
|
limits.max_metadata_bytes,
|
||||||
);
|
);
|
||||||
let handshake = HandshakeService::new(approval.clone());
|
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())
|
let router = Router::builder(endpoint.clone())
|
||||||
.accept(iroh_blobs::ALPN, blobs)
|
.accept(iroh_blobs::ALPN, blobs)
|
||||||
.accept(HandshakeService::ALPN, handshake)
|
.accept(HandshakeService::ALPN, handshake)
|
||||||
|
.accept(OfferService::ALPN, OfferService::new(pairing.clone()))
|
||||||
.spawn();
|
.spawn();
|
||||||
|
|
||||||
let inner = Arc::new(Self {
|
let inner = Arc::new(Self {
|
||||||
@@ -334,6 +346,7 @@ impl CoreInner {
|
|||||||
repository,
|
repository,
|
||||||
event_hub,
|
event_hub,
|
||||||
approval,
|
approval,
|
||||||
|
pairing,
|
||||||
relay_mode,
|
relay_mode,
|
||||||
custom_relay_urls: relay_urls,
|
custom_relay_urls: relay_urls,
|
||||||
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
|
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
|
||||||
|
|||||||
252
crates/vnidrop/tests/pairing.rs
Normal file
252
crates/vnidrop/tests/pairing.rs
Normal 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"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user