mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
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:
@@ -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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct SavedDevice {
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
|
||||
RequestTransfer,
|
||||
},
|
||||
pairing_eligibility::PairingEligibilityService,
|
||||
repository::{ReceiverRequestInsert, Repository},
|
||||
transfer_state::ReceiverRequestStatus,
|
||||
util::now_ms,
|
||||
@@ -35,6 +36,7 @@ pub(crate) struct ApprovalService {
|
||||
pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>,
|
||||
max_pending: usize,
|
||||
max_metadata_bytes: u64,
|
||||
pairing_eligibility: Option<PairingEligibilityService>,
|
||||
}
|
||||
|
||||
impl ApprovalService {
|
||||
@@ -55,6 +57,18 @@ impl ApprovalService {
|
||||
.await
|
||||
{
|
||||
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(
|
||||
receipt.transfer_id,
|
||||
"send",
|
||||
@@ -119,6 +133,7 @@ impl ApprovalService {
|
||||
access_policy: Arc<AccessPolicy>,
|
||||
max_pending: usize,
|
||||
max_metadata_bytes: u64,
|
||||
pairing_eligibility: Option<PairingEligibilityService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
@@ -127,6 +142,7 @@ impl ApprovalService {
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
max_pending,
|
||||
max_metadata_bytes,
|
||||
pairing_eligibility,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ enum EventPhase {
|
||||
Transfer,
|
||||
/// Delivery receipts from receivers (completed download acknowledgements).
|
||||
Delivery,
|
||||
/// Saved-device pairing eligibility and consent prompts.
|
||||
Pairing,
|
||||
/// Prototype contact lifecycle notifications.
|
||||
Contacts,
|
||||
}
|
||||
|
||||
impl EventPhase {
|
||||
@@ -68,6 +72,8 @@ impl EventPhase {
|
||||
"approval" => Some(Self::Approval),
|
||||
"transfer" => Some(Self::Transfer),
|
||||
"delivery" => Some(Self::Delivery),
|
||||
"pairing" => Some(Self::Pairing),
|
||||
"contacts" => Some(Self::Contacts),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -90,6 +96,8 @@ impl EventPhase {
|
||||
Self::Approval => "approval",
|
||||
Self::Transfer => "transfer",
|
||||
Self::Delivery => "delivery",
|
||||
Self::Pairing => "pairing",
|
||||
Self::Contacts => "contacts",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod logging;
|
||||
mod offer;
|
||||
mod offer_inbox;
|
||||
mod pairing;
|
||||
mod pairing_eligibility;
|
||||
mod repository;
|
||||
mod runtime;
|
||||
mod secret;
|
||||
@@ -29,11 +30,11 @@ pub use api::{
|
||||
experimental_saved_device_capabilities, ContactSendResult, ContactSummary, CoreEvent,
|
||||
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage,
|
||||
DeviceRelationship, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities,
|
||||
GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PendingPairing, PublishedOutput,
|
||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
|
||||
RuntimeStatus, SavedDevice, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
|
||||
StoredTransfer, TargetedTransfer, TargetedTransferState, TicketInspection, TransferAccessMode,
|
||||
TransferMetadata,
|
||||
GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PairingEligibilitySummary,
|
||||
PendingPairing, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact,
|
||||
ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice, ShareMetadataInput,
|
||||
ShareResult, ShareSource, SourceKind, StoredTransfer, TargetedTransfer, TargetedTransferState,
|
||||
TicketInspection, TransferAccessMode, TransferMetadata,
|
||||
};
|
||||
pub use error::VnidropError;
|
||||
pub use runtime::VnidropCore;
|
||||
|
||||
338
crates/vnidrop/src/pairing_eligibility.rs
Normal file
338
crates/vnidrop/src/pairing_eligibility.rs
Normal 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())
|
||||
}
|
||||
@@ -15,13 +15,18 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
access_policy::mode_from_storage,
|
||||
api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer},
|
||||
api::{
|
||||
CoreEvent, PairingEligibilitySummary, ReceivedArtifact, ReceivedLocatorKind,
|
||||
ReceiverRequest, StoredTransfer,
|
||||
},
|
||||
contacts::ContactStore,
|
||||
error::VnidropError,
|
||||
pairing_eligibility::{PairingEligibilityInsert, PairingEligibilityRecord},
|
||||
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 10;
|
||||
const SCHEMA_VERSION: i64 = 11;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Repository {
|
||||
@@ -318,6 +323,28 @@ impl Repository {
|
||||
|
||||
crate::contacts::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}"))
|
||||
.execute(&self.pool)
|
||||
@@ -339,6 +366,150 @@ impl Repository {
|
||||
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)]
|
||||
pub(crate) async fn schema_version(&self) -> Result<i64> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,41 @@ pub(crate) fn should_poll(last_polled_ms: Option<i64>, now_ms: i64) -> bool {
|
||||
}
|
||||
|
||||
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>> {
|
||||
let contacts = self
|
||||
.repository
|
||||
@@ -531,6 +566,10 @@ impl CoreInner {
|
||||
// A prompt on screen from a device we just forgot would be actionable
|
||||
// with a grant that no longer exists.
|
||||
self.offers.discard_from(&endpoint_id).await;
|
||||
self.pairing_eligibility
|
||||
.remove_for_peer(&endpoint_id)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
self.emit_endpoint(
|
||||
"contacts",
|
||||
"contact-forgotten",
|
||||
@@ -555,6 +594,10 @@ impl CoreInner {
|
||||
for contact in &contacts {
|
||||
self.offers.discard_from(&contact.endpoint_id).await;
|
||||
}
|
||||
self.pairing_eligibility
|
||||
.remove_all()
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
self.emit_endpoint(
|
||||
"contacts",
|
||||
"contacts-cleared",
|
||||
@@ -582,6 +625,10 @@ impl CoreInner {
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
self.offers.discard_from(&endpoint_id).await;
|
||||
self.pairing_eligibility
|
||||
.remove_for_peer(&endpoint_id)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
self.emit_endpoint(
|
||||
"contacts",
|
||||
"contact-blocked",
|
||||
|
||||
@@ -7,10 +7,10 @@ use super::{CoreInner, IdentityMode};
|
||||
use crate::{
|
||||
api::{
|
||||
ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig,
|
||||
CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PendingPairing,
|
||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus,
|
||||
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
|
||||
TransferAccessMode,
|
||||
CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer,
|
||||
PairingEligibilitySummary, PendingPairing, ReceiveOutputSink, ReceiveOutputSinkV2,
|
||||
ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
|
||||
ShareSource, StoredTransfer, TicketInspection, TransferAccessMode,
|
||||
},
|
||||
error::VnidropError,
|
||||
filesystem::platform_path,
|
||||
@@ -19,6 +19,9 @@ use crate::{
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::secure_secret::unlocked_profile_for_test;
|
||||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct VnidropCore {
|
||||
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]
|
||||
impl VnidropCore {
|
||||
#[uniffi::constructor]
|
||||
@@ -315,6 +369,34 @@ impl VnidropCore {
|
||||
.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.
|
||||
pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> {
|
||||
self.block_on(self.inner.list_contacts())
|
||||
|
||||
@@ -62,9 +62,10 @@ use crate::{
|
||||
offer::OfferService,
|
||||
offer_inbox::OfferInbox,
|
||||
pairing::PairingService,
|
||||
pairing_eligibility::PairingEligibilityService,
|
||||
repository::Repository,
|
||||
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,
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
};
|
||||
@@ -100,11 +101,11 @@ pub(super) struct CoreInner {
|
||||
pub(super) router: Router,
|
||||
pub(super) store: FsStore,
|
||||
pub(super) repository: Repository,
|
||||
_secret_custody: Option<SecretCustody>,
|
||||
_profile_lock: Option<ProfileLock>,
|
||||
pub(super) event_hub: Arc<EventHub>,
|
||||
pub(super) approval: ApprovalService,
|
||||
pub(super) pairing: PairingService,
|
||||
pub(super) pairing_eligibility: PairingEligibilityService,
|
||||
pub(super) offers: OfferInbox,
|
||||
/// Endpoint → last poll time, for the rate limit above.
|
||||
pub(super) last_polled: TokioMutex<HashMap<String, i64>>,
|
||||
@@ -165,7 +166,7 @@ impl CoreInner {
|
||||
&app_data_dir.join("iroh.secret"),
|
||||
)
|
||||
.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");
|
||||
@@ -354,12 +355,19 @@ impl CoreInner {
|
||||
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(
|
||||
repository.clone(),
|
||||
event_hub.clone(),
|
||||
access_policy.clone(),
|
||||
limits.max_pending_approvals as usize,
|
||||
limits.max_metadata_bytes,
|
||||
Some(pairing_eligibility.clone()),
|
||||
);
|
||||
let handshake = HandshakeService::new(approval.clone());
|
||||
let pairing = PairingService::new(
|
||||
@@ -392,11 +400,11 @@ impl CoreInner {
|
||||
router,
|
||||
store,
|
||||
repository,
|
||||
_secret_custody: secret_custody,
|
||||
_profile_lock: profile_lock,
|
||||
event_hub,
|
||||
approval,
|
||||
pairing,
|
||||
pairing_eligibility,
|
||||
offers,
|
||||
last_polled: TokioMutex::new(HashMap::new()),
|
||||
relay_mode,
|
||||
@@ -425,6 +433,9 @@ impl CoreInner {
|
||||
);
|
||||
inner.spawn_provider_event_task(event_rx).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)
|
||||
}
|
||||
|
||||
|
||||
@@ -419,6 +419,18 @@ impl CoreInner {
|
||||
})
|
||||
.await
|
||||
.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
|
||||
.lock()
|
||||
.expect("pending_delivery_receipt")
|
||||
|
||||
@@ -22,6 +22,8 @@ pub(crate) mod windows;
|
||||
|
||||
#[cfg(test)]
|
||||
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};
|
||||
|
||||
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 {
|
||||
&self.0
|
||||
}
|
||||
@@ -422,6 +428,28 @@ impl SecretCustody {
|
||||
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(
|
||||
&self,
|
||||
legacy_path: &Path,
|
||||
|
||||
@@ -36,6 +36,20 @@ pub(crate) fn lock_profile(app_data_dir: &Path) -> Result<ProfileLock, VnidropEr
|
||||
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 {
|
||||
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();
|
||||
|
||||
@@ -16,6 +16,8 @@ mod handshake_tests;
|
||||
mod limits_tests;
|
||||
#[path = "tests/network_config.rs"]
|
||||
mod network_config_tests;
|
||||
#[path = "tests/pairing_eligibility.rs"]
|
||||
mod pairing_eligibility_tests;
|
||||
#[path = "tests/repository.rs"]
|
||||
mod repository_tests;
|
||||
#[path = "tests/runtime.rs"]
|
||||
|
||||
535
crates/vnidrop/src/tests/pairing_eligibility.rs
Normal file
535
crates/vnidrop/src/tests/pairing_eligibility.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
|
||||
async fn persists_transfers_and_events_across_reopen() {
|
||||
let temp = tempfile::tempdir().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
|
||||
.insert_transfer(transfer(
|
||||
7,
|
||||
@@ -645,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
|
||||
pool.close().await;
|
||||
|
||||
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);
|
||||
assert_eq!(stored.transfer_id, 7);
|
||||
assert_eq!(stored.local_id, "legacy-7-send");
|
||||
|
||||
Reference in New Issue
Block a user