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