mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
feat(core): revoke, forget, block, and rotate device relationships
Give saved-device owners immediate local control over grants and identity-wide denies, with minimal tombstones for replay rejection and a ticket-10 hook for targeted-transfer cancellation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -178,6 +178,18 @@ impl ApprovalService {
|
|||||||
remote_endpoint_id: String,
|
remote_endpoint_id: String,
|
||||||
request: RequestTransfer,
|
request: RequestTransfer,
|
||||||
) -> HandshakeResponse {
|
) -> HandshakeResponse {
|
||||||
|
if self
|
||||||
|
.repository
|
||||||
|
.contacts()
|
||||||
|
.is_blocked(&remote_endpoint_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
// Indistinguishable from other refusals so probing cannot detect blocks.
|
||||||
|
return self
|
||||||
|
.deny(request.transfer_id, remote_endpoint_id, "not-accepted")
|
||||||
|
.await;
|
||||||
|
}
|
||||||
let metadata_values = [
|
let metadata_values = [
|
||||||
request.transfer_hash.as_str(),
|
request.transfer_hash.as_str(),
|
||||||
request.transfer_name.as_str(),
|
request.transfer_name.as_str(),
|
||||||
|
|||||||
347
crates/vnidrop/src/device_relationship/lifecycle.rs
Normal file
347
crates/vnidrop/src/device_relationship/lifecycle.rs
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
//! Forget, block, grant rotation, and minimal revocation tombstones (design §7–§8).
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use super::{DeviceRelationshipService, RelationshipRow};
|
||||||
|
use crate::{
|
||||||
|
api::DeviceRelationshipState, error::VnidropError, grant::GrantRejection,
|
||||||
|
secure_secret::SecretHandle, util::now_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Minimal non-secret tombstone for a revoked relationship generation.
|
||||||
|
///
|
||||||
|
/// Retains only what is needed to reject replay: peer identity, generation,
|
||||||
|
/// opaque grant ids, and revocation time. No names, filenames, history, or
|
||||||
|
/// capability material.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct GenerationTombstone {
|
||||||
|
pub(crate) remote_endpoint_id: String,
|
||||||
|
pub(crate) generation: u64,
|
||||||
|
pub(crate) issued_grant_id: Option<String>,
|
||||||
|
pub(crate) held_grant_id: Option<String>,
|
||||||
|
pub(crate) revoked_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct ForgetOutcome {
|
||||||
|
pub(crate) had_relationship: bool,
|
||||||
|
pub(crate) generation: Option<u64>,
|
||||||
|
pub(crate) issued_grant_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceRelationshipService {
|
||||||
|
pub(crate) async fn ensure_lifecycle_schema(pool: &sqlx::SqlitePool) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS relationship_generation_tombstones (
|
||||||
|
remote_endpoint_id TEXT NOT NULL,
|
||||||
|
generation INTEGER NOT NULL,
|
||||||
|
issued_grant_id TEXT,
|
||||||
|
held_grant_id TEXT,
|
||||||
|
revoked_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (remote_endpoint_id, generation)
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a saved (or pending) device: revoke locally first, clean secrets,
|
||||||
|
/// then the caller sends a best-effort remote notice. Invitation-domain
|
||||||
|
/// transfers are untouched.
|
||||||
|
pub(crate) async fn forget(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: String,
|
||||||
|
) -> Result<ForgetOutcome, VnidropError> {
|
||||||
|
let peer_lock = self.lock_peer(&peer_endpoint_id).await;
|
||||||
|
let _guard = peer_lock.lock().await;
|
||||||
|
|
||||||
|
let Some(row) = self.find_row(&peer_endpoint_id).await? else {
|
||||||
|
return Ok(ForgetOutcome {
|
||||||
|
had_relationship: false,
|
||||||
|
generation: None,
|
||||||
|
issued_grant_id: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let issued_grant_id = row.issued_grant_id.clone();
|
||||||
|
let generation = row.generation;
|
||||||
|
self.tombstone_generation(&peer_endpoint_id, &row).await?;
|
||||||
|
self.delete_relationship(&peer_endpoint_id).await?;
|
||||||
|
self.eligibility.remove_for_peer(&peer_endpoint_id).await?;
|
||||||
|
self.emit_changed(&peer_endpoint_id, DeviceRelationshipState::Revoked);
|
||||||
|
drop(_guard);
|
||||||
|
|
||||||
|
Ok(ForgetOutcome {
|
||||||
|
had_relationship: true,
|
||||||
|
generation: Some(generation),
|
||||||
|
issued_grant_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity-wide block: revoke relationship grants, keep deny + tombstones.
|
||||||
|
/// Caller owns the durable deny record (`blocked_endpoints`).
|
||||||
|
pub(crate) async fn revoke_for_block(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: &str,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
let peer_lock = self.lock_peer(peer_endpoint_id).await;
|
||||||
|
let _guard = peer_lock.lock().await;
|
||||||
|
|
||||||
|
if let Some(row) = self.find_row(peer_endpoint_id).await? {
|
||||||
|
self.tombstone_generation(peer_endpoint_id, &row).await?;
|
||||||
|
self.delete_relationship(peer_endpoint_id).await?;
|
||||||
|
}
|
||||||
|
self.eligibility.remove_for_peer(peer_endpoint_id).await?;
|
||||||
|
self.emit_changed(peer_endpoint_id, DeviceRelationshipState::Blocked);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate a replacement grant: invalidate the prior generation first, then
|
||||||
|
/// mint exactly one new active generation for the issued direction.
|
||||||
|
pub(crate) async fn rotate_relationship_grant(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: String,
|
||||||
|
) -> Result<u64, VnidropError> {
|
||||||
|
let peer_lock = self.lock_peer(&peer_endpoint_id).await;
|
||||||
|
let _guard = peer_lock.lock().await;
|
||||||
|
|
||||||
|
let Some(row) = self.find_row(&peer_endpoint_id).await? else {
|
||||||
|
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||||
|
"no relationship to rotate"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
if row.state != DeviceRelationshipState::Saved {
|
||||||
|
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||||
|
"only saved relationships can rotate grants"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate first: tombstone + secret removal before the new generation
|
||||||
|
// becomes active, so a concurrent presenter cannot race past revocation.
|
||||||
|
self.tombstone_generation(&peer_endpoint_id, &row).await?;
|
||||||
|
self.clear_grant_secrets(&row).await?;
|
||||||
|
|
||||||
|
let new_generation = row.generation.saturating_add(1);
|
||||||
|
let now = now_ms();
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE device_relationships
|
||||||
|
SET generation = ?2,
|
||||||
|
issued_grant_handle = NULL,
|
||||||
|
held_grant_handle = NULL,
|
||||||
|
issued_grant_id = NULL,
|
||||||
|
held_grant_id = NULL,
|
||||||
|
updated_at = ?3
|
||||||
|
WHERE remote_endpoint_id = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&peer_endpoint_id)
|
||||||
|
.bind(new_generation as i64)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
|
||||||
|
let _wire = self
|
||||||
|
.mint_and_store_issued_grant(
|
||||||
|
&peer_endpoint_id,
|
||||||
|
new_generation,
|
||||||
|
row.minimum_protocol_version,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.event_hub.emit_endpoint(
|
||||||
|
"pairing",
|
||||||
|
"relationship-grant-rotated",
|
||||||
|
json!({
|
||||||
|
"peer_endpoint_id": peer_endpoint_id,
|
||||||
|
"generation": new_generation,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
Ok(new_generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject a presented generation when it is tombstoned or not the active one.
|
||||||
|
pub(crate) async fn reject_replayed_generation(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: &str,
|
||||||
|
generation: u64,
|
||||||
|
_grant_id: Option<&str>,
|
||||||
|
) -> Result<(), GrantRejection> {
|
||||||
|
if self
|
||||||
|
.find_tombstone(peer_endpoint_id, generation)
|
||||||
|
.await
|
||||||
|
.map_err(|_| GrantRejection::Unknown)?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(GrantRejection::Revoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(row) = self
|
||||||
|
.find_row(peer_endpoint_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| GrantRejection::Unknown)?
|
||||||
|
else {
|
||||||
|
return Err(GrantRejection::Unknown);
|
||||||
|
};
|
||||||
|
// Pending pairing and Saved both use the active row generation; only a
|
||||||
|
// mismatch (or tombstone above) means the presenter is replaying.
|
||||||
|
match row.state {
|
||||||
|
DeviceRelationshipState::PendingOutgoing
|
||||||
|
| DeviceRelationshipState::PendingIncoming
|
||||||
|
| DeviceRelationshipState::Saved => {
|
||||||
|
if row.generation != generation {
|
||||||
|
return Err(GrantRejection::Unknown);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
DeviceRelationshipState::Revoked | DeviceRelationshipState::Blocked => {
|
||||||
|
Err(GrantRejection::Unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn list_tombstones(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: &str,
|
||||||
|
) -> Result<Vec<GenerationTombstone>, VnidropError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||||
|
FROM relationship_generation_tombstones
|
||||||
|
WHERE remote_endpoint_id = ?1
|
||||||
|
ORDER BY generation ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(peer_endpoint_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| GenerationTombstone {
|
||||||
|
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||||
|
generation: row.get::<i64, _>("generation") as u64,
|
||||||
|
issued_grant_id: row.get("issued_grant_id"),
|
||||||
|
held_grant_id: row.get("held_grant_id"),
|
||||||
|
revoked_at: row.get("revoked_at"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn issued_grant_snapshot(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: &str,
|
||||||
|
) -> Result<Option<(u64, String)>, VnidropError> {
|
||||||
|
let Some(row) = self.find_row(peer_endpoint_id).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(grant_id) = row.issued_grant_id else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(Some((row.generation, grant_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tombstone_generation(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: &str,
|
||||||
|
row: &RelationshipRow,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO relationship_generation_tombstones (
|
||||||
|
remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(remote_endpoint_id, generation) DO UPDATE SET
|
||||||
|
issued_grant_id = COALESCE(excluded.issued_grant_id, relationship_generation_tombstones.issued_grant_id),
|
||||||
|
held_grant_id = COALESCE(excluded.held_grant_id, relationship_generation_tombstones.held_grant_id),
|
||||||
|
revoked_at = excluded.revoked_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(peer_endpoint_id)
|
||||||
|
.bind(row.generation as i64)
|
||||||
|
.bind(row.issued_grant_id.as_deref())
|
||||||
|
.bind(row.held_grant_id.as_deref())
|
||||||
|
.bind(now_ms())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_tombstone(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: &str,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<Option<GenerationTombstone>, VnidropError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||||
|
FROM relationship_generation_tombstones
|
||||||
|
WHERE remote_endpoint_id = ?1 AND generation = ?2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(peer_endpoint_id)
|
||||||
|
.bind(generation as i64)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
Ok(row.map(|row| GenerationTombstone {
|
||||||
|
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||||
|
generation: row.get::<i64, _>("generation") as u64,
|
||||||
|
issued_grant_id: row.get("issued_grant_id"),
|
||||||
|
held_grant_id: row.get("held_grant_id"),
|
||||||
|
revoked_at: row.get("revoked_at"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_grant_secrets(&self, row: &RelationshipRow) -> Result<(), VnidropError> {
|
||||||
|
let Some(custody) = &self.custody else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
for handle in [&row.issued_grant_handle, &row.held_grant_handle]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let _ = custody
|
||||||
|
.remove(&SecretHandle::from_stored(handle.clone()))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a best-effort remote revocation notice from a peer.
|
||||||
|
pub(crate) async fn handle_remote_revoke(
|
||||||
|
&self,
|
||||||
|
remote_endpoint_id: String,
|
||||||
|
generation: u64,
|
||||||
|
) -> bool {
|
||||||
|
let peer_lock = self.lock_peer(&remote_endpoint_id).await;
|
||||||
|
let _guard = peer_lock.lock().await;
|
||||||
|
let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if row.generation != generation
|
||||||
|
&& generation != 0
|
||||||
|
&& self
|
||||||
|
.find_tombstone(&remote_endpoint_id, generation)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let _ = self.tombstone_generation(&remote_endpoint_id, &row).await;
|
||||||
|
let _ = self.delete_relationship(&remote_endpoint_id).await;
|
||||||
|
let _ = self.eligibility.remove_for_peer(&remote_endpoint_id).await;
|
||||||
|
self.emit_changed(&remote_endpoint_id, DeviceRelationshipState::Revoked);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,12 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod crypto;
|
mod crypto;
|
||||||
|
mod lifecycle;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) use lifecycle::GenerationTombstone;
|
||||||
|
|
||||||
|
use crate::contacts::ContactStore;
|
||||||
use crypto::{
|
use crypto::{
|
||||||
encode_relationship_grant_secret, prove_relationship_grant, secret_from_material,
|
encode_relationship_grant_secret, prove_relationship_grant, secret_from_material,
|
||||||
verify_relationship_grant,
|
verify_relationship_grant,
|
||||||
@@ -71,7 +76,7 @@ impl DeviceRelationshipService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn lock_peer(&self, peer_endpoint_id: &str) -> Arc<TokioMutex<()>> {
|
pub(super) async fn lock_peer(&self, peer_endpoint_id: &str) -> Arc<TokioMutex<()>> {
|
||||||
let mut locks = self.peer_locks.lock().await;
|
let mut locks = self.peer_locks.lock().await;
|
||||||
locks
|
locks
|
||||||
.entry(peer_endpoint_id.to_string())
|
.entry(peer_endpoint_id.to_string())
|
||||||
@@ -115,9 +120,21 @@ impl DeviceRelationshipService {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
Self::ensure_lifecycle_schema(pool).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn contacts(&self) -> ContactStore {
|
||||||
|
ContactStore::new(self.pool.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn is_blocked(&self, endpoint_id: &str) -> bool {
|
||||||
|
self.contacts()
|
||||||
|
.is_blocked(endpoint_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop orphaned relationship grant secrets and disable rows whose secrets are gone.
|
/// Drop orphaned relationship grant secrets and disable rows whose secrets are gone.
|
||||||
pub(crate) async fn reconcile(&self) -> Result<(), VnidropError> {
|
pub(crate) async fn reconcile(&self) -> Result<(), VnidropError> {
|
||||||
let Some(custody) = &self.custody else {
|
let Some(custody) = &self.custody else {
|
||||||
@@ -139,19 +156,28 @@ impl DeviceRelationshipService {
|
|||||||
let state = parse_state(&row.get::<String, _>("state"))?;
|
let state = parse_state(&row.get::<String, _>("state"))?;
|
||||||
let issued: Option<String> = row.get("issued_grant_handle");
|
let issued: Option<String> = row.get("issued_grant_handle");
|
||||||
let held: Option<String> = row.get("held_grant_handle");
|
let held: Option<String> = row.get("held_grant_handle");
|
||||||
let mut missing = false;
|
let mut issued_missing = issued.is_none();
|
||||||
for handle in [&issued, &held].into_iter().flatten() {
|
if let Some(handle) = &issued {
|
||||||
live_handles.insert(handle.clone());
|
live_handles.insert(handle.clone());
|
||||||
if custody
|
if custody
|
||||||
.load(&SecretHandle::from_stored(handle.clone()))
|
.load(&SecretHandle::from_stored(handle.clone()))
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
missing = true;
|
issued_missing = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if missing && state == DeviceRelationshipState::Saved {
|
if let Some(handle) = &held {
|
||||||
// Grants are required for a usable saved device.
|
live_handles.insert(handle.clone());
|
||||||
|
// Held gaps after rotation are recoverable; orphaned handles are
|
||||||
|
// still tracked so reconcile does not delete live custody rows.
|
||||||
|
let _ = custody
|
||||||
|
.load(&SecretHandle::from_stored(handle.clone()))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
// Issued grants authorize the peer. A missing held grant after
|
||||||
|
// rotation is recoverable while the peer is offline.
|
||||||
|
if state == DeviceRelationshipState::Saved && issued_missing {
|
||||||
self.delete_relationship(&peer).await?;
|
self.delete_relationship(&peer).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,6 +228,9 @@ impl DeviceRelationshipService {
|
|||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
peer_endpoint_id: String,
|
peer_endpoint_id: String,
|
||||||
) -> Result<bool, VnidropError> {
|
) -> Result<bool, VnidropError> {
|
||||||
|
if self.is_blocked(&peer_endpoint_id).await {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
let peer_lock = self.lock_peer(&peer_endpoint_id).await;
|
let peer_lock = self.lock_peer(&peer_endpoint_id).await;
|
||||||
let _guard = peer_lock.lock().await;
|
let _guard = peer_lock.lock().await;
|
||||||
|
|
||||||
@@ -316,6 +345,9 @@ impl DeviceRelationshipService {
|
|||||||
peer_endpoint_id: String,
|
peer_endpoint_id: String,
|
||||||
accepted: bool,
|
accepted: bool,
|
||||||
) -> Result<bool, VnidropError> {
|
) -> Result<bool, VnidropError> {
|
||||||
|
if self.is_blocked(&peer_endpoint_id).await {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
let Some(row) = self.find_row(&peer_endpoint_id).await? else {
|
let Some(row) = self.find_row(&peer_endpoint_id).await? else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
@@ -442,6 +474,10 @@ impl DeviceRelationshipService {
|
|||||||
remote_endpoint_id: String,
|
remote_endpoint_id: String,
|
||||||
request: PairingRequest,
|
request: PairingRequest,
|
||||||
) -> PairingRequestResponse {
|
) -> PairingRequestResponse {
|
||||||
|
if self.is_blocked(&remote_endpoint_id).await {
|
||||||
|
// Indistinguishable rejection: do not expose block state.
|
||||||
|
return PairingRequestResponse::Rejected;
|
||||||
|
}
|
||||||
let peer_lock = self.lock_peer(&remote_endpoint_id).await;
|
let peer_lock = self.lock_peer(&remote_endpoint_id).await;
|
||||||
let _guard = peer_lock.lock().await;
|
let _guard = peer_lock.lock().await;
|
||||||
|
|
||||||
@@ -520,6 +556,9 @@ impl DeviceRelationshipService {
|
|||||||
remote_endpoint_id: String,
|
remote_endpoint_id: String,
|
||||||
consent: PairingConsent,
|
consent: PairingConsent,
|
||||||
) -> PairingConsentResponse {
|
) -> PairingConsentResponse {
|
||||||
|
if self.is_blocked(&remote_endpoint_id).await {
|
||||||
|
return PairingConsentResponse::Rejected;
|
||||||
|
}
|
||||||
let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else {
|
let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else {
|
||||||
return PairingConsentResponse::Rejected;
|
return PairingConsentResponse::Rejected;
|
||||||
};
|
};
|
||||||
@@ -588,6 +627,9 @@ impl DeviceRelationshipService {
|
|||||||
remote_endpoint_id: String,
|
remote_endpoint_id: String,
|
||||||
ack: PairingAck,
|
ack: PairingAck,
|
||||||
) -> PairingAckResponse {
|
) -> PairingAckResponse {
|
||||||
|
if self.is_blocked(&remote_endpoint_id).await {
|
||||||
|
return PairingAckResponse::Rejected;
|
||||||
|
}
|
||||||
let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else {
|
let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else {
|
||||||
return PairingAckResponse::Rejected;
|
return PairingAckResponse::Rejected;
|
||||||
};
|
};
|
||||||
@@ -854,6 +896,15 @@ impl DeviceRelationshipService {
|
|||||||
generation: u64,
|
generation: u64,
|
||||||
protocol_version: u16,
|
protocol_version: u16,
|
||||||
) -> Result<(), VnidropError> {
|
) -> Result<(), VnidropError> {
|
||||||
|
if let Err(rejection) = self
|
||||||
|
.reject_replayed_generation(peer_endpoint_id, generation, Some(proof.grant_id.as_str()))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||||
|
"relationship grant {}",
|
||||||
|
rejection.as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
let custody =
|
let custody =
|
||||||
self.custody
|
self.custody
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1103,6 +1154,19 @@ impl ProtocolHandler for RelationshipProtocol {
|
|||||||
.await;
|
.await;
|
||||||
let _ = tx.send(response).await;
|
let _ = tx.send(response).await;
|
||||||
}
|
}
|
||||||
|
RelationshipMessage::RevokeNotice(message) => {
|
||||||
|
let WithChannels { inner, tx, .. } = message;
|
||||||
|
let acknowledged = self
|
||||||
|
.relationships
|
||||||
|
.handle_remote_revoke(remote_endpoint_id.clone(), inner.generation)
|
||||||
|
.await;
|
||||||
|
let response = if acknowledged {
|
||||||
|
RevokeNoticeResponse::Acknowledged
|
||||||
|
} else {
|
||||||
|
RevokeNoticeResponse::Rejected
|
||||||
|
};
|
||||||
|
let _ = tx.send(response).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.closed().await;
|
connection.closed().await;
|
||||||
@@ -1142,6 +1206,13 @@ impl RelationshipClient {
|
|||||||
async fn pairing_ack(&self, ack: PairingAck) -> Result<PairingAckResponse, irpc::Error> {
|
async fn pairing_ack(&self, ack: PairingAck) -> Result<PairingAckResponse, irpc::Error> {
|
||||||
self.inner.rpc(ack).await
|
self.inner.rpc(ack).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn revoke_notice(
|
||||||
|
&self,
|
||||||
|
notice: RevokeNotice,
|
||||||
|
) -> Result<RevokeNoticeResponse, irpc::Error> {
|
||||||
|
self.inner.rpc(notice).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -1212,6 +1283,18 @@ struct WireProof {
|
|||||||
challenge: String,
|
challenge: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct RevokeNotice {
|
||||||
|
generation: u64,
|
||||||
|
issued_grant_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
enum RevokeNoticeResponse {
|
||||||
|
Acknowledged,
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
#[rpc_requests(message = RelationshipMessage)]
|
#[rpc_requests(message = RelationshipMessage)]
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[allow(
|
#[allow(
|
||||||
@@ -1225,6 +1308,8 @@ enum RelationshipMessages {
|
|||||||
PairingConsent(PairingConsent),
|
PairingConsent(PairingConsent),
|
||||||
#[rpc(tx = oneshot::Sender<PairingAckResponse>)]
|
#[rpc(tx = oneshot::Sender<PairingAckResponse>)]
|
||||||
PairingAck(PairingAck),
|
PairingAck(PairingAck),
|
||||||
|
#[rpc(tx = oneshot::Sender<RevokeNoticeResponse>)]
|
||||||
|
RevokeNotice(RevokeNotice),
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RelationshipUpsert<'a> {
|
struct RelationshipUpsert<'a> {
|
||||||
@@ -1255,6 +1340,29 @@ struct RelationshipRow {
|
|||||||
created_at: i64,
|
created_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DeviceRelationshipService {
|
||||||
|
/// Best-effort signed/bound revocation notice; correctness never depends on delivery.
|
||||||
|
pub(crate) async fn notify_remote_revoke(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: &str,
|
||||||
|
generation: u64,
|
||||||
|
issued_grant_id: Option<String>,
|
||||||
|
) {
|
||||||
|
let Ok(addr) = self.peer_addr(peer_endpoint_id).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let client = RelationshipClient::connect(self.endpoint.clone(), addr);
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
PAIRING_RPC_TIMEOUT,
|
||||||
|
client.revoke_notice(RevokeNotice {
|
||||||
|
generation,
|
||||||
|
issued_grant_id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn state_as_str(state: DeviceRelationshipState) -> &'static str {
|
fn state_as_str(state: DeviceRelationshipState) -> &'static str {
|
||||||
match state {
|
match state {
|
||||||
DeviceRelationshipState::PendingOutgoing => "pending_outgoing",
|
DeviceRelationshipState::PendingOutgoing => "pending_outgoing",
|
||||||
|
|||||||
@@ -81,11 +81,130 @@ impl CoreInner {
|
|||||||
peer_endpoint_id: String,
|
peer_endpoint_id: String,
|
||||||
accepted: bool,
|
accepted: bool,
|
||||||
) -> Result<bool, crate::error::VnidropError> {
|
) -> Result<bool, crate::error::VnidropError> {
|
||||||
|
if self
|
||||||
|
.repository
|
||||||
|
.contacts()
|
||||||
|
.is_blocked(&peer_endpoint_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
self.device_relationships
|
self.device_relationships
|
||||||
.respond_to_pairing(peer_endpoint_id, accepted)
|
.respond_to_pairing(peer_endpoint_id, accepted)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) async fn forget_saved_device(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
peer_endpoint_id: String,
|
||||||
|
) -> Result<(), crate::error::VnidropError> {
|
||||||
|
let outcome = self
|
||||||
|
.device_relationships
|
||||||
|
.forget(peer_endpoint_id.clone())
|
||||||
|
.await?;
|
||||||
|
// Targeted transfers for this relationship only (ticket 10 fills in).
|
||||||
|
// Invitation-domain shares are deliberately not cancelled here.
|
||||||
|
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
|
||||||
|
.await;
|
||||||
|
self.emit_endpoint(
|
||||||
|
"pairing",
|
||||||
|
"saved-device-forgotten",
|
||||||
|
json!({
|
||||||
|
"peer_endpoint_id": peer_endpoint_id,
|
||||||
|
"had_relationship": outcome.had_relationship,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if outcome.had_relationship {
|
||||||
|
if let Some(generation) = outcome.generation {
|
||||||
|
self.device_relationships
|
||||||
|
.notify_remote_revoke(&peer_endpoint_id, generation, outcome.issued_grant_id)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn block_device(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
peer_endpoint_id: String,
|
||||||
|
) -> Result<(), crate::error::VnidropError> {
|
||||||
|
let now = now_ms();
|
||||||
|
self.repository
|
||||||
|
.contacts()
|
||||||
|
.block_endpoint(&peer_endpoint_id, now)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
self.device_relationships
|
||||||
|
.revoke_for_block(&peer_endpoint_id)
|
||||||
|
.await?;
|
||||||
|
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
|
||||||
|
.await;
|
||||||
|
self.offers.discard_from(&peer_endpoint_id).await;
|
||||||
|
self.emit_endpoint(
|
||||||
|
"pairing",
|
||||||
|
"device-blocked",
|
||||||
|
json!({ "peer_endpoint_id": peer_endpoint_id }),
|
||||||
|
);
|
||||||
|
// Silence: blocked peers are not notified (design §8).
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn unblock_device(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: String,
|
||||||
|
) -> Result<(), crate::error::VnidropError> {
|
||||||
|
self.repository
|
||||||
|
.contacts()
|
||||||
|
.unblock_endpoint(&peer_endpoint_id)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
// Unblock removes only the deny rule; grants/relationships stay gone.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn list_blocked_devices(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<String>, crate::error::VnidropError> {
|
||||||
|
self.repository
|
||||||
|
.contacts()
|
||||||
|
.list_blocked()
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn rotate_relationship_grant(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: String,
|
||||||
|
) -> Result<u64, crate::error::VnidropError> {
|
||||||
|
self.device_relationships
|
||||||
|
.rotate_relationship_grant(peer_endpoint_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels active or resumable targeted transfers for a peer relationship.
|
||||||
|
///
|
||||||
|
/// Stub until ticket 10 lands targeted-transfer cancellation. Forget/block
|
||||||
|
/// call this for immediate local effect without touching invitation shares.
|
||||||
|
pub(super) async fn cancel_targeted_transfers_for_peer(&self, peer_endpoint_id: &str) {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
self.targeted_cancel_log
|
||||||
|
.lock()
|
||||||
|
.expect("targeted cancel log")
|
||||||
|
.push(peer_endpoint_id.to_string());
|
||||||
|
}
|
||||||
|
let _ = peer_endpoint_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
|
||||||
|
self.targeted_cancel_log
|
||||||
|
.lock()
|
||||||
|
.expect("targeted cancel log")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(super) async fn submit_pairing_eligibility_for_test(
|
pub(super) async fn submit_pairing_eligibility_for_test(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -118,6 +118,47 @@ impl VnidropCore {
|
|||||||
capability,
|
capability,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn relationship_issued_grant_for_test(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: String,
|
||||||
|
) -> Result<Option<(u64, String)>, VnidropError> {
|
||||||
|
self.block_on(
|
||||||
|
self.inner
|
||||||
|
.device_relationships
|
||||||
|
.issued_grant_snapshot(&peer_endpoint_id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn relationship_tombstones_for_test(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: String,
|
||||||
|
) -> Result<Vec<crate::device_relationship::GenerationTombstone>, VnidropError> {
|
||||||
|
self.block_on(
|
||||||
|
self.inner
|
||||||
|
.device_relationships
|
||||||
|
.list_tombstones(&peer_endpoint_id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reject_relationship_generation_for_test(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: String,
|
||||||
|
generation: u64,
|
||||||
|
grant_id: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.block_on(async {
|
||||||
|
self.inner
|
||||||
|
.device_relationships
|
||||||
|
.reject_replayed_generation(&peer_endpoint_id, generation, grant_id.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(|rejection| rejection.as_str().to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
|
||||||
|
self.inner.targeted_cancel_log_for_test()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
@@ -418,6 +459,31 @@ impl VnidropCore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forget a saved device: revoke locally, clean secrets, cancel that
|
||||||
|
/// relationship's targeted transfers, and best-effort notify the peer.
|
||||||
|
pub fn forget_saved_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> {
|
||||||
|
self.block_on(self.inner.forget_saved_device(peer_endpoint_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity-wide deny across pairing, targeted transfer, invitation, and handshake.
|
||||||
|
pub fn block_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> {
|
||||||
|
self.block_on(self.inner.block_device(peer_endpoint_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove only the deny rule; does not restore grants or relationships.
|
||||||
|
pub fn unblock_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> {
|
||||||
|
self.block_on(self.inner.unblock_device(peer_endpoint_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_blocked_devices(&self) -> Result<Vec<String>, VnidropError> {
|
||||||
|
self.block_on(self.inner.list_blocked_devices())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate the prior relationship generation, then activate a replacement grant.
|
||||||
|
pub fn rotate_relationship_grant(&self, peer_endpoint_id: String) -> Result<u64, VnidropError> {
|
||||||
|
self.block_on(self.inner.rotate_relationship_grant(peer_endpoint_id))
|
||||||
|
}
|
||||||
|
|
||||||
/// Devices the user has chosen to remember.
|
/// Devices the user has chosen to remember.
|
||||||
pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> {
|
pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> {
|
||||||
self.block_on(self.inner.list_contacts())
|
self.block_on(self.inner.list_contacts())
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ pub(super) struct CoreInner {
|
|||||||
pub(super) delivery_receipt_notify: Notify,
|
pub(super) delivery_receipt_notify: Notify,
|
||||||
pub(super) delivery_receipt_task: TokioMutex<Option<JoinHandle<()>>>,
|
pub(super) delivery_receipt_task: TokioMutex<Option<JoinHandle<()>>>,
|
||||||
pub(super) shutdown_started: AtomicBool,
|
pub(super) shutdown_started: AtomicBool,
|
||||||
|
/// Test-only log of peers passed to [`Self::cancel_targeted_transfers_for_peer`].
|
||||||
|
#[cfg(test)]
|
||||||
|
targeted_cancel_log: std::sync::Mutex<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct ActiveTransfer {
|
pub(super) struct ActiveTransfer {
|
||||||
@@ -435,6 +438,8 @@ impl CoreInner {
|
|||||||
delivery_receipt_notify: Notify::new(),
|
delivery_receipt_notify: Notify::new(),
|
||||||
delivery_receipt_task: TokioMutex::new(None),
|
delivery_receipt_task: TokioMutex::new(None),
|
||||||
shutdown_started: AtomicBool::new(false),
|
shutdown_started: AtomicBool::new(false),
|
||||||
|
#[cfg(test)]
|
||||||
|
targeted_cancel_log: std::sync::Mutex::new(Vec::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
inner.emit_endpoint(
|
inner.emit_endpoint(
|
||||||
|
|||||||
@@ -315,3 +315,228 @@ fn request_timeout_leaves_recoverable_pending_not_saved() {
|
|||||||
"timed-out pairing must not surface as saved"
|
"timed-out pairing must not surface as saved"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reach_saved(alice: &ProtectedNode, bob: &ProtectedNode, transfer_id: u64) {
|
||||||
|
let alice_id = alice.core.status().endpoint_id.clone();
|
||||||
|
let bob_id = bob.core.status().endpoint_id.clone();
|
||||||
|
complete_transfer(alice, bob, transfer_id);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotating_grant_invalidates_prior_generation_and_leaves_one_active() {
|
||||||
|
let alice = ProtectedNode::new();
|
||||||
|
let bob = ProtectedNode::new();
|
||||||
|
let bob_id = bob.core.status().endpoint_id.clone();
|
||||||
|
reach_saved(&alice, &bob, 90_001);
|
||||||
|
|
||||||
|
let (old_generation, old_grant_id) = alice
|
||||||
|
.core
|
||||||
|
.relationship_issued_grant_for_test(bob_id.clone())
|
||||||
|
.unwrap()
|
||||||
|
.expect("issued grant before rotate");
|
||||||
|
assert_eq!(old_generation, 1);
|
||||||
|
|
||||||
|
let new_generation = alice
|
||||||
|
.core
|
||||||
|
.rotate_relationship_grant(bob_id.clone())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(new_generation, 2);
|
||||||
|
|
||||||
|
let relationships = alice.core.list_device_relationships().unwrap();
|
||||||
|
assert_eq!(relationships.len(), 1);
|
||||||
|
assert_eq!(relationships[0].generation, 2);
|
||||||
|
assert_eq!(relationships[0].state, DeviceRelationshipState::Saved);
|
||||||
|
|
||||||
|
let (active_generation, active_grant_id) = alice
|
||||||
|
.core
|
||||||
|
.relationship_issued_grant_for_test(bob_id.clone())
|
||||||
|
.unwrap()
|
||||||
|
.expect("issued grant after rotate");
|
||||||
|
assert_eq!(active_generation, 2);
|
||||||
|
assert_ne!(active_grant_id, old_grant_id);
|
||||||
|
|
||||||
|
let err = alice
|
||||||
|
.core
|
||||||
|
.reject_relationship_generation_for_test(
|
||||||
|
bob_id.clone(),
|
||||||
|
old_generation,
|
||||||
|
Some(old_grant_id.clone()),
|
||||||
|
)
|
||||||
|
.expect_err("tombstoned generation must be rejected");
|
||||||
|
assert_eq!(err, "revoked");
|
||||||
|
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.reject_relationship_generation_for_test(bob_id.clone(), new_generation, None)
|
||||||
|
.expect("active generation remains usable");
|
||||||
|
|
||||||
|
let tombstones = alice.core.relationship_tombstones_for_test(bob_id).unwrap();
|
||||||
|
assert_eq!(tombstones.len(), 1);
|
||||||
|
assert_eq!(tombstones[0].generation, old_generation);
|
||||||
|
assert_eq!(
|
||||||
|
tombstones[0].issued_grant_id.as_deref(),
|
||||||
|
Some(old_grant_id.as_str())
|
||||||
|
);
|
||||||
|
// Minimal non-secret payload only.
|
||||||
|
assert!(tombstones[0].revoked_at > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_saved_device_revokes_locally_and_hooks_targeted_cancel() {
|
||||||
|
let alice = ProtectedNode::new();
|
||||||
|
let bob = ProtectedNode::new();
|
||||||
|
let bob_id = bob.core.status().endpoint_id.clone();
|
||||||
|
reach_saved(&alice, &bob, 90_010);
|
||||||
|
|
||||||
|
alice.core.forget_saved_device(bob_id.clone()).unwrap();
|
||||||
|
|
||||||
|
assert!(alice.core.list_saved_devices().unwrap().is_empty());
|
||||||
|
let relationships = alice.core.list_device_relationships().unwrap();
|
||||||
|
assert!(
|
||||||
|
relationships.is_empty(),
|
||||||
|
"forgotten relationship must not remain listed"
|
||||||
|
);
|
||||||
|
let cancels = alice.core.targeted_cancel_log_for_test();
|
||||||
|
assert_eq!(cancels, vec![bob_id.clone()]);
|
||||||
|
|
||||||
|
let tombstones = alice
|
||||||
|
.core
|
||||||
|
.relationship_tombstones_for_test(bob_id.clone())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tombstones.len(), 1);
|
||||||
|
assert!(alice
|
||||||
|
.core
|
||||||
|
.reject_relationship_generation_for_test(bob_id, tombstones[0].generation, None)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_does_not_cancel_active_invitation_transfer() {
|
||||||
|
let alice = ProtectedNode::new();
|
||||||
|
let bob = ProtectedNode::new();
|
||||||
|
let bob_id = bob.core.status().endpoint_id.clone();
|
||||||
|
reach_saved(&alice, &bob, 90_020);
|
||||||
|
|
||||||
|
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"invitation continues after forget").unwrap();
|
||||||
|
let share = share_path(&alice.core, &source_path, 90_021);
|
||||||
|
let output = output_dir.path().to_string_lossy().to_string();
|
||||||
|
let receiver_core = bob.core.clone();
|
||||||
|
let ticket = share.ticket.clone();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
receiver_core.receive(ticket, output, Some("receiver".to_string()))
|
||||||
|
});
|
||||||
|
let request = wait_for_receiver_request(&alice.core, share.transfer_id);
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.respond_receiver_request(request.id, true, None)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Forget after the invitation is approved; the share-domain transfer must finish.
|
||||||
|
alice.core.forget_saved_device(bob_id).unwrap();
|
||||||
|
handle.join().unwrap().unwrap();
|
||||||
|
|
||||||
|
let transfers = alice.core.list_transfers().unwrap();
|
||||||
|
let invitation = transfers
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.transfer_id == share.transfer_id)
|
||||||
|
.expect("invitation transfer retained");
|
||||||
|
assert_ne!(
|
||||||
|
invitation.status.to_lowercase(),
|
||||||
|
"cancelled",
|
||||||
|
"forget must not cancel independently approved invitation transfers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_rejects_pairing_and_invitation_handshake_unblock_restores_neither() {
|
||||||
|
let alice = ProtectedNode::new();
|
||||||
|
let bob = ProtectedNode::new();
|
||||||
|
let alice_id = alice.core.status().endpoint_id.clone();
|
||||||
|
reach_saved(&alice, &bob, 90_030);
|
||||||
|
|
||||||
|
bob.core.block_device(alice_id.clone()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
bob.core.list_blocked_devices().unwrap(),
|
||||||
|
vec![alice_id.clone()]
|
||||||
|
);
|
||||||
|
assert!(bob.core.list_saved_devices().unwrap().is_empty());
|
||||||
|
assert!(bob.core.list_device_relationships().unwrap().is_empty());
|
||||||
|
|
||||||
|
// Outbound pairing toward a blocked identity is refused locally.
|
||||||
|
assert!(!bob
|
||||||
|
.core
|
||||||
|
.request_saved_device_pairing(alice_id.clone())
|
||||||
|
.unwrap());
|
||||||
|
|
||||||
|
// Invitation handshake from the blocked identity is refused.
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let output_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("blocked.txt");
|
||||||
|
std::fs::write(&source_path, b"blocked").unwrap();
|
||||||
|
let share = share_path(&bob.core, &source_path, 90_032);
|
||||||
|
let receive_result = alice.core.receive(
|
||||||
|
share.ticket,
|
||||||
|
output_dir.path().to_string_lossy().into_owned(),
|
||||||
|
Some("alice".to_string()),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
receive_result.is_err(),
|
||||||
|
"blocked endpoint must fail invitation handshake"
|
||||||
|
);
|
||||||
|
|
||||||
|
bob.core.unblock_device(alice_id).unwrap();
|
||||||
|
assert!(bob.core.list_blocked_devices().unwrap().is_empty());
|
||||||
|
assert!(
|
||||||
|
bob.core.list_saved_devices().unwrap().is_empty(),
|
||||||
|
"unblock must not restore the relationship"
|
||||||
|
);
|
||||||
|
assert!(bob.core.list_device_relationships().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reinstalled_peer_is_never_merged_by_name_or_metadata() {
|
||||||
|
let alice = ProtectedNode::new();
|
||||||
|
let bob = ProtectedNode::new();
|
||||||
|
let charlie = ProtectedNode::new();
|
||||||
|
let bob_id = bob.core.status().endpoint_id.clone();
|
||||||
|
let charlie_id = charlie.core.status().endpoint_id.clone();
|
||||||
|
assert_ne!(bob_id, charlie_id);
|
||||||
|
|
||||||
|
reach_saved(&alice, &bob, 90_040);
|
||||||
|
// Same display-facing label on a different endpoint identity must not merge.
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.set_contact_label(bob_id.clone(), Some("Kitchen Tablet".to_string()))
|
||||||
|
.ok();
|
||||||
|
reach_saved(&alice, &charlie, 90_041);
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.set_contact_label(charlie_id.clone(), Some("Kitchen Tablet".to_string()))
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let saved = alice.core.list_saved_devices().unwrap();
|
||||||
|
assert_eq!(saved.len(), 2);
|
||||||
|
let ids: std::collections::HashSet<_> =
|
||||||
|
saved.into_iter().map(|device| device.endpoint_id).collect();
|
||||||
|
assert!(ids.contains(&bob_id));
|
||||||
|
assert!(ids.contains(&charlie_id));
|
||||||
|
assert_eq!(alice.core.list_device_relationships().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user