feat(core): save devices through mutual consent and grants

Establish PendingOutgoing/PendingIncoming relationships over a token-bound
pairing protocol, exchange directional grants with challenge-response proofs
and a final ack before Saved, and merge simultaneous initiations without a
second prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 03:03:43 +02:00
parent 04a31e8a24
commit 193fe7c757
13 changed files with 1933 additions and 8 deletions

View File

@@ -0,0 +1,134 @@
//! Relationship-grant possession proofs (design §7).
use crate::{
error::VnidropError,
grant::{Challenge, GrantId, GrantProof, GrantSecret},
secure_secret::SecretMaterial,
};
const RELATIONSHIP_GRANT_CONTEXT: &[u8] = b"vnidrop-relationship-grant-v1";
pub(super) fn encode_relationship_grant_secret(
secret: &GrantSecret,
) -> Result<SecretMaterial, VnidropError> {
// Custody stores only the 32-byte secret; issuer/holder/generation/protocol
// bindings live in the relationship row and are enforced at prove/verify time.
SecretMaterial::new(secret.as_bytes().to_vec())
}
pub(super) fn secret_from_material(material: &SecretMaterial) -> Result<GrantSecret, VnidropError> {
let bytes: [u8; 32] =
material
.to_vec()
.try_into()
.map_err(|_| VnidropError::SecureStorageCorrupted {
reason: "relationship grant secret has invalid length".to_string(),
})?;
Ok(GrantSecret::from_bytes(bytes))
}
pub(super) fn prove_relationship_grant(
grant_id: GrantId,
secret: &GrantSecret,
challenge: &Challenge,
issuer: &str,
holder: &str,
generation: u64,
protocol_version: u16,
) -> GrantProof {
let mac = relationship_mac(
secret,
challenge,
issuer,
holder,
generation,
protocol_version,
);
GrantProof::from_parts(grant_id, mac)
}
pub(super) fn verify_relationship_grant(
secret: &GrantSecret,
proof: &GrantProof,
challenge: &Challenge,
issuer: &str,
holder: &str,
generation: u64,
protocol_version: u16,
) -> Result<(), &'static str> {
let expected = relationship_mac(
secret,
challenge,
issuer,
holder,
generation,
protocol_version,
);
if blake3::Hash::from_bytes(expected) != blake3::Hash::from_bytes(*proof.mac()) {
return Err("bad relationship grant proof");
}
Ok(())
}
fn relationship_mac(
secret: &GrantSecret,
challenge: &Challenge,
issuer: &str,
holder: &str,
generation: u64,
protocol_version: u16,
) -> [u8; 32] {
let mut hasher = blake3::Hasher::new_keyed(secret.as_bytes());
hasher.update(RELATIONSHIP_GRANT_CONTEXT);
hasher.update(challenge.as_bytes());
hasher.update(&(issuer.len() as u64).to_le_bytes());
hasher.update(issuer.as_bytes());
hasher.update(&(holder.len() as u64).to_le_bytes());
hasher.update(holder.as_bytes());
hasher.update(&generation.to_le_bytes());
hasher.update(&protocol_version.to_le_bytes());
*hasher.finalize().as_bytes()
}
#[cfg(test)]
mod grant_vectors {
use super::*;
use crate::api::experimental_saved_device_capabilities;
use data_encoding::HEXLOWER;
#[test]
fn relationship_grant_proof_vectors_are_stable() {
let secret =
GrantSecret::decode("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
.unwrap();
let grant_id = GrantId::decode("0123456789abcdef0123456789abcdef").unwrap();
let challenge = Challenge::from_bytes([9u8; 32]);
let protocol = experimental_saved_device_capabilities().relationship_protocol_version;
let proof = prove_relationship_grant(
grant_id, &secret, &challenge, "issuer", "holder", 1, protocol,
);
// Binding and replay resistance: wrong holder or challenge must fail.
verify_relationship_grant(&secret, &proof, &challenge, "issuer", "holder", 1, protocol)
.unwrap();
let mac_hex = HEXLOWER.encode(proof.mac());
assert_eq!(
mac_hex,
"e6cc2641183b84fae9e3805761961d69e09d25a1f8ceeaeede952774ddd95d6b"
);
assert!(verify_relationship_grant(
&secret, &proof, &challenge, "issuer", "other", 1, protocol,
)
.is_err());
let other_challenge = Challenge::from_bytes([8u8; 32]);
assert!(verify_relationship_grant(
&secret,
&proof,
&other_challenge,
"issuer",
"holder",
1,
protocol,
)
.is_err());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,14 @@ impl GrantSecret {
Self(random_bytes()) Self(random_bytes())
} }
pub(crate) fn as_bytes(&self) -> &[u8; GRANT_SECRET_LEN] {
&self.0
}
pub(crate) fn from_bytes(bytes: [u8; GRANT_SECRET_LEN]) -> Self {
Self(bytes)
}
pub(crate) fn encode(&self) -> String { pub(crate) fn encode(&self) -> String {
HEXLOWER.encode(&self.0) HEXLOWER.encode(&self.0)
} }
@@ -96,10 +104,28 @@ impl Challenge {
Self(random_bytes()) Self(random_bytes())
} }
pub(crate) fn as_bytes(&self) -> &[u8; CHALLENGE_LEN] {
&self.0
}
#[cfg(test)] #[cfg(test)]
pub(crate) fn from_bytes(bytes: [u8; CHALLENGE_LEN]) -> Self { pub(crate) fn from_bytes(bytes: [u8; CHALLENGE_LEN]) -> Self {
Self(bytes) Self(bytes)
} }
pub(crate) fn encode(&self) -> String {
HEXLOWER.encode(&self.0)
}
pub(crate) fn decode(value: &str) -> Result<Self> {
let bytes = HEXLOWER
.decode(value.as_bytes())
.context("invalid challenge encoding")?;
let bytes: [u8; CHALLENGE_LEN] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("invalid challenge length"))?;
Ok(Self(bytes))
}
} }
impl fmt::Debug for Challenge { impl fmt::Debug for Challenge {
@@ -115,6 +141,16 @@ pub(crate) struct GrantProof {
mac: [u8; PROOF_LEN], mac: [u8; PROOF_LEN],
} }
impl GrantProof {
pub(crate) fn from_parts(grant_id: GrantId, mac: [u8; PROOF_LEN]) -> Self {
Self { grant_id, mac }
}
pub(crate) fn mac(&self) -> &[u8; PROOF_LEN] {
&self.mac
}
}
impl fmt::Debug for GrantProof { impl fmt::Debug for GrantProof {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GrantProof") f.debug_struct("GrantProof")

View File

@@ -2,6 +2,7 @@ mod access_policy;
mod api; mod api;
mod approval; mod approval;
mod contacts; mod contacts;
mod device_relationship;
mod error; mod error;
mod event_hub; mod event_hub;
mod filesystem; mod filesystem;

View File

@@ -148,24 +148,54 @@ impl PairingEligibilityService {
/// ///
/// Returns `false` when eligibility is missing/expired (silent reject). A /// Returns `false` when eligibility is missing/expired (silent reject). A
/// successful start consumes the single-use eligibility for that session. /// successful start consumes the single-use eligibility for that session.
#[allow(
dead_code,
reason = "retained for eligibility-only callers; mutual consent uses take_eligibility"
)]
pub(crate) async fn request_pairing( pub(crate) async fn request_pairing(
&self, &self,
peer_endpoint_id: &str, peer_endpoint_id: &str,
) -> Result<bool, VnidropError> { ) -> Result<bool, VnidropError> {
Ok(self.take_eligibility(peer_endpoint_id).await?.is_some())
}
/// Takes and consumes eligibility for a peer, returning the capability material.
pub(crate) async fn take_eligibility(
&self,
peer_endpoint_id: &str,
) -> Result<Option<TakenEligibility>, VnidropError> {
self.expire_due(true).await?; self.expire_due(true).await?;
let entries = self let entries = self
.repository .repository
.list_pairing_eligibilities_for_peer(peer_endpoint_id) .list_pairing_eligibilities_for_peer(peer_endpoint_id)
.await?; .await?;
let Some(entry) = entries.into_iter().next() else { let Some(entry) = entries.into_iter().next() else {
return Ok(false); return Ok(None);
}; };
if entry.expires_at <= now_ms() { if entry.expires_at <= now_ms() {
self.delete_entry_silent(&entry).await?; self.delete_entry_silent(&entry).await?;
return Ok(false); return Ok(None);
} }
let Some(custody) = &self.custody else {
self.delete_entry_silent(&entry).await?;
return Ok(None);
};
let capability = match custody
.load(&SecretHandle::from_stored(entry.secret_handle.clone()))
.await
{
Ok(material) => material,
Err(_) => {
self.delete_entry_silent(&entry).await?;
return Ok(None);
}
};
self.delete_entry(&entry).await?; self.delete_entry(&entry).await?;
Ok(true) Ok(Some(TakenEligibility {
session_id: entry.session_id,
protocol_version: entry.protocol_version,
capability,
}))
} }
/// Validates an inbound eligibility presentation without prompts or events on failure. /// Validates an inbound eligibility presentation without prompts or events on failure.
@@ -189,6 +219,24 @@ impl PairingEligibilityService {
Ok(true) Ok(true)
} }
/// Consumes eligibility for one session without requiring the capability bytes.
pub(crate) async fn consume_session(
&self,
peer_endpoint_id: &str,
session_id: &str,
) -> Result<(), VnidropError> {
if let Some(entry) = self
.repository
.find_pairing_eligibility_by_session(session_id)
.await?
{
if entry.peer_endpoint_id == peer_endpoint_id {
self.delete_entry(&entry).await?;
}
}
Ok(())
}
pub(crate) async fn decline(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> { pub(crate) async fn decline(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> {
self.remove_for_peer(peer_endpoint_id).await self.remove_for_peer(peer_endpoint_id).await
} }
@@ -304,6 +352,13 @@ pub(crate) struct PairingEligibilityInsert<'a> {
pub(crate) expires_at: i64, pub(crate) expires_at: i64,
} }
#[derive(Debug, Clone)]
pub(crate) struct TakenEligibility {
pub(crate) session_id: String,
pub(crate) protocol_version: u16,
pub(crate) capability: SecretMaterial,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PairingEligibilityRecord { pub(crate) struct PairingEligibilityRecord {
pub(crate) peer_endpoint_id: String, pub(crate) peer_endpoint_id: String,

View File

@@ -26,7 +26,7 @@ use crate::{
util::now_ms, util::now_ms,
}; };
const SCHEMA_VERSION: i64 = 11; const SCHEMA_VERSION: i64 = 13;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct Repository { pub(crate) struct Repository {
@@ -323,6 +323,7 @@ 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?;
crate::device_relationship::DeviceRelationshipService::ensure_schema(&self.pool).await?;
sqlx::query( sqlx::query(
r#" r#"
CREATE TABLE IF NOT EXISTS pairing_eligibilities ( CREATE TABLE IF NOT EXISTS pairing_eligibilities (
@@ -358,6 +359,10 @@ impl Repository {
ContactStore::new(self.pool.clone()) ContactStore::new(self.pool.clone())
} }
pub(crate) fn sqlite_pool(&self) -> SqlitePool {
self.pool.clone()
}
#[allow( #[allow(
dead_code, dead_code,
reason = "the private custody seam is activated by platform credential adapters" reason = "the private custody seam is activated by platform credential adapters"

View File

@@ -59,8 +59,30 @@ impl CoreInner {
&self, &self,
peer_endpoint_id: String, peer_endpoint_id: String,
) -> Result<bool, crate::error::VnidropError> { ) -> Result<bool, crate::error::VnidropError> {
self.pairing_eligibility self.device_relationships
.request_pairing(&peer_endpoint_id) .request_pairing(peer_endpoint_id)
.await
}
pub(super) async fn list_device_relationships(
&self,
) -> Result<Vec<crate::api::DeviceRelationship>, crate::error::VnidropError> {
self.device_relationships.list().await
}
pub(super) async fn list_saved_devices(
&self,
) -> Result<Vec<crate::api::SavedDevice>, crate::error::VnidropError> {
self.device_relationships.list_saved_devices().await
}
pub(super) async fn respond_to_device_pairing(
self: &Arc<Self>,
peer_endpoint_id: String,
accepted: bool,
) -> Result<bool, crate::error::VnidropError> {
self.device_relationships
.respond_to_pairing(peer_endpoint_id, accepted)
.await .await
} }

View File

@@ -397,6 +397,27 @@ impl VnidropCore {
self.block_on(self.inner.request_saved_device_pairing(peer_endpoint_id)) self.block_on(self.inner.request_saved_device_pairing(peer_endpoint_id))
} }
pub fn list_device_relationships(
&self,
) -> Result<Vec<crate::api::DeviceRelationship>, VnidropError> {
self.block_on(self.inner.list_device_relationships())
}
pub fn list_saved_devices(&self) -> Result<Vec<crate::api::SavedDevice>, VnidropError> {
self.block_on(self.inner.list_saved_devices())
}
pub fn respond_to_device_pairing(
&self,
peer_endpoint_id: String,
accepted: bool,
) -> Result<bool, VnidropError> {
self.block_on(
self.inner
.respond_to_device_pairing(peer_endpoint_id, accepted),
)
}
/// Devices the user has chosen to remember. /// Devices the user has chosen to remember.
pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> { pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> {
self.block_on(self.inner.list_contacts()) self.block_on(self.inner.list_contacts())

View File

@@ -56,6 +56,7 @@ use crate::{
access_policy::{mode_from_storage, AccessPolicy}, access_policy::{mode_from_storage, AccessPolicy},
api::{CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode}, api::{CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode},
approval::ApprovalService, approval::ApprovalService,
device_relationship::{DeviceRelationshipService, RelationshipProtocol},
event_hub::EventHub, event_hub::EventHub,
handshake::HandshakeService, handshake::HandshakeService,
logging::init_logging, logging::init_logging,
@@ -106,6 +107,7 @@ pub(super) struct CoreInner {
pub(super) approval: ApprovalService, pub(super) approval: ApprovalService,
pub(super) pairing: PairingService, pub(super) pairing: PairingService,
pub(super) pairing_eligibility: PairingEligibilityService, pub(super) pairing_eligibility: PairingEligibilityService,
pub(super) device_relationships: Arc<DeviceRelationshipService>,
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>>,
@@ -385,6 +387,14 @@ impl CoreInner {
tracing::warn!(%error, "failed to sweep dead grants"); tracing::warn!(%error, "failed to sweep dead grants");
} }
let offers = OfferInbox::new(event_hub.clone(), limits.max_pending_offers as usize); let offers = OfferInbox::new(event_hub.clone(), limits.max_pending_offers as usize);
let device_relationships = Arc::new(DeviceRelationshipService::new(
repository.sqlite_pool(),
secret_custody.clone(),
pairing_eligibility.clone(),
event_hub.clone(),
endpoint.id().to_string(),
endpoint.clone(),
));
let router = Router::builder(endpoint.clone()) let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs) .accept(iroh_blobs::ALPN, blobs)
.accept(HandshakeService::ALPN, handshake) .accept(HandshakeService::ALPN, handshake)
@@ -392,6 +402,10 @@ impl CoreInner {
OfferService::ALPN, OfferService::ALPN,
OfferService::new(pairing.clone(), offers.clone(), endpoint.id().to_string()), OfferService::new(pairing.clone(), offers.clone(), endpoint.id().to_string()),
) )
.accept(
RelationshipProtocol::ALPN,
RelationshipProtocol::new(device_relationships.clone()),
)
.spawn(); .spawn();
let inner = Arc::new(Self { let inner = Arc::new(Self {
@@ -405,6 +419,7 @@ impl CoreInner {
approval, approval,
pairing, pairing,
pairing_eligibility, pairing_eligibility,
device_relationships,
offers, offers,
last_polled: TokioMutex::new(HashMap::new()), last_polled: TokioMutex::new(HashMap::new()),
relay_mode, relay_mode,
@@ -436,6 +451,9 @@ impl CoreInner {
if let Err(error) = inner.pairing_eligibility.reconcile().await { if let Err(error) = inner.pairing_eligibility.reconcile().await {
tracing::warn!(%error, "failed to reconcile pairing eligibility"); tracing::warn!(%error, "failed to reconcile pairing eligibility");
} }
if let Err(error) = inner.device_relationships.reconcile().await {
tracing::warn!(%error, "failed to reconcile device relationships");
}
Ok(inner) Ok(inner)
} }

View File

@@ -52,6 +52,14 @@ impl SecretMaterial {
let bytes: [u8; SECRET_BYTES] = self.0.try_into().expect("validated length"); let bytes: [u8; SECRET_BYTES] = self.0.try_into().expect("validated length");
SecretKey::from_bytes(&bytes) SecretKey::from_bytes(&bytes)
} }
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.0
}
pub(crate) fn to_vec(&self) -> Vec<u8> {
self.0.clone()
}
} }
impl fmt::Debug for SecretMaterial { impl fmt::Debug for SecretMaterial {

View File

@@ -4,6 +4,8 @@ mod access_policy_tests;
mod contact_polling_tests; mod contact_polling_tests;
#[path = "tests/contacts.rs"] #[path = "tests/contacts.rs"]
mod contacts_tests; mod contacts_tests;
#[path = "tests/device_relationship.rs"]
mod device_relationship_tests;
#[path = "tests/error.rs"] #[path = "tests/error.rs"]
mod error_tests; mod error_tests;
#[path = "tests/filesystem.rs"] #[path = "tests/filesystem.rs"]

View File

@@ -0,0 +1,317 @@
use std::{
path::Path,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use crate::{
secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink, DeviceRelationshipState,
ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode, VnidropCore,
};
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
struct ProtectedNode {
_data_dir: tempfile::TempDir,
core: Arc<VnidropCore>,
}
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,
store,
)
.expect("protected test core");
Self {
_data_dir: data_dir,
core,
}
}
}
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 complete_transfer(sender: &ProtectedNode, receiver: &ProtectedNode, transfer_id: u64) {
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"mutual consent").unwrap();
let share = share_path(&sender.core, &source_path, transfer_id);
let output_dir = output_dir.path().to_string_lossy().to_string();
let receiver_core = receiver.core.clone();
let ticket = share.ticket.clone();
let handle = std::thread::spawn(move || {
receiver_core.receive(ticket, output_dir, 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();
handle.join().unwrap().unwrap();
let started = Instant::now();
let peer = receiver.core.status().endpoint_id.clone();
loop {
if sender
.core
.list_pairing_eligibilities()
.unwrap()
.iter()
.any(|entry| entry.peer_endpoint_id == peer)
{
break;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"eligibility never appeared"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn wait_for_relationship(
core: &VnidropCore,
peer: &str,
state: DeviceRelationshipState,
) -> crate::DeviceRelationship {
let started = Instant::now();
loop {
if let Some(relationship) = core
.list_device_relationships()
.unwrap()
.into_iter()
.find(|entry| entry.remote_endpoint_id == peer && entry.state == state)
{
return relationship;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"relationship {peer} never reached {state:?}"
);
std::thread::sleep(Duration::from_millis(25));
}
}
#[test]
fn mutual_consent_reaches_saved_after_both_grants_and_acknowledgement() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let alice_id = alice.core.status().endpoint_id.clone();
let bob_id = bob.core.status().endpoint_id.clone();
complete_transfer(&alice, &bob, 80_001);
assert!(alice
.core
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&alice.core,
&bob_id,
DeviceRelationshipState::PendingOutgoing,
);
wait_for_relationship(
&bob.core,
&alice_id,
DeviceRelationshipState::PendingIncoming,
);
assert!(
alice.core.list_saved_devices().unwrap().is_empty(),
"pending outgoing must not surface as a saved device"
);
assert!(
bob.core.list_saved_devices().unwrap().is_empty(),
"pending incoming must not surface as a saved device"
);
assert!(bob
.core
.respond_to_device_pairing(alice_id.clone(), true)
.unwrap());
wait_for_relationship(&alice.core, &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(&bob.core, &alice_id, DeviceRelationshipState::Saved);
let alice_saved = alice.core.list_saved_devices().unwrap();
let bob_saved = bob.core.list_saved_devices().unwrap();
assert_eq!(alice_saved.len(), 1);
assert_eq!(alice_saved[0].endpoint_id, bob_id);
assert_eq!(bob_saved.len(), 1);
assert_eq!(bob_saved[0].endpoint_id, alice_id);
}
#[test]
fn declining_pending_incoming_consumes_eligibility_and_never_saves() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let alice_id = alice.core.status().endpoint_id.clone();
let bob_id = bob.core.status().endpoint_id.clone();
complete_transfer(&alice, &bob, 80_010);
assert!(alice
.core
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&bob.core,
&alice_id,
DeviceRelationshipState::PendingIncoming,
);
assert!(bob
.core
.respond_to_device_pairing(alice_id.clone(), false)
.unwrap());
assert!(bob.core.list_saved_devices().unwrap().is_empty());
assert!(alice.core.list_saved_devices().unwrap().is_empty());
assert!(bob
.core
.list_pairing_eligibilities()
.unwrap()
.iter()
.all(|entry| entry.peer_endpoint_id != alice_id));
// Declined eligibility cannot prompt again without a new qualifying transfer.
assert!(!alice.core.request_saved_device_pairing(bob_id).unwrap());
}
#[test]
fn repeated_consent_is_idempotent_and_does_not_duplicate_saved_rows() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let alice_id = alice.core.status().endpoint_id.clone();
let bob_id = bob.core.status().endpoint_id.clone();
complete_transfer(&alice, &bob, 80_020);
assert!(alice
.core
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&bob.core,
&alice_id,
DeviceRelationshipState::PendingIncoming,
);
assert!(bob
.core
.respond_to_device_pairing(alice_id.clone(), true)
.unwrap());
wait_for_relationship(&alice.core, &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(&bob.core, &alice_id, DeviceRelationshipState::Saved);
assert!(bob.core.respond_to_device_pairing(alice_id, true).unwrap());
assert_eq!(alice.core.list_saved_devices().unwrap().len(), 1);
assert_eq!(bob.core.list_saved_devices().unwrap().len(), 1);
assert_eq!(alice.core.list_device_relationships().unwrap().len(), 1);
assert_eq!(bob.core.list_device_relationships().unwrap().len(), 1);
}
#[test]
fn simultaneous_initiation_merges_into_one_relationship_per_side() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let alice_id = alice.core.status().endpoint_id.clone();
let bob_id = bob.core.status().endpoint_id.clone();
complete_transfer(&alice, &bob, 80_030);
let alice_core = alice.core.clone();
let bob_core = bob.core.clone();
let bob_id_clone = bob_id.clone();
let alice_id_clone = alice_id.clone();
let alice_handle =
std::thread::spawn(move || alice_core.request_saved_device_pairing(bob_id_clone));
let bob_handle =
std::thread::spawn(move || bob_core.request_saved_device_pairing(alice_id_clone));
let alice_ok = alice_handle.join().unwrap().unwrap();
let bob_ok = bob_handle.join().unwrap().unwrap();
assert!(alice_ok || bob_ok);
wait_for_relationship(&alice.core, &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(&bob.core, &alice_id, DeviceRelationshipState::Saved);
assert_eq!(alice.core.list_device_relationships().unwrap().len(), 1);
assert_eq!(bob.core.list_device_relationships().unwrap().len(), 1);
assert_eq!(alice.core.list_saved_devices().unwrap().len(), 1);
assert_eq!(bob.core.list_saved_devices().unwrap().len(), 1);
}
#[test]
fn request_timeout_leaves_recoverable_pending_not_saved() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let bob_id = bob.core.status().endpoint_id.clone();
complete_transfer(&alice, &bob, 80_040);
// Shut down Bob so Alice's pairing request cannot complete on the wire.
bob.core.shutdown();
assert!(alice
.core
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&alice.core,
&bob_id,
DeviceRelationshipState::PendingOutgoing,
);
assert!(
alice.core.list_saved_devices().unwrap().is_empty(),
"timed-out pairing must not surface as saved"
);
}

View File

@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
async fn persists_transfers_and_events_across_reopen() { async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap(); let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 11); assert_eq!(repository.schema_version().await.unwrap(), 13);
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(), 11); assert_eq!(repository.schema_version().await.unwrap(), 13);
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");