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:
2026-08-11 03:27:28 +02:00
parent 193fe7c757
commit 11fae16391
7 changed files with 888 additions and 6 deletions

View File

@@ -178,6 +178,18 @@ impl ApprovalService {
remote_endpoint_id: String,
request: RequestTransfer,
) -> 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 = [
request.transfer_hash.as_str(),
request.transfer_name.as_str(),

View 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
}
}

View File

@@ -31,7 +31,12 @@ use crate::{
};
mod crypto;
mod lifecycle;
#[cfg(test)]
pub(crate) use lifecycle::GenerationTombstone;
use crate::contacts::ContactStore;
use crypto::{
encode_relationship_grant_secret, prove_relationship_grant, secret_from_material,
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;
locks
.entry(peer_endpoint_id.to_string())
@@ -115,9 +120,21 @@ impl DeviceRelationshipService {
.execute(pool)
.await?;
}
Self::ensure_lifecycle_schema(pool).await?;
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.
pub(crate) async fn reconcile(&self) -> Result<(), VnidropError> {
let Some(custody) = &self.custody else {
@@ -139,19 +156,28 @@ impl DeviceRelationshipService {
let state = parse_state(&row.get::<String, _>("state"))?;
let issued: Option<String> = row.get("issued_grant_handle");
let held: Option<String> = row.get("held_grant_handle");
let mut missing = false;
for handle in [&issued, &held].into_iter().flatten() {
let mut issued_missing = issued.is_none();
if let Some(handle) = &issued {
live_handles.insert(handle.clone());
if custody
.load(&SecretHandle::from_stored(handle.clone()))
.await
.is_err()
{
missing = true;
issued_missing = true;
}
}
if missing && state == DeviceRelationshipState::Saved {
// Grants are required for a usable saved device.
if let Some(handle) = &held {
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?;
}
}
@@ -202,6 +228,9 @@ impl DeviceRelationshipService {
self: &Arc<Self>,
peer_endpoint_id: String,
) -> 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 _guard = peer_lock.lock().await;
@@ -316,6 +345,9 @@ impl DeviceRelationshipService {
peer_endpoint_id: String,
accepted: bool,
) -> 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 {
return Ok(false);
};
@@ -442,6 +474,10 @@ impl DeviceRelationshipService {
remote_endpoint_id: String,
request: PairingRequest,
) -> 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 _guard = peer_lock.lock().await;
@@ -520,6 +556,9 @@ impl DeviceRelationshipService {
remote_endpoint_id: String,
consent: PairingConsent,
) -> PairingConsentResponse {
if self.is_blocked(&remote_endpoint_id).await {
return PairingConsentResponse::Rejected;
}
let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else {
return PairingConsentResponse::Rejected;
};
@@ -588,6 +627,9 @@ impl DeviceRelationshipService {
remote_endpoint_id: String,
ack: PairingAck,
) -> PairingAckResponse {
if self.is_blocked(&remote_endpoint_id).await {
return PairingAckResponse::Rejected;
}
let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else {
return PairingAckResponse::Rejected;
};
@@ -854,6 +896,15 @@ impl DeviceRelationshipService {
generation: u64,
protocol_version: u16,
) -> 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 =
self.custody
.as_ref()
@@ -1103,6 +1154,19 @@ impl ProtocolHandler for RelationshipProtocol {
.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;
@@ -1142,6 +1206,13 @@ impl RelationshipClient {
async fn pairing_ack(&self, ack: PairingAck) -> Result<PairingAckResponse, irpc::Error> {
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)]
@@ -1212,6 +1283,18 @@ struct WireProof {
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)]
#[derive(Debug, Serialize, Deserialize)]
#[allow(
@@ -1225,6 +1308,8 @@ enum RelationshipMessages {
PairingConsent(PairingConsent),
#[rpc(tx = oneshot::Sender<PairingAckResponse>)]
PairingAck(PairingAck),
#[rpc(tx = oneshot::Sender<RevokeNoticeResponse>)]
RevokeNotice(RevokeNotice),
}
struct RelationshipUpsert<'a> {
@@ -1255,6 +1340,29 @@ struct RelationshipRow {
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 {
match state {
DeviceRelationshipState::PendingOutgoing => "pending_outgoing",

View File

@@ -81,11 +81,130 @@ impl CoreInner {
peer_endpoint_id: String,
accepted: bool,
) -> Result<bool, crate::error::VnidropError> {
if self
.repository
.contacts()
.is_blocked(&peer_endpoint_id)
.await
.unwrap_or(false)
{
return Ok(false);
}
self.device_relationships
.respond_to_pairing(peer_endpoint_id, accepted)
.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)]
pub(super) async fn submit_pairing_eligibility_for_test(
&self,

View File

@@ -118,6 +118,47 @@ impl VnidropCore {
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]
@@ -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.
pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> {
self.block_on(self.inner.list_contacts())

View File

@@ -129,6 +129,9 @@ pub(super) struct CoreInner {
pub(super) delivery_receipt_notify: Notify,
pub(super) delivery_receipt_task: TokioMutex<Option<JoinHandle<()>>>,
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 {
@@ -435,6 +438,8 @@ impl CoreInner {
delivery_receipt_notify: Notify::new(),
delivery_receipt_task: TokioMutex::new(None),
shutdown_started: AtomicBool::new(false),
#[cfg(test)]
targeted_cancel_log: std::sync::Mutex::new(Vec::new()),
});
inner.emit_endpoint(

View File

@@ -315,3 +315,228 @@ fn request_timeout_leaves_recoverable_pending_not_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);
}