mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
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:
134
crates/vnidrop/src/device_relationship/crypto.rs
Normal file
134
crates/vnidrop/src/device_relationship/crypto.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
1306
crates/vnidrop/src/device_relationship/mod.rs
Normal file
1306
crates/vnidrop/src/device_relationship/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,14 @@ impl GrantSecret {
|
||||
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 {
|
||||
HEXLOWER.encode(&self.0)
|
||||
}
|
||||
@@ -96,10 +104,28 @@ impl Challenge {
|
||||
Self(random_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn as_bytes(&self) -> &[u8; CHALLENGE_LEN] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_bytes(bytes: [u8; CHALLENGE_LEN]) -> Self {
|
||||
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 {
|
||||
@@ -115,6 +141,16 @@ pub(crate) struct GrantProof {
|
||||
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 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("GrantProof")
|
||||
|
||||
@@ -2,6 +2,7 @@ mod access_policy;
|
||||
mod api;
|
||||
mod approval;
|
||||
mod contacts;
|
||||
mod device_relationship;
|
||||
mod error;
|
||||
mod event_hub;
|
||||
mod filesystem;
|
||||
|
||||
@@ -148,24 +148,54 @@ impl PairingEligibilityService {
|
||||
///
|
||||
/// Returns `false` when eligibility is missing/expired (silent reject). A
|
||||
/// 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(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> 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?;
|
||||
let entries = self
|
||||
.repository
|
||||
.list_pairing_eligibilities_for_peer(peer_endpoint_id)
|
||||
.await?;
|
||||
let Some(entry) = entries.into_iter().next() else {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
};
|
||||
if entry.expires_at <= now_ms() {
|
||||
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?;
|
||||
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.
|
||||
@@ -189,6 +219,24 @@ impl PairingEligibilityService {
|
||||
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> {
|
||||
self.remove_for_peer(peer_endpoint_id).await
|
||||
}
|
||||
@@ -304,6 +352,13 @@ pub(crate) struct PairingEligibilityInsert<'a> {
|
||||
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)]
|
||||
pub(crate) struct PairingEligibilityRecord {
|
||||
pub(crate) peer_endpoint_id: String,
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::{
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 11;
|
||||
const SCHEMA_VERSION: i64 = 13;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Repository {
|
||||
@@ -323,6 +323,7 @@ impl Repository {
|
||||
|
||||
crate::contacts::ensure_schema(&self.pool).await?;
|
||||
crate::secure_secret::ensure_schema(&self.pool).await?;
|
||||
crate::device_relationship::DeviceRelationshipService::ensure_schema(&self.pool).await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS pairing_eligibilities (
|
||||
@@ -358,6 +359,10 @@ impl Repository {
|
||||
ContactStore::new(self.pool.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn sqlite_pool(&self) -> SqlitePool {
|
||||
self.pool.clone()
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "the private custody seam is activated by platform credential adapters"
|
||||
|
||||
@@ -59,8 +59,30 @@ impl CoreInner {
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
) -> Result<bool, crate::error::VnidropError> {
|
||||
self.pairing_eligibility
|
||||
.request_pairing(&peer_endpoint_id)
|
||||
self.device_relationships
|
||||
.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
|
||||
}
|
||||
|
||||
|
||||
@@ -397,6 +397,27 @@ impl VnidropCore {
|
||||
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.
|
||||
pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> {
|
||||
self.block_on(self.inner.list_contacts())
|
||||
|
||||
@@ -56,6 +56,7 @@ use crate::{
|
||||
access_policy::{mode_from_storage, AccessPolicy},
|
||||
api::{CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode},
|
||||
approval::ApprovalService,
|
||||
device_relationship::{DeviceRelationshipService, RelationshipProtocol},
|
||||
event_hub::EventHub,
|
||||
handshake::HandshakeService,
|
||||
logging::init_logging,
|
||||
@@ -106,6 +107,7 @@ pub(super) struct CoreInner {
|
||||
pub(super) approval: ApprovalService,
|
||||
pub(super) pairing: PairingService,
|
||||
pub(super) pairing_eligibility: PairingEligibilityService,
|
||||
pub(super) device_relationships: Arc<DeviceRelationshipService>,
|
||||
pub(super) offers: OfferInbox,
|
||||
/// Endpoint → last poll time, for the rate limit above.
|
||||
pub(super) last_polled: TokioMutex<HashMap<String, i64>>,
|
||||
@@ -385,6 +387,14 @@ impl CoreInner {
|
||||
tracing::warn!(%error, "failed to sweep dead grants");
|
||||
}
|
||||
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())
|
||||
.accept(iroh_blobs::ALPN, blobs)
|
||||
.accept(HandshakeService::ALPN, handshake)
|
||||
@@ -392,6 +402,10 @@ impl CoreInner {
|
||||
OfferService::ALPN,
|
||||
OfferService::new(pairing.clone(), offers.clone(), endpoint.id().to_string()),
|
||||
)
|
||||
.accept(
|
||||
RelationshipProtocol::ALPN,
|
||||
RelationshipProtocol::new(device_relationships.clone()),
|
||||
)
|
||||
.spawn();
|
||||
|
||||
let inner = Arc::new(Self {
|
||||
@@ -405,6 +419,7 @@ impl CoreInner {
|
||||
approval,
|
||||
pairing,
|
||||
pairing_eligibility,
|
||||
device_relationships,
|
||||
offers,
|
||||
last_polled: TokioMutex::new(HashMap::new()),
|
||||
relay_mode,
|
||||
@@ -436,6 +451,9 @@ impl CoreInner {
|
||||
if let Err(error) = inner.pairing_eligibility.reconcile().await {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ impl SecretMaterial {
|
||||
let bytes: [u8; SECRET_BYTES] = self.0.try_into().expect("validated length");
|
||||
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 {
|
||||
|
||||
@@ -4,6 +4,8 @@ mod access_policy_tests;
|
||||
mod contact_polling_tests;
|
||||
#[path = "tests/contacts.rs"]
|
||||
mod contacts_tests;
|
||||
#[path = "tests/device_relationship.rs"]
|
||||
mod device_relationship_tests;
|
||||
#[path = "tests/error.rs"]
|
||||
mod error_tests;
|
||||
#[path = "tests/filesystem.rs"]
|
||||
|
||||
317
crates/vnidrop/src/tests/device_relationship.rs
Normal file
317
crates/vnidrop/src/tests/device_relationship.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
|
||||
async fn persists_transfers_and_events_across_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 11);
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 13);
|
||||
repository
|
||||
.insert_transfer(transfer(
|
||||
7,
|
||||
@@ -645,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
|
||||
pool.close().await;
|
||||
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 11);
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 13);
|
||||
let stored = repository.list_transfers().await.unwrap().remove(0);
|
||||
assert_eq!(stored.transfer_id, 7);
|
||||
assert_eq!(stored.local_id, "legacy-7-send");
|
||||
|
||||
Reference in New Issue
Block a user