feat(core): create pairing eligibility after completed transfers

Add the experimental eligibility control plane so either endpoint of a
fully completed authenticated invitation transfer can start one single-use
pairing attempt within 24 hours, with secrets held in custody and invalid
requests rejected silently.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 02:34:18 +02:00
parent e740942f63
commit 04a31e8a24
15 changed files with 1305 additions and 17 deletions

View File

@@ -27,6 +27,18 @@ pub fn experimental_saved_device_capabilities() -> ExperimentalSavedDeviceCapabi
} }
} }
/// Public view of a single-use pairing window after a completed transfer.
///
/// The eligibility capability itself never crosses this boundary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct PairingEligibilitySummary {
pub peer_endpoint_id: String,
pub session_id: String,
pub protocol_version: u16,
pub created_at: i64,
pub expires_at: i64,
}
/// A remote VniDrop app-installation identity that completed mutual consent. /// A remote VniDrop app-installation identity that completed mutual consent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct SavedDevice { pub struct SavedDevice {

View File

@@ -12,6 +12,7 @@ use crate::{
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
RequestTransfer, RequestTransfer,
}, },
pairing_eligibility::PairingEligibilityService,
repository::{ReceiverRequestInsert, Repository}, repository::{ReceiverRequestInsert, Repository},
transfer_state::ReceiverRequestStatus, transfer_state::ReceiverRequestStatus,
util::now_ms, util::now_ms,
@@ -35,6 +36,7 @@ pub(crate) struct ApprovalService {
pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>, pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>,
max_pending: usize, max_pending: usize,
max_metadata_bytes: u64, max_metadata_bytes: u64,
pairing_eligibility: Option<PairingEligibilityService>,
} }
impl ApprovalService { impl ApprovalService {
@@ -55,6 +57,18 @@ impl ApprovalService {
.await .await
{ {
Ok(()) => { Ok(()) => {
if let Some(eligibility) = &self.pairing_eligibility {
if let Err(error) = eligibility
.activate_after_completed_transfer(
&remote_endpoint_id,
&receipt.request_id,
&receipt.token,
)
.await
{
tracing::warn!(%error, "failed to activate pairing eligibility after delivery");
}
}
self.event_hub.emit_transfer( self.event_hub.emit_transfer(
receipt.transfer_id, receipt.transfer_id,
"send", "send",
@@ -119,6 +133,7 @@ impl ApprovalService {
access_policy: Arc<AccessPolicy>, access_policy: Arc<AccessPolicy>,
max_pending: usize, max_pending: usize,
max_metadata_bytes: u64, max_metadata_bytes: u64,
pairing_eligibility: Option<PairingEligibilityService>,
) -> Self { ) -> Self {
Self { Self {
repository, repository,
@@ -127,6 +142,7 @@ impl ApprovalService {
pending: Arc::new(Mutex::new(HashMap::new())), pending: Arc::new(Mutex::new(HashMap::new())),
max_pending, max_pending,
max_metadata_bytes, max_metadata_bytes,
pairing_eligibility,
} }
} }

View File

@@ -47,6 +47,10 @@ enum EventPhase {
Transfer, Transfer,
/// Delivery receipts from receivers (completed download acknowledgements). /// Delivery receipts from receivers (completed download acknowledgements).
Delivery, Delivery,
/// Saved-device pairing eligibility and consent prompts.
Pairing,
/// Prototype contact lifecycle notifications.
Contacts,
} }
impl EventPhase { impl EventPhase {
@@ -68,6 +72,8 @@ impl EventPhase {
"approval" => Some(Self::Approval), "approval" => Some(Self::Approval),
"transfer" => Some(Self::Transfer), "transfer" => Some(Self::Transfer),
"delivery" => Some(Self::Delivery), "delivery" => Some(Self::Delivery),
"pairing" => Some(Self::Pairing),
"contacts" => Some(Self::Contacts),
_ => None, _ => None,
} }
} }
@@ -90,6 +96,8 @@ impl EventPhase {
Self::Approval => "approval", Self::Approval => "approval",
Self::Transfer => "transfer", Self::Transfer => "transfer",
Self::Delivery => "delivery", Self::Delivery => "delivery",
Self::Pairing => "pairing",
Self::Contacts => "contacts",
} }
} }
} }

View File

@@ -11,6 +11,7 @@ mod logging;
mod offer; mod offer;
mod offer_inbox; mod offer_inbox;
mod pairing; mod pairing;
mod pairing_eligibility;
mod repository; mod repository;
mod runtime; mod runtime;
mod secret; mod secret;
@@ -29,11 +30,11 @@ pub use api::{
experimental_saved_device_capabilities, ContactSendResult, ContactSummary, CoreEvent, experimental_saved_device_capabilities, ContactSendResult, ContactSummary, CoreEvent,
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage,
DeviceRelationship, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, DeviceRelationship, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities,
GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PendingPairing, PublishedOutput, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PairingEligibilitySummary,
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, PendingPairing, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact,
RuntimeStatus, SavedDevice, ShareMetadataInput, ShareResult, ShareSource, SourceKind, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice, ShareMetadataInput,
StoredTransfer, TargetedTransfer, TargetedTransferState, TicketInspection, TransferAccessMode, ShareResult, ShareSource, SourceKind, StoredTransfer, TargetedTransfer, TargetedTransferState,
TransferMetadata, TicketInspection, TransferAccessMode, TransferMetadata,
}; };
pub use error::VnidropError; pub use error::VnidropError;
pub use runtime::VnidropCore; pub use runtime::VnidropCore;

View File

@@ -0,0 +1,338 @@
//! Pairing eligibility after completed authenticated invitation transfers.
//!
//! The capability is derived from the shared approval session token and becomes
//! usable only after the transfer reaches a durable completed state. Public APIs
//! expose eligibility state, never the capability bytes.
use std::collections::HashSet;
use std::sync::Arc;
use serde_json::json;
use crate::{
api::{experimental_saved_device_capabilities, PairingEligibilitySummary},
error::VnidropError,
event_hub::EventHub,
repository::Repository,
secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial},
util::now_ms,
};
const ELIGIBILITY_TTL_MS: i64 = 24 * 60 * 60 * 1_000;
const CAPABILITY_CONTEXT: &str = "vnidrop-pairing-eligibility-v1";
#[derive(Clone)]
pub(crate) struct PairingEligibilityService {
repository: Repository,
custody: Option<Arc<SecretCustody>>,
event_hub: Arc<EventHub>,
local_endpoint_id: String,
}
impl PairingEligibilityService {
pub(crate) fn new(
repository: Repository,
custody: Option<Arc<SecretCustody>>,
event_hub: Arc<EventHub>,
local_endpoint_id: String,
) -> Self {
Self {
repository,
custody,
event_hub,
local_endpoint_id,
}
}
/// Removes orphaned eligibility secrets and rows whose secrets are missing.
pub(crate) async fn reconcile(&self) -> Result<(), VnidropError> {
let records = self.repository.list_pairing_eligibility_records().await?;
let mut referenced = HashSet::new();
for entry in records {
referenced.insert(entry.secret_handle.clone());
let Some(custody) = &self.custody else {
continue;
};
let handle = SecretHandle::from_stored(entry.secret_handle.clone());
if custody.load(&handle).await.is_err() {
self.delete_entry_silent(&entry).await?;
}
}
if let Some(custody) = &self.custody {
for handle in custody
.list_active_handles(SecretKind::PairingEligibility)
.await?
{
if !referenced.contains(handle.as_str()) {
let _ = custody.remove(&handle).await;
}
}
}
self.expire_due(true).await
}
pub(crate) async fn list(&self) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
self.expire_due(true).await?;
self.repository.list_pairing_eligibilities().await
}
/// Activates eligibility after a durable completed authenticated transfer.
pub(crate) async fn activate_after_completed_transfer(
&self,
peer_endpoint_id: &str,
session_id: &str,
approval_token: &str,
) -> Result<(), VnidropError> {
let Some(custody) = &self.custody else {
return Ok(());
};
if peer_endpoint_id.is_empty() || session_id.is_empty() || approval_token.is_empty() {
return Ok(());
}
if self
.repository
.find_pairing_eligibility_by_session(session_id)
.await?
.is_some()
{
return Ok(());
}
let protocol_version =
experimental_saved_device_capabilities().relationship_protocol_version;
let capability = derive_capability(
approval_token,
&self.local_endpoint_id,
peer_endpoint_id,
session_id,
protocol_version,
)?;
// Credential custody already stages then activates the secret. Domain
// metadata is written only after that verify; a crash leaves an orphan
// secret that reconcile() removes on the next start.
let handle = custody
.protect(SecretKind::PairingEligibility, capability, None)
.await?;
let created_at = now_ms();
let expires_at = created_at + ELIGIBILITY_TTL_MS;
if let Err(error) = self
.repository
.insert_pairing_eligibility(PairingEligibilityInsert {
peer_endpoint_id,
session_id,
protocol_version,
secret_handle: handle.as_str(),
created_at,
expires_at,
})
.await
{
let _ = custody.remove(&handle).await;
return Err(error);
}
self.event_hub.emit_endpoint(
"pairing",
"eligibility-available",
json!({
"peer_endpoint_id": peer_endpoint_id,
"session_id": session_id,
"protocol_version": protocol_version,
"expires_at": expires_at,
}),
);
Ok(())
}
/// Starts a local pairing attempt when eligibility exists.
///
/// Returns `false` when eligibility is missing/expired (silent reject). A
/// successful start consumes the single-use eligibility for that session.
pub(crate) async fn request_pairing(
&self,
peer_endpoint_id: &str,
) -> Result<bool, VnidropError> {
self.expire_due(true).await?;
let entries = self
.repository
.list_pairing_eligibilities_for_peer(peer_endpoint_id)
.await?;
let Some(entry) = entries.into_iter().next() else {
return Ok(false);
};
if entry.expires_at <= now_ms() {
self.delete_entry_silent(&entry).await?;
return Ok(false);
}
self.delete_entry(&entry).await?;
Ok(true)
}
/// Validates an inbound eligibility presentation without prompts or events on failure.
#[allow(
dead_code,
reason = "inbound pairing wire acceptance lands with mutual-consent ticket 08"
)]
pub(crate) async fn accept_presented_eligibility(
&self,
peer_endpoint_id: &str,
session_id: &str,
capability: &SecretMaterial,
) -> Result<bool, VnidropError> {
let Some(entry) = self
.validate_presented_capability(peer_endpoint_id, session_id, capability)
.await?
else {
return Ok(false);
};
self.delete_entry(&entry).await?;
Ok(true)
}
pub(crate) async fn decline(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> {
self.remove_for_peer(peer_endpoint_id).await
}
pub(crate) async fn remove_for_peer(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> {
let entries = self
.repository
.list_pairing_eligibilities_for_peer(peer_endpoint_id)
.await?;
for entry in entries {
self.delete_entry(&entry).await?;
}
Ok(())
}
pub(crate) async fn remove_all(&self) -> Result<(), VnidropError> {
let entries = self.repository.list_pairing_eligibility_records().await?;
for entry in entries {
self.delete_entry(&entry).await?;
}
Ok(())
}
/// Returns the matching record when the capability is valid; otherwise `None`
/// without emitting prompts or eligibility-removed events for the reject path.
#[allow(
dead_code,
reason = "inbound pairing wire acceptance lands with mutual-consent ticket 08"
)]
pub(crate) async fn validate_presented_capability(
&self,
peer_endpoint_id: &str,
session_id: &str,
capability: &SecretMaterial,
) -> Result<Option<PairingEligibilityRecord>, VnidropError> {
self.expire_due(false).await?;
let Some(custody) = &self.custody else {
return Ok(None);
};
let Some(entry) = self
.repository
.find_pairing_eligibility_by_session(session_id)
.await?
else {
return Ok(None);
};
if entry.peer_endpoint_id != peer_endpoint_id || entry.expires_at <= now_ms() {
return Ok(None);
}
let stored = match custody
.load(&SecretHandle::from_stored(entry.secret_handle.clone()))
.await
{
Ok(material) => material,
Err(_) => return Ok(None),
};
if stored == *capability {
Ok(Some(entry))
} else {
Ok(None)
}
}
async fn expire_due(&self, emit_events: bool) -> Result<(), VnidropError> {
let now = now_ms();
let expired = self
.repository
.list_expired_pairing_eligibilities(now)
.await?;
for entry in expired {
if emit_events {
self.delete_entry(&entry).await?;
} else {
self.delete_entry_silent(&entry).await?;
}
}
Ok(())
}
async fn delete_entry(&self, entry: &PairingEligibilityRecord) -> Result<(), VnidropError> {
self.delete_entry_silent(entry).await?;
self.event_hub.emit_endpoint(
"pairing",
"eligibility-removed",
json!({
"peer_endpoint_id": entry.peer_endpoint_id,
"session_id": entry.session_id,
}),
);
Ok(())
}
async fn delete_entry_silent(
&self,
entry: &PairingEligibilityRecord,
) -> Result<(), VnidropError> {
if let Some(custody) = &self.custody {
let handle = SecretHandle::from_stored(entry.secret_handle.clone());
let _ = custody.remove(&handle).await;
}
self.repository
.delete_pairing_eligibility(&entry.session_id)
.await
}
}
pub(crate) struct PairingEligibilityInsert<'a> {
pub(crate) peer_endpoint_id: &'a str,
pub(crate) session_id: &'a str,
pub(crate) protocol_version: u16,
pub(crate) secret_handle: &'a str,
pub(crate) created_at: i64,
pub(crate) expires_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PairingEligibilityRecord {
pub(crate) peer_endpoint_id: String,
pub(crate) session_id: String,
pub(crate) protocol_version: u16,
pub(crate) secret_handle: String,
pub(crate) created_at: i64,
pub(crate) expires_at: i64,
}
fn derive_capability(
approval_token: &str,
local_endpoint_id: &str,
peer_endpoint_id: &str,
session_id: &str,
protocol_version: u16,
) -> Result<SecretMaterial, VnidropError> {
let mut endpoints = [local_endpoint_id, peer_endpoint_id];
endpoints.sort_unstable();
let mut hasher = blake3::Hasher::new_derive_key(CAPABILITY_CONTEXT);
hasher.update(approval_token.as_bytes());
hasher.update(&[0]);
hasher.update(endpoints[0].as_bytes());
hasher.update(&[0]);
hasher.update(endpoints[1].as_bytes());
hasher.update(&[0]);
hasher.update(session_id.as_bytes());
hasher.update(&[0]);
hasher.update(&protocol_version.to_le_bytes());
let bytes = *hasher.finalize().as_bytes();
SecretMaterial::new(bytes.to_vec())
}

View File

@@ -15,13 +15,18 @@ use uuid::Uuid;
use crate::{ use crate::{
access_policy::mode_from_storage, access_policy::mode_from_storage,
api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer}, api::{
CoreEvent, PairingEligibilitySummary, ReceivedArtifact, ReceivedLocatorKind,
ReceiverRequest, StoredTransfer,
},
contacts::ContactStore, contacts::ContactStore,
error::VnidropError,
pairing_eligibility::{PairingEligibilityInsert, PairingEligibilityRecord},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
util::now_ms, util::now_ms,
}; };
const SCHEMA_VERSION: i64 = 10; const SCHEMA_VERSION: i64 = 11;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct Repository { pub(crate) struct Repository {
@@ -318,6 +323,28 @@ impl Repository {
crate::contacts::ensure_schema(&self.pool).await?; crate::contacts::ensure_schema(&self.pool).await?;
crate::secure_secret::ensure_schema(&self.pool).await?; crate::secure_secret::ensure_schema(&self.pool).await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS pairing_eligibilities (
session_id TEXT PRIMARY KEY,
peer_endpoint_id TEXT NOT NULL,
protocol_version INTEGER NOT NULL,
secret_handle TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS pairing_eligibilities_peer
ON pairing_eligibilities(peer_endpoint_id);
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}")) sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
.execute(&self.pool) .execute(&self.pool)
@@ -339,6 +366,150 @@ impl Repository {
crate::secure_secret::SecretMetadataStore::new(self.pool.clone()) crate::secure_secret::SecretMetadataStore::new(self.pool.clone())
} }
pub(crate) async fn insert_pairing_eligibility(
&self,
entry: PairingEligibilityInsert<'_>,
) -> Result<(), VnidropError> {
sqlx::query(
r#"
INSERT INTO pairing_eligibilities (
session_id, peer_endpoint_id, protocol_version, secret_handle, created_at, expires_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
"#,
)
.bind(entry.session_id)
.bind(entry.peer_endpoint_id)
.bind(i64::from(entry.protocol_version))
.bind(entry.secret_handle)
.bind(entry.created_at)
.bind(entry.expires_at)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(crate) async fn list_pairing_eligibilities(
&self,
) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT peer_endpoint_id, session_id, protocol_version, created_at, expires_at
FROM pairing_eligibilities
ORDER BY created_at DESC
"#,
)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows
.into_iter()
.map(|row| PairingEligibilitySummary {
peer_endpoint_id: row.get("peer_endpoint_id"),
session_id: row.get("session_id"),
protocol_version: row.get::<i64, _>("protocol_version") as u16,
created_at: row.get("created_at"),
expires_at: row.get("expires_at"),
})
.collect())
}
pub(crate) async fn list_pairing_eligibility_records(
&self,
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
FROM pairing_eligibilities
"#,
)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows.into_iter().map(row_to_pairing_eligibility).collect())
}
pub(crate) async fn list_pairing_eligibilities_for_peer(
&self,
peer_endpoint_id: &str,
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
FROM pairing_eligibilities
WHERE peer_endpoint_id = ?1
"#,
)
.bind(peer_endpoint_id)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows.into_iter().map(row_to_pairing_eligibility).collect())
}
pub(crate) async fn list_expired_pairing_eligibilities(
&self,
now_ms: i64,
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
FROM pairing_eligibilities
WHERE expires_at <= ?1
"#,
)
.bind(now_ms)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows.into_iter().map(row_to_pairing_eligibility).collect())
}
pub(crate) async fn find_pairing_eligibility_by_session(
&self,
session_id: &str,
) -> Result<Option<PairingEligibilityRecord>, VnidropError> {
let row = sqlx::query(
r#"
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
FROM pairing_eligibilities
WHERE session_id = ?1
"#,
)
.bind(session_id)
.fetch_optional(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(row.map(row_to_pairing_eligibility))
}
pub(crate) async fn delete_pairing_eligibility(
&self,
session_id: &str,
) -> Result<(), VnidropError> {
sqlx::query("DELETE FROM pairing_eligibilities WHERE session_id = ?1")
.bind(session_id)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
#[cfg(test)]
pub(crate) async fn force_pairing_eligibility_expiry_for_test(
&self,
session_id: &str,
expires_at: i64,
) -> Result<(), VnidropError> {
sqlx::query("UPDATE pairing_eligibilities SET expires_at = ?2 WHERE session_id = ?1")
.bind(session_id)
.bind(expires_at)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
#[cfg(test)] #[cfg(test)]
pub(crate) async fn schema_version(&self) -> Result<i64> { pub(crate) async fn schema_version(&self) -> Result<i64> {
let row = sqlx::query("PRAGMA user_version") let row = sqlx::query("PRAGMA user_version")
@@ -1267,3 +1438,14 @@ fn row_to_receiver_request(row: sqlx::sqlite::SqliteRow) -> ReceiverRequest {
completed_at: row.get("completed_at"), completed_at: row.get("completed_at"),
} }
} }
fn row_to_pairing_eligibility(row: sqlx::sqlite::SqliteRow) -> PairingEligibilityRecord {
PairingEligibilityRecord {
peer_endpoint_id: row.get("peer_endpoint_id"),
session_id: row.get("session_id"),
protocol_version: row.get::<i64, _>("protocol_version") as u16,
secret_handle: row.get("secret_handle"),
created_at: row.get("created_at"),
expires_at: row.get("expires_at"),
}
}

View File

@@ -42,6 +42,41 @@ pub(crate) fn should_poll(last_polled_ms: Option<i64>, now_ms: i64) -> bool {
} }
impl CoreInner { impl CoreInner {
pub(super) async fn list_pairing_eligibilities(
&self,
) -> Result<Vec<crate::api::PairingEligibilitySummary>, crate::error::VnidropError> {
self.pairing_eligibility.list().await
}
pub(super) async fn decline_pairing_eligibility(
&self,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
self.pairing_eligibility.decline(&peer_endpoint_id).await
}
pub(super) async fn request_saved_device_pairing(
&self,
peer_endpoint_id: String,
) -> Result<bool, crate::error::VnidropError> {
self.pairing_eligibility
.request_pairing(&peer_endpoint_id)
.await
}
#[cfg(test)]
pub(super) async fn submit_pairing_eligibility_for_test(
&self,
peer_endpoint_id: String,
session_id: String,
capability: Vec<u8>,
) -> Result<bool, crate::error::VnidropError> {
let material = crate::secure_secret::SecretMaterial::new(capability)?;
self.pairing_eligibility
.accept_presented_eligibility(&peer_endpoint_id, &session_id, &material)
.await
}
pub(super) async fn list_contacts(&self) -> Result<Vec<ContactSummary>> { pub(super) async fn list_contacts(&self) -> Result<Vec<ContactSummary>> {
let contacts = self let contacts = self
.repository .repository
@@ -531,6 +566,10 @@ impl CoreInner {
// A prompt on screen from a device we just forgot would be actionable // A prompt on screen from a device we just forgot would be actionable
// with a grant that no longer exists. // with a grant that no longer exists.
self.offers.discard_from(&endpoint_id).await; self.offers.discard_from(&endpoint_id).await;
self.pairing_eligibility
.remove_for_peer(&endpoint_id)
.await
.map_err(anyhow::Error::from)?;
self.emit_endpoint( self.emit_endpoint(
"contacts", "contacts",
"contact-forgotten", "contact-forgotten",
@@ -555,6 +594,10 @@ impl CoreInner {
for contact in &contacts { for contact in &contacts {
self.offers.discard_from(&contact.endpoint_id).await; self.offers.discard_from(&contact.endpoint_id).await;
} }
self.pairing_eligibility
.remove_all()
.await
.map_err(anyhow::Error::from)?;
self.emit_endpoint( self.emit_endpoint(
"contacts", "contacts",
"contacts-cleared", "contacts-cleared",
@@ -582,6 +625,10 @@ impl CoreInner {
.await .await
.map_err(VnidropError::repository)?; .map_err(VnidropError::repository)?;
self.offers.discard_from(&endpoint_id).await; self.offers.discard_from(&endpoint_id).await;
self.pairing_eligibility
.remove_for_peer(&endpoint_id)
.await
.map_err(anyhow::Error::from)?;
self.emit_endpoint( self.emit_endpoint(
"contacts", "contacts",
"contact-blocked", "contact-blocked",

View File

@@ -7,10 +7,10 @@ use super::{CoreInner, IdentityMode};
use crate::{ use crate::{
api::{ api::{
ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig,
CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PendingPairing, CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer,
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, PairingEligibilitySummary, 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,
@@ -19,6 +19,9 @@ use crate::{
transfer_state::{TransferDirection, TransferStatus}, transfer_state::{TransferDirection, TransferStatus},
}; };
#[cfg(test)]
use crate::secure_secret::unlocked_profile_for_test;
#[derive(uniffi::Object)] #[derive(uniffi::Object)]
pub struct VnidropCore { pub struct VnidropCore {
runtime: tokio::runtime::Runtime, runtime: tokio::runtime::Runtime,
@@ -66,6 +69,57 @@ impl VnidropCore {
} }
} }
#[cfg(test)]
impl VnidropCore {
/// Test-only protected identity with an injected secret store.
pub(crate) fn initialize_with_test_secret_store(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
store: Arc<dyn crate::secure_secret::SecureSecretStore>,
) -> Result<Arc<Self>, VnidropError> {
let app_data_path = PathBuf::from(&app_data_dir);
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
// In-process restart tests reopen the same directory immediately after
// drop; skip exclusive locking and rely on the injected store instead.
let profile_lock = unlocked_profile_for_test(&app_data_path)?;
Self::initialize_with_identity_mode(
app_data_dir,
event_sink,
CoreLimits::default(),
CoreNetworkConfig::default(),
IdentityMode::Protected {
store,
profile_lock,
},
)
}
pub(crate) fn force_pairing_eligibility_expiry_for_test(
&self,
session_id: String,
expires_at: i64,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.repository
.force_pairing_eligibility_expiry_for_test(&session_id, expires_at),
)
}
pub(crate) fn submit_pairing_eligibility_for_test(
&self,
peer_endpoint_id: String,
session_id: String,
capability: Vec<u8>,
) -> Result<bool, VnidropError> {
self.block_on(self.inner.submit_pairing_eligibility_for_test(
peer_endpoint_id,
session_id,
capability,
))
}
}
#[uniffi::export] #[uniffi::export]
impl VnidropCore { impl VnidropCore {
#[uniffi::constructor] #[uniffi::constructor]
@@ -315,6 +369,34 @@ impl VnidropCore {
.map_err(VnidropError::permission) .map_err(VnidropError::permission)
} }
/// Single-use pairing windows created by completed authenticated transfers.
///
/// Returns eligibility state only — never the capability material.
pub fn list_pairing_eligibilities(
&self,
) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
self.block_on(self.inner.list_pairing_eligibilities())
}
/// Declines and removes pairing eligibility for a peer. Idempotent.
pub fn decline_pairing_eligibility(
&self,
peer_endpoint_id: String,
) -> Result<(), VnidropError> {
self.block_on(self.inner.decline_pairing_eligibility(peer_endpoint_id))
}
/// Initiates saved-device pairing when local eligibility exists.
///
/// Returns `false` when eligibility is missing or already consumed. Invalid
/// attempts produce no pairing prompt.
pub fn request_saved_device_pairing(
&self,
peer_endpoint_id: String,
) -> Result<bool, VnidropError> {
self.block_on(self.inner.request_saved_device_pairing(peer_endpoint_id))
}
/// Devices the user has chosen to remember. /// Devices the user has chosen to remember.
pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> { pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> {
self.block_on(self.inner.list_contacts()) self.block_on(self.inner.list_contacts())

View File

@@ -62,9 +62,10 @@ use crate::{
offer::OfferService, offer::OfferService,
offer_inbox::OfferInbox, offer_inbox::OfferInbox,
pairing::PairingService, pairing::PairingService,
pairing_eligibility::PairingEligibilityService,
repository::Repository, repository::Repository,
secret::load_or_create_secret, secret::load_or_create_secret,
secure_secret::{start_endpoint_identity, ProfileLock, SecretCustody, SecureSecretStore}, secure_secret::{start_endpoint_identity, ProfileLock, SecureSecretStore},
ticket::ticket_matches_relay_profile, ticket::ticket_matches_relay_profile,
transfer_state::{TransferDirection, TransferStatus}, transfer_state::{TransferDirection, TransferStatus},
}; };
@@ -100,11 +101,11 @@ pub(super) struct CoreInner {
pub(super) router: Router, pub(super) router: Router,
pub(super) store: FsStore, pub(super) store: FsStore,
pub(super) repository: Repository, pub(super) repository: Repository,
_secret_custody: Option<SecretCustody>,
_profile_lock: Option<ProfileLock>, _profile_lock: Option<ProfileLock>,
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) pairing: PairingService,
pub(super) pairing_eligibility: PairingEligibilityService,
pub(super) offers: OfferInbox, pub(super) offers: OfferInbox,
/// Endpoint → last poll time, for the rate limit above. /// Endpoint → last poll time, for the rate limit above.
pub(super) last_polled: TokioMutex<HashMap<String, i64>>, pub(super) last_polled: TokioMutex<HashMap<String, i64>>,
@@ -165,7 +166,7 @@ impl CoreInner {
&app_data_dir.join("iroh.secret"), &app_data_dir.join("iroh.secret"),
) )
.await?; .await?;
(secret_key, Some(custody), Some(profile_lock)) (secret_key, Some(Arc::new(custody)), Some(profile_lock))
} }
}; };
let store_root = app_data_dir.join("blobs"); let store_root = app_data_dir.join("blobs");
@@ -354,12 +355,19 @@ impl CoreInner {
store.tags().delete(name).await?; store.tags().delete(name).await?;
} }
} }
let pairing_eligibility = PairingEligibilityService::new(
repository.clone(),
secret_custody.clone(),
event_hub.clone(),
endpoint.id().to_string(),
);
let approval = ApprovalService::new( let approval = ApprovalService::new(
repository.clone(), repository.clone(),
event_hub.clone(), event_hub.clone(),
access_policy.clone(), access_policy.clone(),
limits.max_pending_approvals as usize, limits.max_pending_approvals as usize,
limits.max_metadata_bytes, limits.max_metadata_bytes,
Some(pairing_eligibility.clone()),
); );
let handshake = HandshakeService::new(approval.clone()); let handshake = HandshakeService::new(approval.clone());
let pairing = PairingService::new( let pairing = PairingService::new(
@@ -392,11 +400,11 @@ impl CoreInner {
router, router,
store, store,
repository, repository,
_secret_custody: secret_custody,
_profile_lock: profile_lock, _profile_lock: profile_lock,
event_hub, event_hub,
approval, approval,
pairing, pairing,
pairing_eligibility,
offers, offers,
last_polled: TokioMutex::new(HashMap::new()), last_polled: TokioMutex::new(HashMap::new()),
relay_mode, relay_mode,
@@ -425,6 +433,9 @@ impl CoreInner {
); );
inner.spawn_provider_event_task(event_rx).await; inner.spawn_provider_event_task(event_rx).await;
inner.spawn_delivery_receipt_task().await; inner.spawn_delivery_receipt_task().await;
if let Err(error) = inner.pairing_eligibility.reconcile().await {
tracing::warn!(%error, "failed to reconcile pairing eligibility");
}
Ok(inner) Ok(inner)
} }

View File

@@ -419,6 +419,18 @@ impl CoreInner {
}) })
.await .await
.map_err(VnidropError::repository)?; .map_err(VnidropError::repository)?;
let peer_endpoint_id = sender_addr.id.to_string();
if let Err(error) = self
.pairing_eligibility
.activate_after_completed_transfer(
&peer_endpoint_id,
&delivery_receipt.request_id,
&delivery_receipt.token,
)
.await
{
tracing::warn!(%error, "failed to activate pairing eligibility after receive");
}
pending_delivery_receipt pending_delivery_receipt
.lock() .lock()
.expect("pending_delivery_receipt") .expect("pending_delivery_receipt")

View File

@@ -22,6 +22,8 @@ pub(crate) mod windows;
#[cfg(test)] #[cfg(test)]
pub(crate) use platform::scope_store; pub(crate) use platform::scope_store;
#[cfg(test)]
pub(crate) use platform::unlocked_profile_for_test;
pub(crate) use platform::{lock_profile, platform_secret_store, ProfileLock}; pub(crate) use platform::{lock_profile, platform_secret_store, ProfileLock};
const SECRET_BYTES: usize = 32; const SECRET_BYTES: usize = 32;
@@ -70,6 +72,10 @@ impl SecretHandle {
)) ))
} }
pub(crate) fn from_stored(value: String) -> Self {
Self(value)
}
pub(crate) fn as_str(&self) -> &str { pub(crate) fn as_str(&self) -> &str {
&self.0 &self.0
} }
@@ -422,6 +428,28 @@ impl SecretCustody {
Ok(material) Ok(material)
} }
/// Removes protected material and disables its metadata. Idempotent.
pub(crate) async fn remove(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
if self.metadata.find(handle).await?.is_some() {
self.metadata.disable(handle).await?;
}
self.delete_if_present(handle)
}
pub(crate) async fn list_active_handles(
&self,
kind: SecretKind,
) -> Result<Vec<SecretHandle>, VnidropError> {
Ok(self
.metadata
.list()
.await?
.into_iter()
.filter(|entry| entry.kind == kind && entry.state == SecretMetadataState::Active)
.map(|entry| entry.handle)
.collect())
}
pub(crate) async fn migrate_legacy_endpoint_identity( pub(crate) async fn migrate_legacy_endpoint_identity(
&self, &self,
legacy_path: &Path, legacy_path: &Path,

View File

@@ -36,6 +36,20 @@ pub(crate) fn lock_profile(app_data_dir: &Path) -> Result<ProfileLock, VnidropEr
Ok(ProfileLock { _file: file }) Ok(ProfileLock { _file: file })
} }
/// Opens the profile marker without locking so in-process restart tests can
/// reopen the same directory after dropping the previous core.
#[cfg(test)]
pub(crate) fn unlocked_profile_for_test(app_data_dir: &Path) -> Result<ProfileLock, VnidropError> {
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(app_data_dir.join("protected-secrets.lock"))
.map_err(VnidropError::filesystem)?;
Ok(ProfileLock { _file: file })
}
impl ScopedSecretStore { impl ScopedSecretStore {
fn new(app_data_dir: &Path, inner: Arc<dyn SecureSecretStore>) -> Self { fn new(app_data_dir: &Path, inner: Arc<dyn SecureSecretStore>) -> Self {
let profile = blake3::hash(app_data_dir.to_string_lossy().as_bytes()).to_hex(); let profile = blake3::hash(app_data_dir.to_string_lossy().as_bytes()).to_hex();

View File

@@ -16,6 +16,8 @@ mod handshake_tests;
mod limits_tests; mod limits_tests;
#[path = "tests/network_config.rs"] #[path = "tests/network_config.rs"]
mod network_config_tests; mod network_config_tests;
#[path = "tests/pairing_eligibility.rs"]
mod pairing_eligibility_tests;
#[path = "tests/repository.rs"] #[path = "tests/repository.rs"]
mod repository_tests; mod repository_tests;
#[path = "tests/runtime.rs"] #[path = "tests/runtime.rs"]

View File

@@ -0,0 +1,535 @@
use std::{
path::Path,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use crate::{
experimental_saved_device_capabilities, secure_secret::FaultInjectingSecretStore, CoreEvent,
CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode, VnidropCore,
VnidropError,
};
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
impl RecordingSink {
fn events(&self) -> Vec<CoreEvent> {
self.events.lock().unwrap().clone()
}
}
struct ProtectedNode {
_data_dir: tempfile::TempDir,
core: Arc<VnidropCore>,
sink: Arc<RecordingSink>,
}
impl ProtectedNode {
fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = Arc::new(FaultInjectingSecretStore::default());
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store,
)
.expect("protected test core");
Self {
_data_dir: data_dir,
core,
sink,
}
}
}
impl Drop for ProtectedNode {
fn drop(&mut self) {
self.core.shutdown();
}
}
fn share_path(core: &VnidropCore, source: &Path, transfer_id: u64) -> crate::ShareResult {
core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source.to_string_lossy().into_owned(),
display_name: Some("hello.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some("hello.txt".to_string()),
sender_name: Some("sender".to_string()),
access_mode: TransferAccessMode::ApprovalRequired,
},
)
.unwrap()
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> crate::ReceiverRequest {
let started = Instant::now();
loop {
if let Some(request) = sender
.list_receiver_requests(transfer_id)
.unwrap()
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn receive_with_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_dir: &Path,
accepted: bool,
) -> Result<(), VnidropError> {
let output_dir = output_dir.to_string_lossy().to_string();
let handle = std::thread::spawn(move || {
receiver.receive(ticket, output_dir, Some("receiver".to_string()))
});
let request = wait_for_receiver_request(sender, transfer_id);
sender
.respond_receiver_request(
request.id,
accepted,
(!accepted).then(|| "sender-refused".to_string()),
)
.unwrap();
handle.join().unwrap()
}
fn wait_for_eligibility(core: &VnidropCore, peer_endpoint_id: &str) {
let started = Instant::now();
loop {
let found = core
.list_pairing_eligibilities()
.unwrap()
.into_iter()
.any(|entry| entry.peer_endpoint_id == peer_endpoint_id);
if found {
return;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"eligibility for {peer_endpoint_id} never appeared"
);
std::thread::sleep(Duration::from_millis(25));
}
}
#[test]
fn completed_authenticated_transfer_creates_pairing_eligibility_on_both_sides() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"eligible after completion").unwrap();
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let sender_id = sender.core.status().endpoint_id.clone();
let receiver_id = receiver.core.status().endpoint_id.clone();
let share = share_path(&sender.core, &source_path, 70_001);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
wait_for_eligibility(&sender.core, &receiver_id);
wait_for_eligibility(&receiver.core, &sender_id);
let protocol = experimental_saved_device_capabilities().relationship_protocol_version;
let sender_entry = sender
.core
.list_pairing_eligibilities()
.unwrap()
.into_iter()
.find(|entry| entry.peer_endpoint_id == receiver_id)
.unwrap();
let receiver_entry = receiver
.core
.list_pairing_eligibilities()
.unwrap()
.into_iter()
.find(|entry| entry.peer_endpoint_id == sender_id)
.unwrap();
assert_eq!(sender_entry.session_id, receiver_entry.session_id);
assert_eq!(sender_entry.protocol_version, protocol);
assert_eq!(receiver_entry.protocol_version, protocol);
assert!(sender_entry.expires_at > sender_entry.created_at);
assert_eq!(
sender_entry.expires_at - sender_entry.created_at,
24 * 60 * 60 * 1_000
);
let sender_events = sender.sink.events();
assert!(
sender_events
.iter()
.any(|event| { event.phase == "pairing" && event.kind == "eligibility-available" }),
"sender should emit eligibility-available without capability material"
);
assert!(
!sender_events
.iter()
.any(|event| event.data_json.contains("capability")),
"events must not expose the eligibility capability"
);
}
#[test]
fn declined_cancelled_and_failed_transfers_create_no_eligibility() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"not eligible").unwrap();
// Declined approval
{
let output_dir = tempfile::tempdir().unwrap();
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let share = share_path(&sender.core, &source_path, 70_010);
let _ = receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
false,
);
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
assert!(receiver
.core
.list_pairing_eligibilities()
.unwrap()
.is_empty());
}
// Failed export on the receiver
{
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let share = share_path(&sender.core, &source_path, 70_011);
let sink = Arc::new(FailingOutputSink);
let handle = {
let receiver = receiver.core.clone();
let ticket = share.ticket.clone();
std::thread::spawn(move || {
receiver.receive_with_output_sink(ticket, sink, Some("receiver".to_string()))
})
};
let request = wait_for_receiver_request(&sender.core, share.transfer_id);
sender
.core
.respond_receiver_request(request.id, true, None)
.unwrap();
let _ = handle.join().unwrap();
std::thread::sleep(Duration::from_millis(200));
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
assert!(receiver
.core
.list_pairing_eligibilities()
.unwrap()
.is_empty());
}
}
#[test]
fn eligibility_survives_restart_without_filenames_or_history_payload() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("secret-name.txt");
std::fs::write(&source_path, b"persist eligibility").unwrap();
let data_dir = tempfile::tempdir().unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let sender = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store.clone(),
)
.unwrap();
let receiver = ProtectedNode::new();
let receiver_id = receiver.core.status().endpoint_id.clone();
let share = share_path(&sender, &source_path, 70_020);
receive_with_response(
&sender,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
wait_for_eligibility(&sender, &receiver_id);
let before = sender.list_pairing_eligibilities().unwrap();
assert_eq!(before.len(), 1);
sender.shutdown();
drop(sender);
let restarted = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink,
store,
)
.unwrap();
let after = restarted.list_pairing_eligibilities().unwrap();
assert_eq!(after, before);
assert!(!serde_json::to_string(&after)
.unwrap()
.contains("secret-name"));
assert!(!serde_json::to_string(&after)
.unwrap()
.contains("persist eligibility"));
restarted.shutdown();
}
#[test]
fn expired_eligibility_is_removed_and_cannot_authorize_pairing() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"expires").unwrap();
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let receiver_id = receiver.core.status().endpoint_id.clone();
let share = share_path(&sender.core, &source_path, 70_050);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
wait_for_eligibility(&sender.core, &receiver_id);
let session_id = sender.core.list_pairing_eligibilities().unwrap()[0]
.session_id
.clone();
sender
.core
.force_pairing_eligibility_expiry_for_test(session_id, 1)
.unwrap();
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
assert!(!sender
.core
.request_saved_device_pairing(receiver_id)
.unwrap());
}
#[test]
fn decline_forget_block_and_replay_remove_eligibility_idempotently() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"remove eligibility").unwrap();
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let receiver_id = receiver.core.status().endpoint_id.clone();
let share = share_path(&sender.core, &source_path, 70_030);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
wait_for_eligibility(&sender.core, &receiver_id);
sender
.core
.decline_pairing_eligibility(receiver_id.clone())
.unwrap();
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
sender
.core
.decline_pairing_eligibility(receiver_id.clone())
.unwrap();
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
// Fresh eligibility for forget/block coverage on the receiver side.
let sender2 = ProtectedNode::new();
let output_dir2 = tempfile::tempdir().unwrap();
let share2 = share_path(&sender2.core, &source_path, 70_031);
receive_with_response(
&sender2.core,
share2.transfer_id,
receiver.core.clone(),
share2.ticket,
output_dir2.path(),
true,
)
.unwrap();
wait_for_eligibility(&receiver.core, &sender2.core.status().endpoint_id);
receiver
.core
.forget_contact(sender2.core.status().endpoint_id.clone())
.unwrap();
assert!(receiver
.core
.list_pairing_eligibilities()
.unwrap()
.iter()
.all(|entry| entry.peer_endpoint_id != sender2.core.status().endpoint_id));
let sender3 = ProtectedNode::new();
let output_dir3 = tempfile::tempdir().unwrap();
let share3 = share_path(&sender3.core, &source_path, 70_032);
receive_with_response(
&sender3.core,
share3.transfer_id,
receiver.core.clone(),
share3.ticket,
output_dir3.path(),
true,
)
.unwrap();
wait_for_eligibility(&receiver.core, &sender3.core.status().endpoint_id);
receiver
.core
.block_contact(sender3.core.status().endpoint_id.clone())
.unwrap();
assert!(receiver
.core
.list_pairing_eligibilities()
.unwrap()
.iter()
.all(|entry| entry.peer_endpoint_id != sender3.core.status().endpoint_id));
}
#[test]
fn missing_expired_replayed_and_fabricated_eligibility_are_silently_rejected() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"silent reject").unwrap();
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let receiver_id = receiver.core.status().endpoint_id.clone();
let events_before = receiver.sink.events().len();
// Missing eligibility: request produces no pending pairing prompt/event.
assert!(!receiver
.core
.request_saved_device_pairing(sender.core.status().endpoint_id.clone())
.unwrap());
assert!(receiver.core.list_pending_pairings().is_empty());
assert_eq!(
receiver
.sink
.events()
.iter()
.filter(|event| event.phase == "pairing" && event.kind.contains("pending"))
.count(),
0
);
let share = share_path(&sender.core, &source_path, 70_040);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
wait_for_eligibility(&sender.core, &receiver_id);
// Consume once, then replay must not create a second prompt.
assert!(sender
.core
.request_saved_device_pairing(receiver_id.clone())
.unwrap());
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
assert!(!sender
.core
.request_saved_device_pairing(receiver_id)
.unwrap());
assert!(sender.core.list_pending_pairings().is_empty());
assert!(receiver.core.list_pending_pairings().is_empty());
// Fabricated peer identity is rejected without growing pairing events.
let pairing_events_before = receiver
.sink
.events()
.iter()
.filter(|event| event.phase == "pairing")
.count();
assert!(!receiver
.core
.submit_pairing_eligibility_for_test(
"fabricated-endpoint".to_string(),
"fabricated-session".to_string(),
vec![7u8; 32],
)
.unwrap());
assert!(receiver.core.list_pending_pairings().is_empty());
let pairing_events_after = receiver
.sink
.events()
.iter()
.filter(|event| event.phase == "pairing")
.count();
assert_eq!(pairing_events_before, pairing_events_after);
let _ = events_before;
}
struct FailingOutputSink;
impl crate::ReceiveOutputSink for FailingOutputSink {
fn start_file(&self, _relative_path: String) -> Result<(), VnidropError> {
Ok(())
}
fn write_chunk(&self, _relative_path: String, _bytes: Vec<u8>) -> Result<(), VnidropError> {
Err(VnidropError::Filesystem {
reason: "export failed".to_string(),
})
}
fn finish_file(&self, _relative_path: String) -> Result<(), VnidropError> {
Ok(())
}
fn abort_file(&self, _relative_path: String, _reason: String) -> Result<(), VnidropError> {
Ok(())
}
}

View File

@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
async fn persists_transfers_and_events_across_reopen() { async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap(); let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 10); assert_eq!(repository.schema_version().await.unwrap(), 11);
repository repository
.insert_transfer(transfer( .insert_transfer(transfer(
7, 7,
@@ -645,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
pool.close().await; pool.close().await;
let repository = Repository::open(temp.path()).await.unwrap(); let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 10); assert_eq!(repository.schema_version().await.unwrap(), 11);
let stored = repository.list_transfers().await.unwrap().remove(0); let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.transfer_id, 7); assert_eq!(stored.transfer_id, 7);
assert_eq!(stored.local_id, "legacy-7-send"); assert_eq!(stored.local_id, "legacy-7-send");