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