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.
|
/// 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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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::{
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
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() {
|
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");
|
||||||
|
|||||||
Reference in New Issue
Block a user