feat(core): honor network profiles and protocol floors for saved devices

Targeted offers and pairing now validate relay-policy compatibility and
reject protocol downgrades with typed unavailable/timeout/incompatibility
errors, without falling back to ordinary shares.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 03:54:13 +02:00
parent e2cfad7fb3
commit 4a83b18d42
10 changed files with 732 additions and 71 deletions

View File

@@ -10,7 +10,7 @@ use data_encoding::HEXLOWER;
use iroh::{ use iroh::{
endpoint::Connection, endpoint::Connection,
protocol::{AcceptError, ProtocolHandler}, protocol::{AcceptError, ProtocolHandler},
Endpoint, EndpointAddr, EndpointId, Endpoint, EndpointAddr, EndpointId, RelayUrl,
}; };
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels}; use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
use irpc_iroh::{read_request, IrohLazyRemoteConnection}; use irpc_iroh::{read_request, IrohLazyRemoteConnection};
@@ -20,13 +20,16 @@ use sqlx::{Row, SqlitePool};
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
use crate::{ use crate::{
api::{DeviceRelationship, DeviceRelationshipState, SavedDevice}, api::{
experimental_saved_device_capabilities, CoreRelayMode, DeviceRelationship,
DeviceRelationshipState, SavedDevice,
},
error::VnidropError, error::VnidropError,
event_hub::EventHub, event_hub::EventHub,
grant::{Challenge, GrantId, GrantProof, GrantSecret}, grant::{Challenge, GrantId, GrantProof, GrantSecret},
pairing_eligibility::PairingEligibilityService, pairing_eligibility::PairingEligibilityService,
secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial}, secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial},
ticket::encode_persisted_sender_address, ticket::{encode_persisted_sender_address, filter_peer_addr_for_relay_mode},
util::now_ms, util::now_ms,
}; };
@@ -53,10 +56,16 @@ pub(crate) struct DeviceRelationshipService {
event_hub: Arc<EventHub>, event_hub: Arc<EventHub>,
local_endpoint_id: String, local_endpoint_id: String,
endpoint: Endpoint, endpoint: Endpoint,
relay_mode: CoreRelayMode,
custom_relay_urls: Vec<RelayUrl>,
peer_locks: Arc<TokioMutex<HashMap<String, Arc<TokioMutex<()>>>>>, peer_locks: Arc<TokioMutex<HashMap<String, Arc<TokioMutex<()>>>>>,
} }
impl DeviceRelationshipService { impl DeviceRelationshipService {
#[allow(
clippy::too_many_arguments,
reason = "constructor wires custody, eligibility, endpoint, and network profile once"
)]
pub(crate) fn new( pub(crate) fn new(
pool: SqlitePool, pool: SqlitePool,
custody: Option<Arc<SecretCustody>>, custody: Option<Arc<SecretCustody>>,
@@ -64,6 +73,8 @@ impl DeviceRelationshipService {
event_hub: Arc<EventHub>, event_hub: Arc<EventHub>,
local_endpoint_id: String, local_endpoint_id: String,
endpoint: Endpoint, endpoint: Endpoint,
relay_mode: CoreRelayMode,
custom_relay_urls: Vec<RelayUrl>,
) -> Self { ) -> Self {
Self { Self {
pool, pool,
@@ -72,6 +83,8 @@ impl DeviceRelationshipService {
event_hub, event_hub,
local_endpoint_id, local_endpoint_id,
endpoint, endpoint,
relay_mode,
custom_relay_urls,
peer_locks: Arc::new(TokioMutex::new(HashMap::new())), peer_locks: Arc::new(TokioMutex::new(HashMap::new())),
} }
} }
@@ -511,6 +524,12 @@ impl DeviceRelationshipService {
Ok(capability) => capability, Ok(capability) => capability,
Err(_) => return PairingRequestResponse::Rejected, Err(_) => return PairingRequestResponse::Rejected,
}; };
let local_protocol = experimental_saved_device_capabilities().relationship_protocol_version;
// Peers without a compatible saved-device protocol cannot pair; they
// retain ordinary invitation flow outside this ALPN.
if request.protocol_version != local_protocol {
return PairingRequestResponse::Rejected;
}
let accepted = match self let accepted = match self
.eligibility .eligibility
.validate_presented_capability(&remote_endpoint_id, &request.session_id, &capability) .validate_presented_capability(&remote_endpoint_id, &request.session_id, &capability)
@@ -569,6 +588,11 @@ impl DeviceRelationshipService {
if row.state != DeviceRelationshipState::PendingOutgoing { if row.state != DeviceRelationshipState::PendingOutgoing {
return PairingConsentResponse::Rejected; return PairingConsentResponse::Rejected;
} }
if consent.protocol_version < row.minimum_protocol_version
|| consent.generation != row.generation
{
return PairingConsentResponse::Rejected;
}
if !consent.accepted { if !consent.accepted {
// Peer declined: clear local pending; eligibility was already consumed by requester. // Peer declined: clear local pending; eligibility was already consumed by requester.
let _ = self.delete_relationship(&remote_endpoint_id).await; let _ = self.delete_relationship(&remote_endpoint_id).await;
@@ -895,6 +919,13 @@ impl DeviceRelationshipService {
"relationship generation mismatch" "relationship generation mismatch"
))); )));
} }
// Established relationships record a protocol floor and reject silent
// downgrade attempts (design §7 / §15).
if protocol_version < row.minimum_protocol_version {
return Err(VnidropError::protocol_incompatible(anyhow::anyhow!(
"relationship protocol downgrade is forbidden"
)));
}
self.verify_issued_possession( self.verify_issued_possession(
peer_endpoint_id, peer_endpoint_id,
challenge, challenge,
@@ -917,6 +948,24 @@ impl DeviceRelationshipService {
Ok(()) Ok(())
} }
#[cfg(test)]
pub(crate) async fn force_minimum_protocol_version_for_test(
&self,
peer_endpoint_id: &str,
minimum_protocol_version: u16,
) -> Result<(), VnidropError> {
sqlx::query(
"UPDATE device_relationships SET minimum_protocol_version = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1",
)
.bind(peer_endpoint_id)
.bind(i64::from(minimum_protocol_version))
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
async fn prove_held_possession( async fn prove_held_possession(
&self, &self,
peer_endpoint_id: &str, peer_endpoint_id: &str,
@@ -1158,13 +1207,21 @@ impl DeviceRelationshipService {
.parse() .parse()
.context("unusable peer endpoint id") .context("unusable peer endpoint id")
.map_err(VnidropError::invalid_input)?; .map_err(VnidropError::invalid_input)?;
if let Some(info) = self.endpoint.remote_info(parsed).await { let raw = if let Some(info) = self.endpoint.remote_info(parsed).await {
let mut addr = EndpointAddr::from(parsed); let mut addr = EndpointAddr::from(parsed);
addr.addrs = info.addrs().map(|entry| entry.addr().clone()).collect(); addr.addrs = info.addrs().map(|entry| entry.addr().clone()).collect();
let _ = encode_persisted_sender_address(&addr); let _ = encode_persisted_sender_address(&addr);
return Ok(addr); addr
} } else {
Ok(EndpointAddr::from(parsed)) EndpointAddr::from(parsed)
};
filter_peer_addr_for_relay_mode(&raw, self.relay_mode, &self.custom_relay_urls).map_err(
|error| {
VnidropError::relay_policy_incompatible(anyhow::anyhow!(
"peer address is unusable under the active network profile: {error}"
))
},
)
} }
fn emit_changed(&self, peer_endpoint_id: &str, state: DeviceRelationshipState) { fn emit_changed(&self, peer_endpoint_id: &str, state: DeviceRelationshipState) {

View File

@@ -16,6 +16,14 @@ pub enum VnidropError {
StorageFull { reason: String }, StorageFull { reason: String },
#[error("network error: {reason}")] #[error("network error: {reason}")]
Network { reason: String }, Network { reason: String },
#[error("device unavailable: {reason}")]
DeviceUnavailable { reason: String },
#[error("offer timed out: {reason}")]
OfferTimeout { reason: String },
#[error("relay policy incompatible: {reason}")]
RelayPolicyIncompatible { reason: String },
#[error("protocol incompatible: {reason}")]
ProtocolIncompatible { reason: String },
#[error("transfer error: {reason}")] #[error("transfer error: {reason}")]
Transfer { reason: String }, Transfer { reason: String },
#[error("permission error: {reason}")] #[error("permission error: {reason}")]
@@ -60,6 +68,24 @@ impl VnidropError {
Self::from_error(error.into(), |reason| Self::Network { reason }) Self::from_error(error.into(), |reason| Self::Network { reason })
} }
pub(crate) fn device_unavailable(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::DeviceUnavailable { reason })
}
pub(crate) fn offer_timeout(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::OfferTimeout { reason })
}
pub(crate) fn relay_policy_incompatible(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::RelayPolicyIncompatible {
reason,
})
}
pub(crate) fn protocol_incompatible(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::ProtocolIncompatible { reason })
}
pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> Self { pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> Self {
let error = error.into(); let error = error.into();
Self::classify(error, |reason| Self::Transfer { reason }) Self::classify(error, |reason| Self::Transfer { reason })
@@ -96,6 +122,10 @@ impl VnidropError {
Self::DestinationExists { .. } => "destination_exists", Self::DestinationExists { .. } => "destination_exists",
Self::StorageFull { .. } => "storage_full", Self::StorageFull { .. } => "storage_full",
Self::Network { .. } => "network", Self::Network { .. } => "network",
Self::DeviceUnavailable { .. } => "device_unavailable",
Self::OfferTimeout { .. } => "offer_timeout",
Self::RelayPolicyIncompatible { .. } => "relay_policy_incompatible",
Self::ProtocolIncompatible { .. } => "protocol_incompatible",
Self::Transfer { .. } => "transfer", Self::Transfer { .. } => "transfer",
Self::Permission { .. } => "permission_denied", Self::Permission { .. } => "permission_denied",
Self::Repository { .. } => "repository", Self::Repository { .. } => "repository",
@@ -119,6 +149,10 @@ impl VnidropError {
| Self::DestinationExists { reason } | Self::DestinationExists { reason }
| Self::StorageFull { reason } | Self::StorageFull { reason }
| Self::Network { reason } | Self::Network { reason }
| Self::DeviceUnavailable { reason }
| Self::OfferTimeout { reason }
| Self::RelayPolicyIncompatible { reason }
| Self::ProtocolIncompatible { reason }
| Self::Transfer { reason } | Self::Transfer { reason }
| Self::Permission { reason } | Self::Permission { reason }
| Self::Repository { reason } | Self::Repository { reason }
@@ -176,6 +210,10 @@ impl VnidropError {
Self::DestinationExists { .. } => Self::DestinationExists { reason }, Self::DestinationExists { .. } => Self::DestinationExists { reason },
Self::StorageFull { .. } => Self::StorageFull { reason }, Self::StorageFull { .. } => Self::StorageFull { reason },
Self::Network { .. } => Self::Network { reason }, Self::Network { .. } => Self::Network { reason },
Self::DeviceUnavailable { .. } => Self::DeviceUnavailable { reason },
Self::OfferTimeout { .. } => Self::OfferTimeout { reason },
Self::RelayPolicyIncompatible { .. } => Self::RelayPolicyIncompatible { reason },
Self::ProtocolIncompatible { .. } => Self::ProtocolIncompatible { reason },
Self::Transfer { .. } => Self::Transfer { reason }, Self::Transfer { .. } => Self::Transfer { reason },
Self::Permission { .. } => Self::Permission { reason }, Self::Permission { .. } => Self::Permission { reason },
Self::Repository { .. } => Self::Repository { reason }, Self::Repository { .. } => Self::Repository { reason },

View File

@@ -76,6 +76,20 @@ impl VnidropCore {
app_data_dir: String, app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>, event_sink: Arc<dyn CoreEventSink>,
store: Arc<dyn crate::secure_secret::SecureSecretStore>, store: Arc<dyn crate::secure_secret::SecureSecretStore>,
) -> Result<Arc<Self>, VnidropError> {
Self::initialize_with_test_secret_store_and_network(
app_data_dir,
event_sink,
store,
CoreNetworkConfig::default(),
)
}
pub(crate) fn initialize_with_test_secret_store_and_network(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
store: Arc<dyn crate::secure_secret::SecureSecretStore>,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> { ) -> Result<Arc<Self>, VnidropError> {
let app_data_path = PathBuf::from(&app_data_dir); let app_data_path = PathBuf::from(&app_data_dir);
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?; std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
@@ -86,7 +100,7 @@ impl VnidropCore {
app_data_dir, app_data_dir,
event_sink, event_sink,
CoreLimits::default(), CoreLimits::default(),
CoreNetworkConfig::default(), network_config,
IdentityMode::Protected { IdentityMode::Protected {
store, store,
profile_lock, profile_lock,
@@ -159,6 +173,21 @@ impl VnidropCore {
pub(crate) fn targeted_cancel_log_for_test(&self) -> Vec<String> { pub(crate) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
self.inner.targeted_cancel_log_for_test() self.inner.targeted_cancel_log_for_test()
} }
pub(crate) fn force_relationship_protocol_floor_for_test(
&self,
peer_endpoint_id: String,
minimum_protocol_version: u16,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.device_relationships
.force_minimum_protocol_version_for_test(
&peer_endpoint_id,
minimum_protocol_version,
),
)
}
} }
#[uniffi::export] #[uniffi::export]

View File

@@ -403,6 +403,8 @@ impl CoreInner {
event_hub.clone(), event_hub.clone(),
endpoint.id().to_string(), endpoint.id().to_string(),
endpoint.clone(), endpoint.clone(),
relay_mode,
relay_urls.clone(),
)); ));
let router = Router::builder(endpoint.clone()) let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs) .accept(iroh_blobs::ALPN, blobs)
@@ -422,6 +424,8 @@ impl CoreInner {
targeted_offers.clone(), targeted_offers.clone(),
limits.clone(), limits.clone(),
endpoint.id().to_string(), endpoint.id().to_string(),
relay_mode,
relay_urls.clone(),
), ),
) )
.spawn(); .spawn();
@@ -526,38 +530,7 @@ pub(crate) fn filter_peer_addr_for_relay_mode(
relay_mode: CoreRelayMode, relay_mode: CoreRelayMode,
custom_relay_urls: &[RelayUrl], custom_relay_urls: &[RelayUrl],
) -> Result<EndpointAddr> { ) -> Result<EndpointAddr> {
match relay_mode { crate::ticket::filter_peer_addr_for_relay_mode(addr, relay_mode, custom_relay_urls)
CoreRelayMode::Automatic => Ok(addr.clone()),
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
let mut filtered = EndpointAddr::new(addr.id);
for ip_addr in addr.ip_addrs().copied() {
filtered = filtered.with_ip_addr(ip_addr);
}
for relay_url in addr
.relay_urls()
.filter(|relay_url| custom_relay_urls.contains(relay_url))
.cloned()
{
filtered = filtered.with_relay_url(relay_url);
}
if filtered.is_empty() {
anyhow::bail!(
"invitation has no direct address or relay allowed by strict custom relay mode"
);
}
Ok(filtered)
}
CoreRelayMode::LocalOnly => {
let mut filtered = EndpointAddr::new(addr.id);
for ip_addr in addr.ip_addrs().copied() {
filtered = filtered.with_ip_addr(ip_addr);
}
if filtered.is_empty() {
anyhow::bail!("invitation has no direct address allowed by local-only mode");
}
Ok(filtered)
}
}
} }
pub(crate) async fn wait_for_relay( pub(crate) async fn wait_for_relay(

View File

@@ -15,8 +15,8 @@ use crate::{
error::VnidropError, error::VnidropError,
targeted_transfer::{ targeted_transfer::{
protocol::{ protocol::{
DeliverTargetedAuthorization, SubmitTargetedOffer, TargetedOfferResponse, map_offer_refuse_reason, DeliverTargetedAuthorization, SubmitTargetedOffer,
TargetedTransferProtocol, TargetedOfferResponse, TargetedTransferProtocol,
}, },
TargetedAuthorization, TargetedAuthorizationDraft, TargetedTransferRow, TargetedAuthorization, TargetedAuthorizationDraft, TargetedTransferRow,
TargetedTransferStore, TargetedTransferStore,
@@ -86,12 +86,16 @@ impl CoreInner {
Err(crate::targeted_transfer::RespondError::Unknown) => Err( Err(crate::targeted_transfer::RespondError::Unknown) => Err(
VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")), VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")),
), ),
Err(crate::targeted_transfer::RespondError::SenderGone) => Err(VnidropError::network( Err(crate::targeted_transfer::RespondError::SenderGone) => {
anyhow::anyhow!("sender disconnected before approval completed"), Err(VnidropError::device_unavailable(anyhow::anyhow!(
)), "sender disconnected before approval completed"
Err(crate::targeted_transfer::RespondError::AuthorizationTimeout) => Err( )))
VnidropError::network(anyhow::anyhow!("authorization was not delivered in time")), }
), Err(crate::targeted_transfer::RespondError::AuthorizationTimeout) => {
Err(VnidropError::offer_timeout(anyhow::anyhow!(
"authorization was not delivered in time"
)))
}
} }
} }
@@ -152,11 +156,34 @@ impl CoreInner {
.peer_addr(&receiver_endpoint_id) .peer_addr(&receiver_endpoint_id)
.await?; .await?;
let client = TargetedTransferProtocol::client(self.endpoint.clone(), addr); let client = TargetedTransferProtocol::client(self.endpoint.clone(), addr);
let challenge = tokio::time::timeout(OFFER_CONNECT_TIMEOUT, client.request_challenge()) let challenge =
.await match tokio::time::timeout(OFFER_CONNECT_TIMEOUT, client.request_challenge()).await {
.map_err(|_| VnidropError::network(anyhow::anyhow!("device did not answer in time")))? Ok(Ok(challenge)) => challenge,
.context("device is not reachable") Ok(Err(error)) => {
.map_err(VnidropError::network)?; let _ = store
.set_state(
&transfer_uuid,
TargetedTransferState::Offering,
TargetedTransferState::Failed,
)
.await;
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
return Err(map_connect_failure(error));
}
Err(_) => {
let _ = store
.set_state(
&transfer_uuid,
TargetedTransferState::Offering,
TargetedTransferState::Failed,
)
.await;
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
return Err(VnidropError::device_unavailable(anyhow::anyhow!(
"device did not answer in time"
)));
}
};
let (proof, generation, relationship_protocol_version) = self let (proof, generation, relationship_protocol_version) = self
.device_relationships .device_relationships
@@ -173,7 +200,7 @@ impl CoreInner {
) )
.await?; .await?;
let response = tokio::time::timeout( let response = match tokio::time::timeout(
OFFER_CONNECT_TIMEOUT + std::time::Duration::from_secs(120), OFFER_CONNECT_TIMEOUT + std::time::Duration::from_secs(120),
client.submit_offer(SubmitTargetedOffer { client.submit_offer(SubmitTargetedOffer {
proof, proof,
@@ -188,12 +215,42 @@ impl CoreInner {
transfer_name: share.transfer_name.clone(), transfer_name: share.transfer_name.clone(),
file_count: share.file_count, file_count: share.file_count,
total_size: share.total_size, total_size: share.total_size,
relay_mode: self.relay_mode,
relay_urls: self
.custom_relay_urls
.iter()
.map(ToString::to_string)
.collect(),
}), }),
) )
.await .await
.map_err(|_| VnidropError::network(anyhow::anyhow!("offer timed out")))? {
.context("failed to submit targeted offer") Ok(Ok(response)) => response,
.map_err(VnidropError::network)?; Ok(Err(error)) => {
let _ = store
.set_state(
&transfer_uuid,
TargetedTransferState::AwaitingApproval,
TargetedTransferState::Failed,
)
.await;
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
return Err(map_connect_failure(error));
}
Err(_) => {
let _ = store
.set_state(
&transfer_uuid,
TargetedTransferState::AwaitingApproval,
TargetedTransferState::Failed,
)
.await;
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
return Err(VnidropError::offer_timeout(anyhow::anyhow!(
"offer timed out"
)));
}
};
match response { match response {
TargetedOfferResponse::Accepted => {} TargetedOfferResponse::Accepted => {}
@@ -219,9 +276,7 @@ impl CoreInner {
) )
.await; .await;
let _ = self.cancel_idle_or_share(protocol_transfer_id).await; let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
return Err(VnidropError::permission(anyhow::anyhow!( return Err(map_offer_refuse_reason(&reason));
"targeted offer refused: {reason}"
)));
} }
} }
@@ -341,6 +396,21 @@ fn allocate_protocol_transfer_id(transfer_uuid: &str) -> u64 {
} }
} }
fn map_connect_failure(error: irpc::Error) -> VnidropError {
let rendered = error.to_string();
// ALPN / protocol negotiation failures are distinguishable from offline peers.
if rendered.contains("ALPN")
|| rendered.contains("alpn")
|| rendered.contains("protocol")
|| rendered.contains("unsupported")
{
return VnidropError::protocol_incompatible(anyhow::anyhow!(
"peer does not support saved-device targeted transfers"
));
}
VnidropError::device_unavailable(anyhow::anyhow!("device is not reachable: {rendered}"))
}
trait BlobTicketParse { trait BlobTicketParse {
fn from_str_compat(value: &str) -> Result<BlobTicket, String>; fn from_str_compat(value: &str) -> Result<BlobTicket, String>;
} }

View File

@@ -9,7 +9,7 @@ use anyhow::Result;
use iroh::{ use iroh::{
endpoint::Connection, endpoint::Connection,
protocol::{AcceptError, ProtocolHandler}, protocol::{AcceptError, ProtocolHandler},
Endpoint, EndpointAddr, Endpoint, EndpointAddr, RelayUrl,
}; };
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels}; use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
use irpc_iroh::{read_request, IrohLazyRemoteConnection}; use irpc_iroh::{read_request, IrohLazyRemoteConnection};
@@ -20,10 +20,11 @@ use super::{
inbox::{TargetedOfferDecision, TargetedOfferInbox}, inbox::{TargetedOfferDecision, TargetedOfferInbox},
}; };
use crate::{ use crate::{
api::{experimental_saved_device_capabilities, PendingTargetedOffer}, api::{experimental_saved_device_capabilities, CoreRelayMode, PendingTargetedOffer},
device_relationship::{DeviceRelationshipService, WireProof}, device_relationship::{DeviceRelationshipService, WireProof},
error::VnidropError, error::VnidropError,
grant::Challenge, grant::Challenge,
ticket::relay_profiles_compatible,
util::now_ms, util::now_ms,
}; };
@@ -33,6 +34,8 @@ pub(crate) struct TargetedTransferProtocol {
inbox: TargetedOfferInbox, inbox: TargetedOfferInbox,
limits: crate::api::CoreLimits, limits: crate::api::CoreLimits,
local_endpoint_id: String, local_endpoint_id: String,
relay_mode: CoreRelayMode,
custom_relay_urls: Vec<RelayUrl>,
} }
impl fmt::Debug for TargetedTransferProtocol { impl fmt::Debug for TargetedTransferProtocol {
@@ -49,12 +52,16 @@ impl TargetedTransferProtocol {
inbox: TargetedOfferInbox, inbox: TargetedOfferInbox,
limits: crate::api::CoreLimits, limits: crate::api::CoreLimits,
local_endpoint_id: String, local_endpoint_id: String,
relay_mode: CoreRelayMode,
custom_relay_urls: Vec<RelayUrl>,
) -> Self { ) -> Self {
Self { Self {
relationships, relationships,
inbox, inbox,
limits, limits,
local_endpoint_id, local_endpoint_id,
relay_mode,
custom_relay_urls,
} }
} }
@@ -111,6 +118,25 @@ impl TargetedTransferProtocol {
}; };
} }
let remote_urls = match parse_offer_relay_urls(&offer.relay_urls) {
Ok(urls) => urls,
Err(_) => {
return TargetedOfferResponse::Refused {
reason: "relay-policy-incompatible".to_string(),
};
}
};
if !relay_profiles_compatible(
self.relay_mode,
&self.custom_relay_urls,
offer.relay_mode,
&remote_urls,
) {
return TargetedOfferResponse::Refused {
reason: "relay-policy-incompatible".to_string(),
};
}
if let Err(error) = self if let Err(error) = self
.relationships .relationships
.verify_saved_possession( .verify_saved_possession(
@@ -123,6 +149,11 @@ impl TargetedTransferProtocol {
.await .await
{ {
tracing::debug!(%error, "targeted offer relationship proof rejected"); tracing::debug!(%error, "targeted offer relationship proof rejected");
if matches!(error, VnidropError::ProtocolIncompatible { .. }) {
return TargetedOfferResponse::Refused {
reason: "protocol-incompatible".to_string(),
};
}
return TargetedOfferResponse::Refused { return TargetedOfferResponse::Refused {
reason: "unauthenticated".to_string(), reason: "unauthenticated".to_string(),
}; };
@@ -258,6 +289,8 @@ pub(crate) struct SubmitTargetedOffer {
pub(crate) transfer_name: String, pub(crate) transfer_name: String,
pub(crate) file_count: u64, pub(crate) file_count: u64,
pub(crate) total_size: u64, pub(crate) total_size: u64,
pub(crate) relay_mode: CoreRelayMode,
pub(crate) relay_urls: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -281,6 +314,10 @@ pub(crate) enum DeliverAuthorizationResponse {
#[rpc_requests(message = TargetedTransferMessage)] #[rpc_requests(message = TargetedTransferMessage)]
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[allow(
clippy::large_enum_variant,
reason = "offer payload carries relay profile + manifest summary; boxing breaks irpc channels"
)]
enum TargetedTransferMessages { enum TargetedTransferMessages {
#[rpc(tx = oneshot::Sender<ChallengeResponse>)] #[rpc(tx = oneshot::Sender<ChallengeResponse>)]
RequestChallenge(RequestChallenge), RequestChallenge(RequestChallenge),
@@ -290,8 +327,26 @@ enum TargetedTransferMessages {
DeliverTargetedAuthorization(DeliverTargetedAuthorization), DeliverTargetedAuthorization(DeliverTargetedAuthorization),
} }
/// Helper kept for type visibility in callers that map refuse reasons. fn parse_offer_relay_urls(values: &[String]) -> Result<Vec<RelayUrl>, ()> {
#[allow(dead_code)] let mut urls = Vec::with_capacity(values.len());
pub(crate) fn map_offer_error(reason: &str) -> VnidropError { for value in values {
VnidropError::permission(anyhow::anyhow!("targeted offer refused: {reason}")) let Ok(url) = value.parse::<RelayUrl>() else {
return Err(());
};
urls.push(url);
}
Ok(urls)
}
/// Map a receiver refuse reason to a typed public error (design §13).
pub(crate) fn map_offer_refuse_reason(reason: &str) -> VnidropError {
match reason {
"relay-policy-incompatible" => VnidropError::relay_policy_incompatible(anyhow::anyhow!(
"sender and receiver network profiles are incompatible"
)),
"protocol-incompatible" => VnidropError::protocol_incompatible(anyhow::anyhow!(
"targeted-transfer protocol is incompatible"
)),
other => VnidropError::permission(anyhow::anyhow!("targeted offer refused: {other}")),
}
} }

View File

@@ -68,3 +68,29 @@ fn transfer_boundary_classifies_database_failures() {
assert!(matches!(transfer, VnidropError::Repository { .. })); assert!(matches!(transfer, VnidropError::Repository { .. }));
assert!(matches!(approval, VnidropError::Repository { .. })); assert!(matches!(approval, VnidropError::Repository { .. }));
} }
#[test]
fn saved_device_failures_remain_distinguishable() {
let unavailable = VnidropError::device_unavailable(anyhow::anyhow!("offline"));
let timeout = VnidropError::offer_timeout(anyhow::anyhow!("no answer"));
let relay = VnidropError::relay_policy_incompatible(anyhow::anyhow!("profiles differ"));
let protocol = VnidropError::protocol_incompatible(anyhow::anyhow!("downgrade"));
assert_eq!(unavailable.code(), "device_unavailable");
assert_eq!(timeout.code(), "offer_timeout");
assert_eq!(relay.code(), "relay_policy_incompatible");
assert_eq!(protocol.code(), "protocol_incompatible");
assert!(matches!(
unavailable,
VnidropError::DeviceUnavailable { .. }
));
assert!(matches!(timeout, VnidropError::OfferTimeout { .. }));
assert!(matches!(
relay,
VnidropError::RelayPolicyIncompatible { .. }
));
assert!(matches!(
protocol,
VnidropError::ProtocolIncompatible { .. }
));
}

View File

@@ -5,9 +5,9 @@ use std::{
}; };
use crate::{ use crate::{
secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink, DeviceRelationshipState, secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink, CoreNetworkConfig,
PendingTargetedOffer, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState, CoreRelayMode, DeviceRelationshipState, PendingTargetedOffer, ShareMetadataInput, ShareSource,
TransferAccessMode, VnidropCore, VnidropError, SourceKind, TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError,
}; };
struct RecordingSink { struct RecordingSink {
@@ -27,15 +27,20 @@ struct ProtectedNode {
impl ProtectedNode { impl ProtectedNode {
fn new() -> Self { fn new() -> Self {
Self::with_network_config(CoreNetworkConfig::default())
}
fn with_network_config(network_config: CoreNetworkConfig) -> Self {
let data_dir = tempfile::tempdir().unwrap(); let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink { let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()), events: Mutex::new(Vec::new()),
}); });
let store = Arc::new(FaultInjectingSecretStore::default()); let store = Arc::new(FaultInjectingSecretStore::default());
let core = VnidropCore::initialize_with_test_secret_store( let core = VnidropCore::initialize_with_test_secret_store_and_network(
data_dir.path().to_string_lossy().into_owned(), data_dir.path().to_string_lossy().into_owned(),
sink, sink,
store, store,
network_config,
) )
.expect("protected test core"); .expect("protected test core");
Self { Self {
@@ -469,3 +474,283 @@ fn invitation_multi_receiver_shares_remain_independently_authorized() {
); );
} }
} }
fn complete_targeted_roundtrip(alice: &ProtectedNode, bob: &ProtectedNode, transfer_id: u64) {
establish_saved(alice, bob, transfer_id);
let bob_id = bob.core.status().endpoint_id.clone();
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("payload.txt");
let payload = b"profile-honoring payload";
std::fs::write(&source_path, payload).unwrap();
let bob_core = bob.core.clone();
let accept = std::thread::spawn(move || {
let offer = wait_for_pending_offer(&bob_core);
bob_core
.respond_to_targeted_offer(offer.transfer_id, true)
.unwrap()
});
let transfer = alice
.core
.create_targeted_transfer(
bob_id,
vec![targeted_source(&source_path)],
Some("payload.txt".to_string()),
)
.unwrap();
let auth = accept.join().unwrap().expect("authorization");
let output = tempfile::tempdir().unwrap();
bob.core
.receive_targeted_transfer(auth, output.path().to_string_lossy().into_owned())
.unwrap();
assert_eq!(
std::fs::read(output.path().join("payload.txt")).unwrap(),
payload
);
assert!(!transfer.id.is_empty());
}
#[test]
fn targeted_transfer_honors_automatic_network_profile() {
let alice = ProtectedNode::with_network_config(CoreNetworkConfig {
mode: CoreRelayMode::Automatic,
relay_urls: Vec::new(),
});
let bob = ProtectedNode::with_network_config(CoreNetworkConfig {
mode: CoreRelayMode::Automatic,
relay_urls: Vec::new(),
});
complete_targeted_roundtrip(&alice, &bob, 12_001);
}
#[test]
fn targeted_transfer_honors_strict_custom_and_direct_fallback_profiles() {
// Loopback HTTP relays are the supported custom-relay development path.
let relay = start_loopback_relay();
let urls = vec![relay.url.clone()];
for mode in [
CoreRelayMode::StrictCustom,
CoreRelayMode::CustomWithDirectFallback,
] {
let config = CoreNetworkConfig {
mode,
relay_urls: urls.clone(),
};
let alice = ProtectedNode::with_network_config(config.clone());
let bob = ProtectedNode::with_network_config(config);
let transfer_id = match mode {
CoreRelayMode::StrictCustom => 12_010,
CoreRelayMode::CustomWithDirectFallback => 12_011,
_ => unreachable!(),
};
complete_targeted_roundtrip(&alice, &bob, transfer_id);
}
}
#[test]
fn targeted_transfer_local_only_uses_direct_reachability_without_relays() {
let config = CoreNetworkConfig {
mode: CoreRelayMode::LocalOnly,
relay_urls: Vec::new(),
};
let alice = ProtectedNode::with_network_config(config.clone());
let bob = ProtectedNode::with_network_config(config);
assert!(!alice.core.status().addr.contains("iroh.link"));
assert!(!bob.core.status().addr.contains("iroh.link"));
complete_targeted_roundtrip(&alice, &bob, 12_020);
}
#[test]
fn mismatched_relay_profiles_are_typed_and_never_reinterpreted_as_ordinary_share() {
let relay = start_loopback_relay();
let alice = ProtectedNode::with_network_config(CoreNetworkConfig {
mode: CoreRelayMode::Automatic,
relay_urls: Vec::new(),
});
let bob = ProtectedNode::with_network_config(CoreNetworkConfig {
mode: CoreRelayMode::StrictCustom,
relay_urls: vec![relay.url.clone()],
});
establish_saved(&alice, &bob, 12_030);
let bob_id = bob.core.status().endpoint_id.clone();
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("payload.txt");
std::fs::write(&source_path, b"incompatible profiles").unwrap();
let err = alice
.core
.create_targeted_transfer(
bob_id,
vec![targeted_source(&source_path)],
Some("payload.txt".to_string()),
)
.unwrap_err();
assert!(
matches!(err, VnidropError::RelayPolicyIncompatible { .. }),
"expected relay-policy incompatibility, got {err:?}"
);
assert!(bob.core.list_pending_targeted_offers().is_empty());
let failed = alice
.core
.list_targeted_transfers()
.unwrap()
.into_iter()
.find(|entry| matches!(entry.state, TargetedTransferState::Failed));
assert!(
failed.is_some(),
"failed targeted transfer must remain targeted, not an ordinary share"
);
}
#[test]
fn protocol_floor_rejects_silent_downgrade_with_typed_error() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
establish_saved(&alice, &bob, 12_040);
let alice_id = alice.core.status().endpoint_id.clone();
let bob_id = bob.core.status().endpoint_id.clone();
bob.core
.force_relationship_protocol_floor_for_test(alice_id, 2)
.unwrap();
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("payload.txt");
std::fs::write(&source_path, b"downgrade attempt").unwrap();
let err = alice
.core
.create_targeted_transfer(
bob_id,
vec![targeted_source(&source_path)],
Some("payload.txt".to_string()),
)
.unwrap_err();
assert!(
matches!(err, VnidropError::ProtocolIncompatible { .. }),
"expected protocol incompatibility, got {err:?}"
);
assert!(bob.core.list_pending_targeted_offers().is_empty());
}
#[test]
fn incompatible_network_or_protocol_peers_keep_ordinary_invitation_flow() {
let relay = start_loopback_relay();
// Profiles that cannot complete a targeted transfer can still use ordinary shares.
let alice = ProtectedNode::with_network_config(CoreNetworkConfig {
mode: CoreRelayMode::Automatic,
relay_urls: Vec::new(),
});
let bob = ProtectedNode::with_network_config(CoreNetworkConfig {
mode: CoreRelayMode::StrictCustom,
relay_urls: vec![relay.url.clone()],
});
complete_transfer(&alice, &bob, 12_050);
assert!(alice
.core
.list_pairing_eligibilities()
.unwrap()
.iter()
.any(|entry| entry.peer_endpoint_id == bob.core.status().endpoint_id));
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("invite.txt");
std::fs::write(&source_path, b"ordinary invitation still works").unwrap();
let share = alice
.core
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().into_owned(),
display_name: Some("invite.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 12_051,
transfer_name: Some("invite".to_string()),
sender_name: Some("alice".to_string()),
access_mode: TransferAccessMode::Public,
},
)
.unwrap();
bob.core
.receive(
share.ticket,
output_dir.path().to_string_lossy().into_owned(),
Some("bob".to_string()),
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("invite.txt")).unwrap(),
b"ordinary invitation still works"
);
}
#[test]
fn map_offer_refuse_reasons_stay_typed() {
use crate::targeted_transfer::protocol::map_offer_refuse_reason;
assert!(matches!(
map_offer_refuse_reason("relay-policy-incompatible"),
VnidropError::RelayPolicyIncompatible { .. }
));
assert!(matches!(
map_offer_refuse_reason("protocol-incompatible"),
VnidropError::ProtocolIncompatible { .. }
));
assert!(matches!(
map_offer_refuse_reason("unauthenticated"),
VnidropError::Permission { .. }
));
}
struct LoopbackRelay {
url: String,
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Drop for LoopbackRelay {
fn drop(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn start_loopback_relay() -> LoopbackRelay {
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let thread = std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name("vnidrop-ticket12-relay")
.build()
.unwrap();
runtime.block_on(async move {
let relay = iroh_relay::server::RelayConfig::new((std::net::Ipv4Addr::LOCALHOST, 0));
let mut config = iroh_relay::server::ServerConfig::default();
config.relay = Some(relay);
let server = match iroh_relay::server::Server::spawn(config).await {
Ok(server) => server,
Err(error) => {
ready_tx.send(Err(error.to_string())).ok();
return;
}
};
let url = format!("http://{}", server.http_addr().expect("http addr"));
ready_tx.send(Ok(url)).ok();
let _ = shutdown_rx.await;
let _ = server.shutdown().await;
});
});
let url = ready_rx.recv().unwrap().expect("relay started");
LoopbackRelay {
url,
shutdown: Some(shutdown_tx),
thread: Some(thread),
}
}

View File

@@ -9,7 +9,8 @@ use crate::{
api::{CoreLimits, CoreRelayMode, TransferMetadata}, api::{CoreLimits, CoreRelayMode, TransferMetadata},
ticket::{ ticket::{
encode_persisted_sender_address, parse_persisted_sender_address, parse_transfer_ticket, encode_persisted_sender_address, parse_persisted_sender_address, parse_transfer_ticket,
parse_transfer_ticket_with_limits, ticket_matches_relay_profile, VnidropTicket, parse_transfer_ticket_with_limits, relay_profiles_compatible, ticket_matches_relay_profile,
VnidropTicket,
}, },
}; };
@@ -285,3 +286,60 @@ fn parser_rejects_or_parses_generated_inputs_without_panicking() {
let _ = parse_transfer_ticket(&input); let _ = parse_transfer_ticket(&input);
} }
} }
#[test]
fn relay_profiles_compatible_matches_network_profile_matrix() {
let relay: RelayUrl = "https://relay.example.com".parse().unwrap();
let other: RelayUrl = "https://other.example.com".parse().unwrap();
let relay_urls = std::slice::from_ref(&relay);
let other_urls = std::slice::from_ref(&other);
assert!(relay_profiles_compatible(
CoreRelayMode::Automatic,
&[],
CoreRelayMode::Automatic,
&[],
));
assert!(relay_profiles_compatible(
CoreRelayMode::LocalOnly,
&[],
CoreRelayMode::LocalOnly,
&[],
));
assert!(relay_profiles_compatible(
CoreRelayMode::LocalOnly,
&[],
CoreRelayMode::Automatic,
&[],
));
assert!(relay_profiles_compatible(
CoreRelayMode::StrictCustom,
relay_urls,
CoreRelayMode::StrictCustom,
relay_urls,
));
assert!(relay_profiles_compatible(
CoreRelayMode::CustomWithDirectFallback,
relay_urls,
CoreRelayMode::StrictCustom,
relay_urls,
));
assert!(!relay_profiles_compatible(
CoreRelayMode::Automatic,
&[],
CoreRelayMode::StrictCustom,
relay_urls,
));
assert!(!relay_profiles_compatible(
CoreRelayMode::StrictCustom,
relay_urls,
CoreRelayMode::StrictCustom,
other_urls,
));
assert!(!relay_profiles_compatible(
CoreRelayMode::LocalOnly,
&[],
CoreRelayMode::StrictCustom,
relay_urls,
));
}

View File

@@ -184,6 +184,76 @@ pub(crate) fn ticket_matches_relay_profile(
} }
} }
/// Whether a remote peer's advertised network profile can be used under the
/// local profile (design §3 / §10 relay-policy validation).
pub(crate) fn relay_profiles_compatible(
local_mode: CoreRelayMode,
local_urls: &[RelayUrl],
remote_mode: CoreRelayMode,
remote_urls: &[RelayUrl],
) -> bool {
match (local_mode, remote_mode) {
(CoreRelayMode::Automatic, CoreRelayMode::Automatic) => {
local_urls.is_empty() && remote_urls.is_empty()
}
(CoreRelayMode::LocalOnly, CoreRelayMode::LocalOnly)
| (CoreRelayMode::LocalOnly, CoreRelayMode::Automatic)
| (CoreRelayMode::Automatic, CoreRelayMode::LocalOnly) => {
// Local-only never enables relay fallback; Automatic peers are only
// compatible when neither side advertises custom relays.
local_urls.is_empty() && remote_urls.is_empty()
}
(
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback,
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback,
) => {
let local = local_urls.iter().collect::<BTreeSet<_>>();
let remote = remote_urls.iter().collect::<BTreeSet<_>>();
!local.is_empty() && local == remote
}
_ => false,
}
}
pub(crate) fn filter_peer_addr_for_relay_mode(
addr: &EndpointAddr,
relay_mode: CoreRelayMode,
custom_relay_urls: &[RelayUrl],
) -> Result<EndpointAddr> {
match relay_mode {
CoreRelayMode::Automatic => Ok(addr.clone()),
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
let mut filtered = EndpointAddr::new(addr.id);
for ip_addr in addr.ip_addrs().copied() {
filtered = filtered.with_ip_addr(ip_addr);
}
for relay_url in addr
.relay_urls()
.filter(|relay_url| custom_relay_urls.contains(relay_url))
.cloned()
{
filtered = filtered.with_relay_url(relay_url);
}
if filtered.is_empty() {
anyhow::bail!(
"invitation has no direct address or relay allowed by strict custom relay mode"
);
}
Ok(filtered)
}
CoreRelayMode::LocalOnly => {
let mut filtered = EndpointAddr::new(addr.id);
for ip_addr in addr.ip_addrs().copied() {
filtered = filtered.with_ip_addr(ip_addr);
}
if filtered.is_empty() {
anyhow::bail!("invitation has no direct address allowed by local-only mode");
}
Ok(filtered)
}
}
}
fn normalize_ticket_input(value: &str) -> String { fn normalize_ticket_input(value: &str) -> String {
// Tickets are commonly copied from text views or chat apps that insert line // Tickets are commonly copied from text views or chat apps that insert line
// breaks. Strip whitespace only; other corrupt characters should still be // breaks. Strip whitespace only; other corrupt characters should still be