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:
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 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",
|
||||
|
||||
Reference in New Issue
Block a user