From 3b7fd3d468656827ba4ff7947817d8619659936e Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 11 Aug 2026 15:51:35 +0200 Subject: [PATCH] refactor(core): deepen domain stores and invitation-only persistence Peel relationships, eligibility, and secrets off the shared pool into AppDataStores adapters, split pairing service/protocol, and move the invitation Repository into its own module so open_all owns schemas. Co-authored-by: Cursor --- AGENTS.md | 2 +- CONTEXT.md | 4 +- crates/vnidrop/AGENTS.md | 7 +- crates/vnidrop/src/approval.rs | 9 +- crates/vnidrop/src/blocked_devices.rs | 12 +- .../src/device_relationship/lifecycle.rs | 129 +- crates/vnidrop/src/device_relationship/mod.rs | 1622 +---------------- .../src/device_relationship/protocol.rs | 226 +++ .../src/device_relationship/service.rs | 1129 ++++++++++++ .../vnidrop/src/device_relationship/store.rs | 574 ++++++ crates/vnidrop/src/event_hub.rs | 2 +- .../src/{repository.rs => invitation/mod.rs} | 217 +-- crates/vnidrop/src/lib.rs | 2 +- .../mod.rs} | 68 +- .../vnidrop/src/pairing_eligibility/store.rs | 195 ++ crates/vnidrop/src/persistence.rs | 51 +- crates/vnidrop/src/runtime/delivery.rs | 2 +- crates/vnidrop/src/runtime/facade.rs | 4 +- crates/vnidrop/src/runtime/mod.rs | 10 +- crates/vnidrop/src/runtime/receive.rs | 2 +- crates/vnidrop/src/runtime/share.rs | 2 +- crates/vnidrop/src/tests/blocked_devices.rs | 9 +- crates/vnidrop/src/tests/control_plane.rs | 2 +- crates/vnidrop/src/tests/persistence.rs | 41 +- crates/vnidrop/src/tests/repository.rs | 2 +- crates/vnidrop/src/tests/runtime.rs | 2 +- crates/vnidrop/src/tests/secure_secret.rs | 86 +- .../vnidrop/src/tests/secure_secret_linux.rs | 10 +- .../src/tests/secure_secret_windows.rs | 11 +- 29 files changed, 2344 insertions(+), 2088 deletions(-) create mode 100644 crates/vnidrop/src/device_relationship/protocol.rs create mode 100644 crates/vnidrop/src/device_relationship/service.rs create mode 100644 crates/vnidrop/src/device_relationship/store.rs rename crates/vnidrop/src/{repository.rs => invitation/mod.rs} (85%) rename crates/vnidrop/src/{pairing_eligibility.rs => pairing_eligibility/mod.rs} (89%) create mode 100644 crates/vnidrop/src/pairing_eligibility/store.rs diff --git a/AGENTS.md b/AGENTS.md index dab6e35..de1f47e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,7 +139,7 @@ crates/vnidrop/src/runtime/ provider.rs # provider events, per-connection send progress ``` -Other core modules: `filesystem.rs`, `repository.rs`, `approval.rs`, +Other core modules: `filesystem.rs`, `invitation/`, `approval.rs`, `handshake.rs`, `ticket.rs`, `access_policy.rs`, `event_hub.rs`, `api.rs`. ### Shared app diff --git a/CONTEXT.md b/CONTEXT.md index 943fd56..5c4308e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -23,11 +23,11 @@ _Avoid_: contact record, friendship ## Persistence (core) **Domain store**: -The module that owns schema and queries for one domain (invitation history, targeted transfers, blocked devices, relationship rows, secret metadata). Callers use store methods — never a raw SQL pool. +The module that owns schema and queries for one domain (invitation history, targeted transfers, blocked devices, relationship rows, pairing eligibility, secret metadata). Callers use store methods — never a raw SQL pool. _Avoid_: repository-for-everything, DAO, database layer **Invitation repository**: -The domain store for invitation-transfer history, artifacts, receiver requests, and related events. Today’s type name may still be `Repository`. +The domain store for invitation-transfer history, artifacts, receiver requests, and related events. Module path `invitation`; today’s type name may still be `Repository`. _Avoid_: “the database”, AppDataStores **AppDataStores**: diff --git a/crates/vnidrop/AGENTS.md b/crates/vnidrop/AGENTS.md index 03740aa..006b4b4 100644 --- a/crates/vnidrop/AGENTS.md +++ b/crates/vnidrop/AGENTS.md @@ -57,11 +57,12 @@ src/ saved_devices.rs # experimental saved-device pairing, forget, block targeted.rs # saved-device targeted transfers persistence.rs # AppDataStores / persistence open (domain stores) - repository.rs # invitation-transfer domain store (not raw pool export) - device_relationship/ # mutual consent + grants + invitation/ # invitation-transfer domain store (type name: Repository) + pairing_eligibility/ # eligibility service + store + device_relationship/ # store + service + protocol (ALPN pairing) targeted_transfer/ # targeted protocol + store adapter blocked_devices.rs - secure_secret/ # custody + platform credential adapters + secure_secret/ # custody + platform credential adapters (+ metadata store) filesystem.rs # collect sources, atomic publish, path rules approval.rs / handshake.rs / ticket.rs / access_policy.rs / event_hub.rs api.rs # UniFFI records/enums diff --git a/crates/vnidrop/src/approval.rs b/crates/vnidrop/src/approval.rs index f9b8346..82c7357 100644 --- a/crates/vnidrop/src/approval.rs +++ b/crates/vnidrop/src/approval.rs @@ -7,13 +7,14 @@ use uuid::Uuid; use crate::{ access_policy::{AccessDecision, AccessPolicy, APPROVAL_SESSION_TTL_MS}, + blocked_devices::BlockStore, event_hub::EventHub, handshake::{ DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer, }, + invitation::{ReceiverRequestInsert, Repository}, pairing_eligibility::PairingEligibilityService, - repository::{ReceiverRequestInsert, Repository}, transfer_state::ReceiverRequestStatus, util::now_ms, }; @@ -31,6 +32,7 @@ pub(crate) struct ApprovalDecision { #[derive(Clone)] pub(crate) struct ApprovalService { repository: Repository, + blocked: BlockStore, event_hub: Arc, access_policy: Arc, pending: Arc>>>, @@ -129,6 +131,7 @@ impl ApprovalService { pub(crate) fn new( repository: Repository, + blocked: BlockStore, event_hub: Arc, access_policy: Arc, max_pending: usize, @@ -137,6 +140,7 @@ impl ApprovalService { ) -> Self { Self { repository, + blocked, event_hub, access_policy, pending: Arc::new(Mutex::new(HashMap::new())), @@ -179,8 +183,7 @@ impl ApprovalService { request: RequestTransfer, ) -> HandshakeResponse { if self - .repository - .blocked_devices() + .blocked .is_blocked(&remote_endpoint_id) .await .unwrap_or(true) diff --git a/crates/vnidrop/src/blocked_devices.rs b/crates/vnidrop/src/blocked_devices.rs index d043664..f17a858 100644 --- a/crates/vnidrop/src/blocked_devices.rs +++ b/crates/vnidrop/src/blocked_devices.rs @@ -1,19 +1,9 @@ //! Identity-wide deny list for saved-device and invitation traffic. -//! -//! Unreleased prototype contact / grant / held-offer tables are dropped on open -//! so they leave no compatibility commitment or orphaned authorization. use anyhow::Result; use sqlx::{Row, SqlitePool}; pub(crate) async fn ensure_schema(pool: &SqlitePool) -> Result<()> { - // Prototype artifacts from the unreleased device-history experiment. - for table in ["held_offers", "grants_held", "grants_issued", "contacts"] { - sqlx::query(&format!("DROP TABLE IF EXISTS {table}")) - .execute(pool) - .await?; - } - sqlx::query( r#" CREATE TABLE IF NOT EXISTS blocked_endpoints ( @@ -28,7 +18,7 @@ pub(crate) async fn ensure_schema(pool: &SqlitePool) -> Result<()> { Ok(()) } -/// Durable deny records over the shared repository pool. +/// Durable deny records for one app-data profile. #[derive(Debug, Clone)] pub(crate) struct BlockStore { pool: SqlitePool, diff --git a/crates/vnidrop/src/device_relationship/lifecycle.rs b/crates/vnidrop/src/device_relationship/lifecycle.rs index 25fe4fc..099561c 100644 --- a/crates/vnidrop/src/device_relationship/lifecycle.rs +++ b/crates/vnidrop/src/device_relationship/lifecycle.rs @@ -1,28 +1,13 @@ //! Forget, block, grant rotation, and minimal revocation tombstones (design §7–§8). use serde_json::json; -use sqlx::Row; -use super::{DeviceRelationshipService, RelationshipRow}; +use super::{store::RelationshipRow, DeviceRelationshipService}; use crate::{ api::DeviceRelationshipState, error::VnidropError, grant::GrantRejection, - secure_secret::SecretHandle, util::now_ms, + secure_secret::SecretHandle, }; -/// 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, - pub(crate) held_grant_id: Option, - pub(crate) revoked_at: i64, -} - #[derive(Debug, Clone)] pub(crate) struct ForgetOutcome { pub(crate) had_relationship: bool, @@ -31,24 +16,6 @@ pub(crate) struct ForgetOutcome { } 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. @@ -127,25 +94,9 @@ impl DeviceRelationshipService { 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)?; + self.store + .begin_grant_rotation(&peer_endpoint_id, new_generation) + .await?; let _wire = self .mint_and_store_issued_grant( @@ -210,29 +161,8 @@ impl DeviceRelationshipService { pub(crate) async fn list_tombstones( &self, peer_endpoint_id: &str, - ) -> Result, 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::("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()) + ) -> Result, VnidropError> { + self.store.list_tombstones(peer_endpoint_id).await } #[cfg(test)] @@ -254,52 +184,17 @@ impl DeviceRelationshipService { 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(()) + self.store.insert_tombstone(peer_endpoint_id, row).await } async fn find_tombstone( &self, peer_endpoint_id: &str, generation: u64, - ) -> Result, 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::("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"), - })) + ) -> Result, VnidropError> { + self.store + .find_tombstone(peer_endpoint_id, generation) + .await } async fn clear_grant_secrets(&self, row: &RelationshipRow) -> Result<(), VnidropError> { diff --git a/crates/vnidrop/src/device_relationship/mod.rs b/crates/vnidrop/src/device_relationship/mod.rs index 96eb63c..082881c 100644 --- a/crates/vnidrop/src/device_relationship/mod.rs +++ b/crates/vnidrop/src/device_relationship/mod.rs @@ -3,1622 +3,14 @@ //! Implements design §6/§7: pending outgoing/incoming states, directional grants //! bound to relationship generation, and Saved only after mutual acknowledgement. -use std::{collections::HashMap, fmt, sync::Arc, time::Duration}; - -use anyhow::Context; -use data_encoding::HEXLOWER; -use iroh::{ - endpoint::Connection, - protocol::{AcceptError, ProtocolHandler}, - Endpoint, EndpointAddr, EndpointId, RelayUrl, -}; -use irpc::{channel::oneshot, rpc_requests, Client, WithChannels}; -use irpc_iroh::{read_request, IrohLazyRemoteConnection}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use sqlx::{Row, SqlitePool}; -use tokio::sync::Mutex as TokioMutex; - -use crate::{ - api::{ - experimental_saved_device_capabilities, CoreRelayMode, DeviceRelationship, - DeviceRelationshipState, SavedDevice, - }, - error::VnidropError, - event_hub::EventHub, - grant::{Challenge, GrantId, GrantProof, GrantSecret}, - pairing_eligibility::PairingEligibilityService, - secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial}, - ticket::{encode_persisted_sender_address, filter_peer_addr_for_relay_mode}, - util::now_ms, -}; - mod crypto; mod lifecycle; +mod protocol; +mod service; +mod store; +pub(crate) use protocol::{RelationshipProtocol, WireProof}; +pub(crate) use service::DeviceRelationshipService; +pub(crate) use store::DeviceRelationshipStore; #[cfg(test)] -pub(crate) use lifecycle::GenerationTombstone; - -use crate::blocked_devices::BlockStore; -use crypto::{ - encode_relationship_grant_secret, prove_relationship_grant, secret_from_material, - verify_relationship_grant, -}; - -const PENDING_TTL_MS: i64 = 30 * 60 * 1_000; - -#[derive(Clone)] -pub(crate) struct DeviceRelationshipService { - pool: SqlitePool, - custody: Option>, - eligibility: PairingEligibilityService, - event_hub: Arc, - local_endpoint_id: String, - endpoint: Endpoint, - relay_mode: CoreRelayMode, - custom_relay_urls: Vec, - max_saved_devices: u64, - pairing_timeout: Duration, - peer_locks: Arc>>>>, -} - -impl DeviceRelationshipService { - #[allow( - clippy::too_many_arguments, - reason = "constructor wires custody, eligibility, endpoint, and network profile once" - )] - pub(crate) fn new( - pool: SqlitePool, - custody: Option>, - eligibility: PairingEligibilityService, - event_hub: Arc, - local_endpoint_id: String, - endpoint: Endpoint, - relay_mode: CoreRelayMode, - custom_relay_urls: Vec, - max_saved_devices: u64, - pairing_timeout_ms: u64, - ) -> Self { - Self { - pool, - custody, - eligibility, - event_hub, - local_endpoint_id, - endpoint, - relay_mode, - custom_relay_urls, - max_saved_devices, - pairing_timeout: Duration::from_millis(pairing_timeout_ms), - peer_locks: Arc::new(TokioMutex::new(HashMap::new())), - } - } - - /// Slots consumed by Saved or in-flight mutual-consent relationships. - async fn relationship_slots_used(&self) -> Result { - let row = sqlx::query( - r#" - SELECT COUNT(*) AS n FROM device_relationships - WHERE state IN ('saved', 'pending_outgoing', 'pending_incoming') - "#, - ) - .fetch_one(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(row.get::("n") as u64) - } - - async fn can_create_new_relationship( - &self, - peer_endpoint_id: &str, - ) -> Result { - if self.find_row(peer_endpoint_id).await?.is_some() { - return Ok(true); - } - Ok(self.relationship_slots_used().await? < self.max_saved_devices) - } - - pub(super) async fn lock_peer(&self, peer_endpoint_id: &str) -> Arc> { - let mut locks = self.peer_locks.lock().await; - locks - .entry(peer_endpoint_id.to_string()) - .or_insert_with(|| Arc::new(TokioMutex::new(()))) - .clone() - } - - pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS device_relationships ( - remote_endpoint_id TEXT PRIMARY KEY, - state TEXT NOT NULL, - generation INTEGER NOT NULL, - minimum_protocol_version INTEGER NOT NULL, - session_id TEXT, - issued_grant_handle TEXT, - held_grant_handle TEXT, - issued_grant_id TEXT, - held_grant_id TEXT, - peer_ack INTEGER NOT NULL DEFAULT 0, - local_ack INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - "#, - ) - .execute(pool) - .await?; - let columns = sqlx::query("PRAGMA table_info(device_relationships)") - .fetch_all(pool) - .await?; - let has = |name: &str| columns.iter().any(|row| row.get::(1) == name); - if !has("issued_grant_id") { - sqlx::query("ALTER TABLE device_relationships ADD COLUMN issued_grant_id TEXT") - .execute(pool) - .await?; - } - if !has("held_grant_id") { - sqlx::query("ALTER TABLE device_relationships ADD COLUMN held_grant_id TEXT") - .execute(pool) - .await?; - } - if !has("local_label") { - sqlx::query("ALTER TABLE device_relationships ADD COLUMN local_label TEXT") - .execute(pool) - .await?; - } - Self::ensure_lifecycle_schema(pool).await?; - Ok(()) - } - - pub(super) fn blocked_devices(&self) -> BlockStore { - BlockStore::new(self.pool.clone()) - } - - pub(super) async fn is_blocked(&self, endpoint_id: &str) -> bool { - // Fail closed: a store error must not admit blocked traffic. - self.blocked_devices() - .is_blocked(endpoint_id) - .await - .unwrap_or(true) - } - - /// 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 { - return Ok(()); - }; - let rows = sqlx::query( - r#" - SELECT remote_endpoint_id, issued_grant_handle, held_grant_handle, state - FROM device_relationships - "#, - ) - .fetch_all(&self.pool) - .await - .map_err(VnidropError::repository)?; - - let mut live_handles = std::collections::HashSet::new(); - for row in rows { - let peer: String = row.get("remote_endpoint_id"); - let state = parse_state(&row.get::("state"))?; - let issued: Option = row.get("issued_grant_handle"); - let held: Option = row.get("held_grant_handle"); - 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() - { - issued_missing = true; - } - } - 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?; - } - } - - for handle in custody - .list_active_handles(SecretKind::RelationshipGrant) - .await? - { - if !live_handles.contains(handle.as_str()) { - let _ = custody.remove(&handle).await; - } - } - Ok(()) - } - - pub(crate) async fn list(&self) -> Result, VnidropError> { - self.expire_pending().await?; - let rows = sqlx::query( - r#" - SELECT remote_endpoint_id, state, generation, minimum_protocol_version, created_at, updated_at - FROM device_relationships - ORDER BY updated_at DESC - "#, - ) - .fetch_all(&self.pool) - .await - .map_err(VnidropError::repository)?; - rows.into_iter().map(row_to_relationship).collect() - } - - pub(crate) async fn list_saved_devices(&self) -> Result, VnidropError> { - let rows = sqlx::query( - r#" - SELECT remote_endpoint_id, local_label, created_at, updated_at - FROM device_relationships - WHERE state = 'saved' - ORDER BY updated_at DESC - "#, - ) - .fetch_all(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(rows - .into_iter() - .map(|row| SavedDevice { - endpoint_id: row.get("remote_endpoint_id"), - local_label: row.get("local_label"), - remote_display_name: None, - created_at: row.get("created_at"), - last_authenticated_at: Some(row.get("updated_at")), - }) - .collect()) - } - - /// Sets the user-owned local label for a Saved device. Labels are never - /// overwritten by remote display names. - pub(crate) async fn set_saved_device_label( - &self, - peer_endpoint_id: String, - label: Option, - ) -> Result<(), VnidropError> { - let result = sqlx::query( - r#" - UPDATE device_relationships - SET local_label = ?2, updated_at = ?3 - WHERE remote_endpoint_id = ?1 AND state = 'saved' - "#, - ) - .bind(&peer_endpoint_id) - .bind(label) - .bind(now_ms()) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - if result.rows_affected() == 0 { - return Err(VnidropError::invalid_input(anyhow::anyhow!( - "peer is not a saved device" - ))); - } - self.emit_changed(&peer_endpoint_id, DeviceRelationshipState::Saved); - Ok(()) - } - - pub(crate) async fn request_pairing( - self: &Arc, - peer_endpoint_id: String, - ) -> Result { - 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; - - if let Some(existing) = self.find_row(&peer_endpoint_id).await? { - if existing.state == DeviceRelationshipState::Saved { - return Ok(true); - } - } - - if !self.can_create_new_relationship(&peer_endpoint_id).await? { - return Ok(false); - } - - let Some(taken) = self.eligibility.take_eligibility(&peer_endpoint_id).await? else { - return Ok(false); - }; - let generation = 1_u64; - let now = now_ms(); - - // Peer already prompted us for the same qualifying session: merge into - // one relationship without a second consent prompt. - if let Some(existing) = self.find_row(&peer_endpoint_id).await? { - if existing.state == DeviceRelationshipState::PendingIncoming - && existing.session_id.as_deref() == Some(taken.session_id.as_str()) - { - self.upsert_relationship(RelationshipUpsert { - remote_endpoint_id: &peer_endpoint_id, - state: DeviceRelationshipState::PendingOutgoing, - generation, - minimum_protocol_version: taken.protocol_version, - session_id: Some(&taken.session_id), - issued_grant_handle: None, - held_grant_handle: None, - issued_grant_id: None, - held_grant_id: None, - peer_ack: false, - local_ack: false, - created_at: existing.created_at, - updated_at: now, - }) - .await?; - self.emit_changed(&peer_endpoint_id, DeviceRelationshipState::PendingOutgoing); - drop(_guard); - self.complete_simultaneous_merge( - &peer_endpoint_id, - generation, - taken.protocol_version, - ) - .await?; - return Ok(true); - } - } - - self.upsert_relationship(RelationshipUpsert { - remote_endpoint_id: &peer_endpoint_id, - state: DeviceRelationshipState::PendingOutgoing, - generation, - minimum_protocol_version: taken.protocol_version, - session_id: Some(&taken.session_id), - issued_grant_handle: None, - held_grant_handle: None, - issued_grant_id: None, - held_grant_id: None, - peer_ack: false, - local_ack: false, - created_at: now, - updated_at: now, - }) - .await?; - self.emit_changed(&peer_endpoint_id, DeviceRelationshipState::PendingOutgoing); - drop(_guard); - - let addr = self.peer_addr(&peer_endpoint_id).await?; - let client = RelationshipClient::connect(self.endpoint.clone(), addr); - let response = match tokio::time::timeout( - self.pairing_timeout, - client.pairing_request(PairingRequest { - session_id: taken.session_id.clone(), - capability: taken.capability.to_vec(), - protocol_version: taken.protocol_version, - generation, - }), - ) - .await - { - Ok(Ok(response)) => response, - // Timeout/network failure keeps bounded PendingOutgoing for recovery. - Ok(Err(_)) | Err(_) => return Ok(true), - }; - - match response { - PairingRequestResponse::AwaitingConsent => Ok(true), - PairingRequestResponse::Merged => { - self.complete_simultaneous_merge( - &peer_endpoint_id, - generation, - taken.protocol_version, - ) - .await?; - Ok(true) - } - PairingRequestResponse::AlreadySaved => { - self.activate_saved(&peer_endpoint_id).await?; - Ok(true) - } - PairingRequestResponse::Rejected => { - // Keep pending: a transient reject must not erase recoverable state. - // Explicit decline is handled on the peer via consent=false. - Ok(true) - } - } - } - - pub(crate) async fn respond_to_pairing( - self: &Arc, - peer_endpoint_id: String, - accepted: bool, - ) -> Result { - 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); - }; - if row.state == DeviceRelationshipState::Saved { - return Ok(true); - } - if row.state != DeviceRelationshipState::PendingIncoming { - return Ok(false); - } - if !accepted { - if let Some(session_id) = &row.session_id { - let _ = self - .eligibility - .consume_session(&peer_endpoint_id, session_id) - .await; - } - self.delete_relationship(&peer_endpoint_id).await?; - let addr = self.peer_addr(&peer_endpoint_id).await?; - let client = RelationshipClient::connect(self.endpoint.clone(), addr); - let _ = tokio::time::timeout( - self.pairing_timeout, - client.pairing_consent(PairingConsent { - accepted: false, - grant: None, - challenge: None, - generation: row.generation, - protocol_version: row.minimum_protocol_version, - }), - ) - .await; - return Ok(true); - } - - if let Some(session_id) = &row.session_id { - let _ = self - .eligibility - .consume_session(&peer_endpoint_id, session_id) - .await; - } - - let grant = self - .mint_and_store_issued_grant( - &peer_endpoint_id, - row.generation, - row.minimum_protocol_version, - ) - .await?; - let challenge = Challenge::generate(); - let addr = self.peer_addr(&peer_endpoint_id).await?; - let client = RelationshipClient::connect(self.endpoint.clone(), addr); - let response = match tokio::time::timeout( - self.pairing_timeout, - client.pairing_consent(PairingConsent { - accepted: true, - grant: Some(grant.clone()), - challenge: Some(challenge.encode()), - generation: row.generation, - protocol_version: row.minimum_protocol_version, - }), - ) - .await - { - Ok(Ok(response)) => response, - // Leave PendingIncoming so the operator can retry consent. - Ok(Err(_)) | Err(_) => return Ok(true), - }; - - match response { - PairingConsentResponse::Completed { - grant: peer_grant, - possession_proof, - ack_challenge, - } => { - self.verify_issued_possession( - &peer_endpoint_id, - &challenge, - &possession_proof, - row.generation, - row.minimum_protocol_version, - ) - .await?; - self.store_held_grant(&peer_endpoint_id, &peer_grant) - .await?; - let ack_challenge = - Challenge::decode(&ack_challenge).map_err(VnidropError::invalid_input)?; - let proof = self - .prove_held_possession( - &peer_endpoint_id, - &ack_challenge, - peer_grant.generation, - peer_grant.protocol_version, - ) - .await?; - match tokio::time::timeout( - self.pairing_timeout, - client.pairing_ack(PairingAck { - possession_proof: proof, - challenge: ack_challenge.encode(), - generation: row.generation, - protocol_version: row.minimum_protocol_version, - }), - ) - .await - { - Ok(Ok(PairingAckResponse::Acknowledged | PairingAckResponse::AlreadySaved)) => { - self.set_acks(&peer_endpoint_id, true, true).await?; - self.activate_saved(&peer_endpoint_id).await?; - Ok(true) - } - // Ack lost/failed: grants are stored; pending remains recoverable. - _ => Ok(true), - } - } - PairingConsentResponse::AlreadySaved => { - self.activate_saved(&peer_endpoint_id).await?; - Ok(true) - } - PairingConsentResponse::Rejected => Ok(false), - } - } - - async fn handle_pairing_request( - self: &Arc, - 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; - - if let Ok(Some(existing)) = self.find_row(&remote_endpoint_id).await { - if existing.state == DeviceRelationshipState::Saved { - return PairingRequestResponse::AlreadySaved; - } - // Simultaneous initiation: both sides proved eligibility for the same session. - if existing.state == DeviceRelationshipState::PendingOutgoing - && existing.session_id.as_deref() == Some(request.session_id.as_str()) - { - let generation = existing.generation; - let protocol_version = existing.minimum_protocol_version; - let should_lead = self.local_endpoint_id.as_str() < remote_endpoint_id.as_str(); - drop(_guard); - if should_lead { - let _ = self - .complete_simultaneous_merge( - &remote_endpoint_id, - generation, - protocol_version, - ) - .await; - } - return PairingRequestResponse::Merged; - } - } - - let capability = match SecretMaterial::new(request.capability) { - Ok(capability) => capability, - Err(_) => return PairingRequestResponse::Rejected, - }; - let local_protocol = experimental_saved_device_capabilities().relationship_protocol_version; - // Peers without a compatible saved-device protocol cannot pair; they - // retain ordinary invitation flow outside this ALPN. - if request.protocol_version != local_protocol { - return PairingRequestResponse::Rejected; - } - let accepted = match self - .eligibility - .validate_presented_capability(&remote_endpoint_id, &request.session_id, &capability) - .await - { - Ok(Some(_)) => true, - Ok(None) | Err(_) => false, - }; - if !accepted { - return PairingRequestResponse::Rejected; - } - - match self.can_create_new_relationship(&remote_endpoint_id).await { - Ok(true) => {} - Ok(false) | Err(_) => return PairingRequestResponse::Rejected, - } - - let now = now_ms(); - if self - .upsert_relationship(RelationshipUpsert { - remote_endpoint_id: &remote_endpoint_id, - state: DeviceRelationshipState::PendingIncoming, - generation: request.generation, - minimum_protocol_version: request.protocol_version, - session_id: Some(&request.session_id), - issued_grant_handle: None, - held_grant_handle: None, - issued_grant_id: None, - held_grant_id: None, - peer_ack: false, - local_ack: false, - created_at: now, - updated_at: now, - }) - .await - .is_err() - { - return PairingRequestResponse::Rejected; - } - self.emit_changed( - &remote_endpoint_id, - DeviceRelationshipState::PendingIncoming, - ); - PairingRequestResponse::AwaitingConsent - } - - async fn handle_pairing_consent( - self: &Arc, - 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; - }; - if row.state == DeviceRelationshipState::Saved { - return PairingConsentResponse::AlreadySaved; - } - if row.state != DeviceRelationshipState::PendingOutgoing { - return PairingConsentResponse::Rejected; - } - if consent.protocol_version < row.minimum_protocol_version - || consent.generation != row.generation - { - return PairingConsentResponse::Rejected; - } - if !consent.accepted { - // Peer declined: clear local pending; eligibility was already consumed by requester. - let _ = self.delete_relationship(&remote_endpoint_id).await; - return PairingConsentResponse::Rejected; - } - let (Some(peer_grant), Some(challenge_hex)) = (consent.grant, consent.challenge) else { - return PairingConsentResponse::Rejected; - }; - let Ok(challenge) = Challenge::decode(&challenge_hex) else { - return PairingConsentResponse::Rejected; - }; - if self - .store_held_grant(&remote_endpoint_id, &peer_grant) - .await - .is_err() - { - return PairingConsentResponse::Rejected; - } - let Ok(local_grant) = self - .mint_and_store_issued_grant( - &remote_endpoint_id, - consent.generation, - consent.protocol_version, - ) - .await - else { - return PairingConsentResponse::Rejected; - }; - let Ok(possession_proof) = self - .prove_held_possession( - &remote_endpoint_id, - &challenge, - peer_grant.generation, - peer_grant.protocol_version, - ) - .await - else { - return PairingConsentResponse::Rejected; - }; - let ack_challenge = Challenge::generate(); - if self - .set_acks(&remote_endpoint_id, false, false) - .await - .is_err() - { - return PairingConsentResponse::Rejected; - } - PairingConsentResponse::Completed { - grant: Box::new(local_grant), - possession_proof, - ack_challenge: ack_challenge.encode(), - } - } - - async fn handle_pairing_ack( - &self, - 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; - }; - if row.state == DeviceRelationshipState::Saved { - return PairingAckResponse::AlreadySaved; - } - if row.state != DeviceRelationshipState::PendingOutgoing - && row.state != DeviceRelationshipState::PendingIncoming - { - return PairingAckResponse::Rejected; - } - // Peer proves possession of our issued grant over the ack challenge we - // sent in Completed; the challenge travels again on the ack message. - let Ok(challenge) = Challenge::decode(&ack.challenge) else { - return PairingAckResponse::Rejected; - }; - if self - .verify_issued_possession( - &remote_endpoint_id, - &challenge, - &ack.possession_proof, - ack.generation, - ack.protocol_version, - ) - .await - .is_err() - { - return PairingAckResponse::Rejected; - } - if self - .set_acks(&remote_endpoint_id, true, true) - .await - .is_err() - { - return PairingAckResponse::Rejected; - } - if self.activate_saved(&remote_endpoint_id).await.is_err() { - return PairingAckResponse::Rejected; - } - PairingAckResponse::Acknowledged - } - - async fn complete_simultaneous_merge( - self: &Arc, - peer_endpoint_id: &str, - generation: u64, - protocol_version: u16, - ) -> 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? { - if row.state == DeviceRelationshipState::Saved { - return Ok(()); - } - // Another merge attempt already minted; do not rotate the issued grant. - if row.issued_grant_handle.is_some() { - return Ok(()); - } - } - // Deterministic role: smaller endpoint ID leads the consent+ack exchange. - if self.local_endpoint_id.as_str() >= peer_endpoint_id { - return Ok(()); - } - let grant = self - .mint_and_store_issued_grant(peer_endpoint_id, generation, protocol_version) - .await?; - let challenge = Challenge::generate(); - drop(_guard); - let addr = self.peer_addr(peer_endpoint_id).await?; - let client = RelationshipClient::connect(self.endpoint.clone(), addr); - let response = match tokio::time::timeout( - self.pairing_timeout, - client.pairing_consent(PairingConsent { - accepted: true, - grant: Some(grant), - challenge: Some(challenge.encode()), - generation, - protocol_version, - }), - ) - .await - { - Ok(Ok(response)) => response, - Ok(Err(_)) | Err(_) => return Ok(()), - }; - match response { - PairingConsentResponse::Completed { - grant: peer_grant, - possession_proof, - ack_challenge, - } => { - self.verify_issued_possession( - peer_endpoint_id, - &challenge, - &possession_proof, - generation, - protocol_version, - ) - .await?; - self.store_held_grant(peer_endpoint_id, &peer_grant).await?; - let ack_challenge = - Challenge::decode(&ack_challenge).map_err(VnidropError::invalid_input)?; - let proof = self - .prove_held_possession( - peer_endpoint_id, - &ack_challenge, - peer_grant.generation, - peer_grant.protocol_version, - ) - .await?; - if let Ok(Ok(PairingAckResponse::Acknowledged | PairingAckResponse::AlreadySaved)) = - tokio::time::timeout( - self.pairing_timeout, - client.pairing_ack(PairingAck { - possession_proof: proof, - challenge: ack_challenge.encode(), - generation, - protocol_version, - }), - ) - .await - { - self.set_acks(peer_endpoint_id, true, true).await?; - self.activate_saved(peer_endpoint_id).await?; - } - } - PairingConsentResponse::AlreadySaved => { - self.activate_saved(peer_endpoint_id).await?; - } - PairingConsentResponse::Rejected => {} - } - Ok(()) - } - - async fn mint_and_store_issued_grant( - &self, - peer_endpoint_id: &str, - generation: u64, - protocol_version: u16, - ) -> Result { - let custody = - self.custody - .as_ref() - .ok_or_else(|| VnidropError::SecureStorageUnavailable { - reason: "relationship grants require protected custody".to_string(), - })?; - let secret = GrantSecret::generate(); - let grant_id = GrantId::generate(); - let material = encode_relationship_grant_secret(&secret)?; - let handle = custody - .protect(SecretKind::RelationshipGrant, material, None) - .await?; - sqlx::query( - r#" - UPDATE device_relationships - SET issued_grant_handle = ?2, issued_grant_id = ?3, updated_at = ?4 - WHERE remote_endpoint_id = ?1 - "#, - ) - .bind(peer_endpoint_id) - .bind(handle.as_str()) - .bind(grant_id.encode()) - .bind(now_ms()) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(WireGrant { - grant_id: grant_id.encode(), - secret: secret.encode(), - issuer_endpoint_id: self.local_endpoint_id.clone(), - holder_endpoint_id: peer_endpoint_id.to_string(), - generation, - protocol_version, - }) - } - - async fn store_held_grant( - &self, - peer_endpoint_id: &str, - grant: &WireGrant, - ) -> Result<(), VnidropError> { - if grant.holder_endpoint_id != self.local_endpoint_id - || grant.issuer_endpoint_id != peer_endpoint_id - { - return Err(VnidropError::invalid_input(anyhow::anyhow!( - "relationship grant endpoint binding mismatch" - ))); - } - let custody = - self.custody - .as_ref() - .ok_or_else(|| VnidropError::SecureStorageUnavailable { - reason: "relationship grants require protected custody".to_string(), - })?; - let grant_id = GrantId::decode(&grant.grant_id).map_err(VnidropError::invalid_input)?; - let secret = GrantSecret::decode(&grant.secret).map_err(VnidropError::invalid_input)?; - let material = encode_relationship_grant_secret(&secret)?; - let handle = custody - .protect(SecretKind::RelationshipGrant, material, None) - .await?; - sqlx::query( - r#" - UPDATE device_relationships - SET held_grant_handle = ?2, held_grant_id = ?3, updated_at = ?4 - WHERE remote_endpoint_id = ?1 - "#, - ) - .bind(peer_endpoint_id) - .bind(handle.as_str()) - .bind(grant_id.encode()) - .bind(now_ms()) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - - /// Prove possession of the held grant for a Saved peer (targeted offers). - pub(crate) async fn prove_saved_possession( - &self, - peer_endpoint_id: &str, - challenge: &Challenge, - ) -> Result<(WireProof, u64, u16), VnidropError> { - let row = self.find_row(peer_endpoint_id).await?.ok_or_else(|| { - VnidropError::permission(anyhow::anyhow!("no saved relationship with peer")) - })?; - if row.state != DeviceRelationshipState::Saved { - return Err(VnidropError::permission(anyhow::anyhow!( - "peer is not a saved device" - ))); - } - let proof = self - .prove_held_possession( - peer_endpoint_id, - challenge, - row.generation, - row.minimum_protocol_version, - ) - .await?; - Ok((proof, row.generation, row.minimum_protocol_version)) - } - - /// Verify a Saved peer's held-grant proof against our issued grant. - pub(crate) async fn verify_saved_possession( - &self, - peer_endpoint_id: &str, - challenge: &Challenge, - proof: &WireProof, - generation: u64, - protocol_version: u16, - ) -> Result<(), VnidropError> { - let row = self.find_row(peer_endpoint_id).await?.ok_or_else(|| { - VnidropError::permission(anyhow::anyhow!("no saved relationship with peer")) - })?; - if row.state != DeviceRelationshipState::Saved { - return Err(VnidropError::permission(anyhow::anyhow!( - "peer is not a saved device" - ))); - } - if row.generation != generation { - return Err(VnidropError::permission(anyhow::anyhow!( - "relationship generation mismatch" - ))); - } - // Established relationships record a protocol floor and reject silent - // downgrade attempts (design §7 / §15). - if protocol_version < row.minimum_protocol_version { - return Err(VnidropError::protocol_incompatible(anyhow::anyhow!( - "relationship protocol downgrade is forbidden" - ))); - } - self.verify_issued_possession( - peer_endpoint_id, - challenge, - proof, - generation, - protocol_version, - ) - .await - } - - pub(crate) async fn require_saved(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> { - let row = self.find_row(peer_endpoint_id).await?.ok_or_else(|| { - VnidropError::permission(anyhow::anyhow!("no saved relationship with peer")) - })?; - if row.state != DeviceRelationshipState::Saved { - return Err(VnidropError::permission(anyhow::anyhow!( - "peer is not a saved device" - ))); - } - Ok(()) - } - - #[cfg(test)] - pub(crate) async fn force_minimum_protocol_version_for_test( - &self, - peer_endpoint_id: &str, - minimum_protocol_version: u16, - ) -> Result<(), VnidropError> { - sqlx::query( - "UPDATE device_relationships SET minimum_protocol_version = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1", - ) - .bind(peer_endpoint_id) - .bind(i64::from(minimum_protocol_version)) - .bind(now_ms()) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - - async fn prove_held_possession( - &self, - peer_endpoint_id: &str, - challenge: &Challenge, - generation: u64, - protocol_version: u16, - ) -> Result { - let custody = - self.custody - .as_ref() - .ok_or_else(|| VnidropError::SecureStorageUnavailable { - reason: "relationship grants require protected custody".to_string(), - })?; - let row = self.find_row(peer_endpoint_id).await?.ok_or_else(|| { - VnidropError::invalid_input(anyhow::anyhow!("missing relationship for proof")) - })?; - let handle = row.held_grant_handle.ok_or_else(|| { - VnidropError::invalid_input(anyhow::anyhow!("missing held grant for proof")) - })?; - let grant_id = GrantId::decode(row.held_grant_id.as_deref().ok_or_else(|| { - VnidropError::invalid_input(anyhow::anyhow!("missing held grant id")) - })?) - .map_err(VnidropError::invalid_input)?; - let material = custody.load(&SecretHandle::from_stored(handle)).await?; - let secret = secret_from_material(&material)?; - let proof = prove_relationship_grant( - grant_id, - &secret, - challenge, - peer_endpoint_id, - &self.local_endpoint_id, - generation, - protocol_version, - ); - Ok(WireProof { - grant_id: proof.grant_id.encode(), - mac: HEXLOWER.encode(proof.mac()), - challenge: challenge.encode(), - }) - } - - async fn verify_issued_possession( - &self, - peer_endpoint_id: &str, - challenge: &Challenge, - proof: &WireProof, - 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() - .ok_or_else(|| VnidropError::SecureStorageUnavailable { - reason: "relationship grants require protected custody".to_string(), - })?; - let row = self.find_row(peer_endpoint_id).await?.ok_or_else(|| { - VnidropError::invalid_input(anyhow::anyhow!("missing relationship for verify")) - })?; - let handle = row.issued_grant_handle.ok_or_else(|| { - VnidropError::invalid_input(anyhow::anyhow!("missing issued grant for verify")) - })?; - let expected_id = row.issued_grant_id.ok_or_else(|| { - VnidropError::invalid_input(anyhow::anyhow!("missing issued grant id")) - })?; - if proof.grant_id != expected_id { - return Err(VnidropError::invalid_input(anyhow::anyhow!( - "relationship grant id mismatch" - ))); - } - let material = custody.load(&SecretHandle::from_stored(handle)).await?; - let secret = secret_from_material(&material)?; - let grant_id = GrantId::decode(&proof.grant_id).map_err(VnidropError::invalid_input)?; - let mac_bytes = HEXLOWER - .decode(proof.mac.as_bytes()) - .context("invalid proof mac") - .map_err(VnidropError::invalid_input)?; - let mac: [u8; 32] = mac_bytes.try_into().map_err(|_| { - VnidropError::invalid_input(anyhow::anyhow!("invalid proof mac length")) - })?; - let presented = GrantProof::from_parts(grant_id, mac); - verify_relationship_grant( - &secret, - &presented, - challenge, - &self.local_endpoint_id, - peer_endpoint_id, - generation, - protocol_version, - ) - .map_err(|error| VnidropError::invalid_input(anyhow::anyhow!(error)))?; - Ok(()) - } - - async fn set_acks( - &self, - peer_endpoint_id: &str, - local_ack: bool, - peer_ack: bool, - ) -> Result<(), VnidropError> { - sqlx::query( - "UPDATE device_relationships SET local_ack = ?2, peer_ack = ?3, updated_at = ?4 WHERE remote_endpoint_id = ?1", - ) - .bind(peer_endpoint_id) - .bind(i64::from(local_ack)) - .bind(i64::from(peer_ack)) - .bind(now_ms()) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - - async fn activate_saved(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> { - sqlx::query( - "UPDATE device_relationships SET state = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1", - ) - .bind(peer_endpoint_id) - .bind(state_as_str(DeviceRelationshipState::Saved)) - .bind(now_ms()) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - self.emit_changed(peer_endpoint_id, DeviceRelationshipState::Saved); - Ok(()) - } - - async fn expire_pending(&self) -> Result<(), VnidropError> { - let cutoff = now_ms() - PENDING_TTL_MS; - let rows = sqlx::query( - r#" - SELECT remote_endpoint_id FROM device_relationships - WHERE state IN ('pending_outgoing', 'pending_incoming') AND updated_at < ?1 - "#, - ) - .bind(cutoff) - .fetch_all(&self.pool) - .await - .map_err(VnidropError::repository)?; - for row in rows { - let peer: String = row.get(0); - self.delete_relationship(&peer).await?; - } - Ok(()) - } - - async fn delete_relationship(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> { - if let Ok(Some(row)) = self.find_row(peer_endpoint_id).await { - if let Some(custody) = &self.custody { - for handle in [row.issued_grant_handle, row.held_grant_handle] - .into_iter() - .flatten() - { - let _ = custody.remove(&SecretHandle::from_stored(handle)).await; - } - } - } - sqlx::query("DELETE FROM device_relationships WHERE remote_endpoint_id = ?1") - .bind(peer_endpoint_id) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - - async fn find_row( - &self, - peer_endpoint_id: &str, - ) -> Result, VnidropError> { - let row = sqlx::query( - r#" - SELECT remote_endpoint_id, state, generation, minimum_protocol_version, session_id, - issued_grant_handle, held_grant_handle, issued_grant_id, held_grant_id, - peer_ack, local_ack, created_at, updated_at - FROM device_relationships WHERE remote_endpoint_id = ?1 - "#, - ) - .bind(peer_endpoint_id) - .fetch_optional(&self.pool) - .await - .map_err(VnidropError::repository)?; - row.map(relationship_row_from_sql).transpose() - } - - async fn upsert_relationship(&self, entry: RelationshipUpsert<'_>) -> Result<(), VnidropError> { - sqlx::query( - r#" - INSERT INTO device_relationships ( - remote_endpoint_id, state, generation, minimum_protocol_version, session_id, - issued_grant_handle, held_grant_handle, issued_grant_id, held_grant_id, - peer_ack, local_ack, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) - ON CONFLICT(remote_endpoint_id) DO UPDATE SET - state = excluded.state, - generation = excluded.generation, - minimum_protocol_version = excluded.minimum_protocol_version, - session_id = excluded.session_id, - issued_grant_handle = COALESCE(excluded.issued_grant_handle, device_relationships.issued_grant_handle), - held_grant_handle = COALESCE(excluded.held_grant_handle, device_relationships.held_grant_handle), - issued_grant_id = COALESCE(excluded.issued_grant_id, device_relationships.issued_grant_id), - held_grant_id = COALESCE(excluded.held_grant_id, device_relationships.held_grant_id), - peer_ack = excluded.peer_ack, - local_ack = excluded.local_ack, - updated_at = excluded.updated_at - "#, - ) - .bind(entry.remote_endpoint_id) - .bind(state_as_str(entry.state)) - .bind(entry.generation as i64) - .bind(i64::from(entry.minimum_protocol_version)) - .bind(entry.session_id) - .bind(entry.issued_grant_handle) - .bind(entry.held_grant_handle) - .bind(entry.issued_grant_id) - .bind(entry.held_grant_id) - .bind(i64::from(entry.peer_ack)) - .bind(i64::from(entry.local_ack)) - .bind(entry.created_at) - .bind(entry.updated_at) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - - pub(crate) async fn peer_addr( - &self, - peer_endpoint_id: &str, - ) -> Result { - let parsed: EndpointId = peer_endpoint_id - .parse() - .context("unusable peer endpoint id") - .map_err(VnidropError::invalid_input)?; - let raw = if let Some(info) = self.endpoint.remote_info(parsed).await { - let mut addr = EndpointAddr::from(parsed); - addr.addrs = info.addrs().map(|entry| entry.addr().clone()).collect(); - let _ = encode_persisted_sender_address(&addr); - addr - } else { - EndpointAddr::from(parsed) - }; - filter_peer_addr_for_relay_mode(&raw, self.relay_mode, &self.custom_relay_urls).map_err( - |error| { - VnidropError::relay_policy_incompatible(anyhow::anyhow!( - "peer address is unusable under the active network profile: {error}" - )) - }, - ) - } - - fn emit_changed(&self, peer_endpoint_id: &str, state: DeviceRelationshipState) { - self.event_hub.emit_endpoint( - "pairing", - "relationship-changed", - json!({ - "peer_endpoint_id": peer_endpoint_id, - "state": state_as_str(state), - }), - ); - } -} - -#[derive(Clone)] -pub(crate) struct RelationshipProtocol { - relationships: Arc, -} - -impl RelationshipProtocol { - pub(crate) const ALPN: &'static [u8] = b"/vnidrop/relationship/1"; - - pub(crate) fn new(relationships: Arc) -> Self { - Self { relationships } - } -} - -impl fmt::Debug for RelationshipProtocol { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("RelationshipProtocol") - } -} - -impl ProtocolHandler for RelationshipProtocol { - async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { - let remote_endpoint_id = connection.remote_id().to_string(); - while let Some(message) = read_request::(&connection).await? { - match message { - RelationshipMessage::PairingRequest(message) => { - let WithChannels { inner, tx, .. } = message; - let response = self - .relationships - .handle_pairing_request(remote_endpoint_id.clone(), inner) - .await; - let _ = tx.send(response).await; - } - RelationshipMessage::PairingConsent(message) => { - let WithChannels { inner, tx, .. } = message; - let response = self - .relationships - .handle_pairing_consent(remote_endpoint_id.clone(), inner) - .await; - let _ = tx.send(response).await; - } - RelationshipMessage::PairingAck(message) => { - let WithChannels { inner, tx, .. } = message; - let response = self - .relationships - .handle_pairing_ack(remote_endpoint_id.clone(), inner) - .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; - Ok(()) - } -} - -struct RelationshipClient { - inner: Client, -} - -impl RelationshipClient { - fn connect(endpoint: Endpoint, addr: EndpointAddr) -> Self { - Self { - inner: Client::boxed(IrohLazyRemoteConnection::new( - endpoint, - addr, - RelationshipProtocol::ALPN.to_vec(), - )), - } - } - - async fn pairing_request( - &self, - request: PairingRequest, - ) -> Result { - self.inner.rpc(request).await - } - - async fn pairing_consent( - &self, - consent: PairingConsent, - ) -> Result { - self.inner.rpc(consent).await - } - - async fn pairing_ack(&self, ack: PairingAck) -> Result { - self.inner.rpc(ack).await - } - - async fn revoke_notice( - &self, - notice: RevokeNotice, - ) -> Result { - self.inner.rpc(notice).await - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct PairingRequest { - session_id: String, - capability: Vec, - protocol_version: u16, - generation: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -enum PairingRequestResponse { - AwaitingConsent, - Merged, - AlreadySaved, - Rejected, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct PairingConsent { - accepted: bool, - grant: Option, - challenge: Option, - generation: u64, - protocol_version: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -enum PairingConsentResponse { - Completed { - grant: Box, - possession_proof: WireProof, - ack_challenge: String, - }, - AlreadySaved, - Rejected, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct PairingAck { - possession_proof: WireProof, - challenge: String, - generation: u64, - protocol_version: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -enum PairingAckResponse { - Acknowledged, - AlreadySaved, - Rejected, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct WireGrant { - grant_id: String, - secret: String, - issuer_endpoint_id: String, - holder_endpoint_id: String, - generation: u64, - protocol_version: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct WireProof { - pub(crate) grant_id: String, - pub(crate) mac: String, - pub(crate) challenge: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct RevokeNotice { - generation: u64, - issued_grant_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -enum RevokeNoticeResponse { - Acknowledged, - Rejected, -} - -#[rpc_requests(message = RelationshipMessage)] -#[derive(Debug, Serialize, Deserialize)] -#[allow( - clippy::enum_variant_names, - reason = "Pairing* names mirror the wire RPC surface" -)] -enum RelationshipMessages { - #[rpc(tx = oneshot::Sender)] - PairingRequest(PairingRequest), - #[rpc(tx = oneshot::Sender)] - PairingConsent(PairingConsent), - #[rpc(tx = oneshot::Sender)] - PairingAck(PairingAck), - #[rpc(tx = oneshot::Sender)] - RevokeNotice(RevokeNotice), -} - -struct RelationshipUpsert<'a> { - remote_endpoint_id: &'a str, - state: DeviceRelationshipState, - generation: u64, - minimum_protocol_version: u16, - session_id: Option<&'a str>, - issued_grant_handle: Option<&'a str>, - held_grant_handle: Option<&'a str>, - issued_grant_id: Option<&'a str>, - held_grant_id: Option<&'a str>, - peer_ack: bool, - local_ack: bool, - created_at: i64, - updated_at: i64, -} - -struct RelationshipRow { - state: DeviceRelationshipState, - generation: u64, - minimum_protocol_version: u16, - session_id: Option, - issued_grant_handle: Option, - held_grant_handle: Option, - issued_grant_id: Option, - held_grant_id: Option, - 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, - ) { - let Ok(addr) = self.peer_addr(peer_endpoint_id).await else { - return; - }; - let client = RelationshipClient::connect(self.endpoint.clone(), addr); - let _ = tokio::time::timeout( - self.pairing_timeout, - client.revoke_notice(RevokeNotice { - generation, - issued_grant_id, - }), - ) - .await; - } -} - -fn state_as_str(state: DeviceRelationshipState) -> &'static str { - match state { - DeviceRelationshipState::PendingOutgoing => "pending_outgoing", - DeviceRelationshipState::PendingIncoming => "pending_incoming", - DeviceRelationshipState::Saved => "saved", - DeviceRelationshipState::Revoked => "revoked", - DeviceRelationshipState::Blocked => "blocked", - } -} - -fn parse_state(value: &str) -> Result { - match value { - "pending_outgoing" => Ok(DeviceRelationshipState::PendingOutgoing), - "pending_incoming" => Ok(DeviceRelationshipState::PendingIncoming), - "saved" => Ok(DeviceRelationshipState::Saved), - "revoked" => Ok(DeviceRelationshipState::Revoked), - "blocked" => Ok(DeviceRelationshipState::Blocked), - _ => Err(VnidropError::Internal { - reason: "unknown device relationship state".to_string(), - }), - } -} - -fn row_to_relationship(row: sqlx::sqlite::SqliteRow) -> Result { - Ok(DeviceRelationship { - remote_endpoint_id: row.get("remote_endpoint_id"), - state: parse_state(&row.get::("state"))?, - generation: row.get::("generation") as u64, - minimum_protocol_version: row.get::("minimum_protocol_version") as u16, - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }) -} - -fn relationship_row_from_sql( - row: sqlx::sqlite::SqliteRow, -) -> Result { - Ok(RelationshipRow { - state: parse_state(&row.get::("state"))?, - generation: row.get::("generation") as u64, - minimum_protocol_version: row.get::("minimum_protocol_version") as u16, - session_id: row.get("session_id"), - issued_grant_handle: row.get("issued_grant_handle"), - held_grant_handle: row.get("held_grant_handle"), - issued_grant_id: row.get("issued_grant_id"), - held_grant_id: row.get("held_grant_id"), - created_at: row.get("created_at"), - }) -} +pub(crate) use store::GenerationTombstone; diff --git a/crates/vnidrop/src/device_relationship/protocol.rs b/crates/vnidrop/src/device_relationship/protocol.rs new file mode 100644 index 0000000..728c176 --- /dev/null +++ b/crates/vnidrop/src/device_relationship/protocol.rs @@ -0,0 +1,226 @@ +//! Iroh ALPN handler and client for mutual-consent pairing. +//! +//! Wire messages and transport live here; durable state and grant custody stay on +//! [`super::service::DeviceRelationshipService`]. + +use std::{fmt, sync::Arc}; + +use iroh::{ + endpoint::Connection, + protocol::{AcceptError, ProtocolHandler}, + Endpoint, EndpointAddr, +}; +use irpc::{channel::oneshot, rpc_requests, Client, WithChannels}; +use irpc_iroh::{read_request, IrohLazyRemoteConnection}; +use serde::{Deserialize, Serialize}; + +use super::DeviceRelationshipService; + +#[derive(Clone)] +pub(crate) struct RelationshipProtocol { + relationships: Arc, +} + +impl RelationshipProtocol { + pub(crate) const ALPN: &'static [u8] = b"/vnidrop/relationship/1"; + + pub(crate) fn new(relationships: Arc) -> Self { + Self { relationships } + } +} + +impl fmt::Debug for RelationshipProtocol { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RelationshipProtocol") + } +} + +impl ProtocolHandler for RelationshipProtocol { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + let remote_endpoint_id = connection.remote_id().to_string(); + while let Some(message) = read_request::(&connection).await? { + match message { + RelationshipMessage::PairingRequest(message) => { + let WithChannels { inner, tx, .. } = message; + let response = self + .relationships + .handle_pairing_request(remote_endpoint_id.clone(), inner) + .await; + let _ = tx.send(response).await; + } + RelationshipMessage::PairingConsent(message) => { + let WithChannels { inner, tx, .. } = message; + let response = self + .relationships + .handle_pairing_consent(remote_endpoint_id.clone(), inner) + .await; + let _ = tx.send(response).await; + } + RelationshipMessage::PairingAck(message) => { + let WithChannels { inner, tx, .. } = message; + let response = self + .relationships + .handle_pairing_ack(remote_endpoint_id.clone(), inner) + .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; + Ok(()) + } +} + +pub(super) struct RelationshipClient { + inner: Client, +} + +impl RelationshipClient { + pub(super) fn connect(endpoint: Endpoint, addr: EndpointAddr) -> Self { + Self { + inner: Client::boxed(IrohLazyRemoteConnection::new( + endpoint, + addr, + RelationshipProtocol::ALPN.to_vec(), + )), + } + } + + pub(super) async fn pairing_request( + &self, + request: PairingRequest, + ) -> Result { + self.inner.rpc(request).await + } + + pub(super) async fn pairing_consent( + &self, + consent: PairingConsent, + ) -> Result { + self.inner.rpc(consent).await + } + + pub(super) async fn pairing_ack( + &self, + ack: PairingAck, + ) -> Result { + self.inner.rpc(ack).await + } + + pub(super) async fn revoke_notice( + &self, + notice: RevokeNotice, + ) -> Result { + self.inner.rpc(notice).await + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PairingRequest { + pub(super) session_id: String, + pub(super) capability: Vec, + pub(super) protocol_version: u16, + pub(super) generation: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) enum PairingRequestResponse { + AwaitingConsent, + Merged, + AlreadySaved, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PairingConsent { + pub(super) accepted: bool, + pub(super) grant: Option, + pub(super) challenge: Option, + pub(super) generation: u64, + pub(super) protocol_version: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) enum PairingConsentResponse { + Completed { + grant: Box, + possession_proof: WireProof, + ack_challenge: String, + }, + AlreadySaved, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PairingAck { + pub(super) possession_proof: WireProof, + pub(super) challenge: String, + pub(super) generation: u64, + pub(super) protocol_version: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) enum PairingAckResponse { + Acknowledged, + AlreadySaved, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct WireGrant { + pub(super) grant_id: String, + pub(super) secret: String, + pub(super) issuer_endpoint_id: String, + pub(super) holder_endpoint_id: String, + pub(super) generation: u64, + pub(super) protocol_version: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct WireProof { + pub(crate) grant_id: String, + pub(crate) mac: String, + pub(crate) challenge: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RevokeNotice { + pub(super) generation: u64, + pub(super) issued_grant_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) enum RevokeNoticeResponse { + Acknowledged, + Rejected, +} + +#[rpc_requests(message = RelationshipMessage)] +#[derive(Debug, Serialize, Deserialize)] +#[allow( + clippy::enum_variant_names, + reason = "Pairing* names mirror the wire RPC surface" +)] +enum RelationshipMessages { + #[rpc(tx = oneshot::Sender)] + PairingRequest(PairingRequest), + #[rpc(tx = oneshot::Sender)] + PairingConsent(PairingConsent), + #[rpc(tx = oneshot::Sender)] + PairingAck(PairingAck), + #[rpc(tx = oneshot::Sender)] + RevokeNotice(RevokeNotice), +} diff --git a/crates/vnidrop/src/device_relationship/service.rs b/crates/vnidrop/src/device_relationship/service.rs new file mode 100644 index 0000000..5cbfac6 --- /dev/null +++ b/crates/vnidrop/src/device_relationship/service.rs @@ -0,0 +1,1129 @@ +//! Pairing orchestration, grant custody, and Saved-device listing. +//! +//! Transport lives in [`super::protocol`]; durable rows in [`super::store`]. + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use anyhow::Context; +use data_encoding::HEXLOWER; +use iroh::{Endpoint, EndpointAddr, EndpointId, RelayUrl}; +use serde_json::json; +use tokio::sync::Mutex as TokioMutex; + +use crate::{ + api::{ + experimental_saved_device_capabilities, CoreRelayMode, DeviceRelationship, + DeviceRelationshipState, SavedDevice, + }, + blocked_devices::BlockStore, + error::VnidropError, + event_hub::EventHub, + grant::{Challenge, GrantId, GrantProof, GrantSecret}, + pairing_eligibility::PairingEligibilityService, + secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial}, + ticket::{encode_persisted_sender_address, filter_peer_addr_for_relay_mode}, + util::now_ms, +}; + +use super::{ + crypto::{ + encode_relationship_grant_secret, prove_relationship_grant, secret_from_material, + verify_relationship_grant, + }, + protocol::{ + PairingAck, PairingAckResponse, PairingConsent, PairingConsentResponse, PairingRequest, + PairingRequestResponse, RelationshipClient, RevokeNotice, WireGrant, WireProof, + }, + store::{state_as_str, DeviceRelationshipStore, RelationshipRow, RelationshipUpsert}, +}; + +const PENDING_TTL_MS: i64 = 30 * 60 * 1_000; + +#[derive(Clone)] +pub(crate) struct DeviceRelationshipService { + pub(super) store: DeviceRelationshipStore, + pub(super) blocked: BlockStore, + pub(super) custody: Option>, + pub(super) eligibility: PairingEligibilityService, + pub(super) event_hub: Arc, + local_endpoint_id: String, + endpoint: Endpoint, + relay_mode: CoreRelayMode, + custom_relay_urls: Vec, + max_saved_devices: u64, + pairing_timeout: Duration, + peer_locks: Arc>>>>, +} + +impl DeviceRelationshipService { + #[allow( + clippy::too_many_arguments, + reason = "constructor wires custody, eligibility, endpoint, and network profile once" + )] + pub(crate) fn new( + store: DeviceRelationshipStore, + blocked: BlockStore, + custody: Option>, + eligibility: PairingEligibilityService, + event_hub: Arc, + local_endpoint_id: String, + endpoint: Endpoint, + relay_mode: CoreRelayMode, + custom_relay_urls: Vec, + max_saved_devices: u64, + pairing_timeout_ms: u64, + ) -> Self { + Self { + store, + blocked, + custody, + eligibility, + event_hub, + local_endpoint_id, + endpoint, + relay_mode, + custom_relay_urls, + max_saved_devices, + pairing_timeout: Duration::from_millis(pairing_timeout_ms), + peer_locks: Arc::new(TokioMutex::new(HashMap::new())), + } + } + + /// Slots consumed by Saved or in-flight mutual-consent relationships. + async fn relationship_slots_used(&self) -> Result { + self.store.count_active_slots().await + } + + async fn can_create_new_relationship( + &self, + peer_endpoint_id: &str, + ) -> Result { + if self.find_row(peer_endpoint_id).await?.is_some() { + return Ok(true); + } + Ok(self.relationship_slots_used().await? < self.max_saved_devices) + } + + pub(super) async fn lock_peer(&self, peer_endpoint_id: &str) -> Arc> { + let mut locks = self.peer_locks.lock().await; + locks + .entry(peer_endpoint_id.to_string()) + .or_insert_with(|| Arc::new(TokioMutex::new(()))) + .clone() + } + + pub(super) fn blocked_devices(&self) -> BlockStore { + self.blocked.clone() + } + + pub(super) async fn is_blocked(&self, endpoint_id: &str) -> bool { + // Fail closed: a store error must not admit blocked traffic. + self.blocked_devices() + .is_blocked(endpoint_id) + .await + .unwrap_or(true) + } + + /// 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 { + return Ok(()); + }; + let rows = self.store.list_reconcile_rows().await?; + + let mut live_handles = std::collections::HashSet::new(); + for row in rows { + let peer = row.remote_endpoint_id; + let state = row.state; + let issued = row.issued_grant_handle; + let held = row.held_grant_handle; + 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() + { + issued_missing = true; + } + } + 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?; + } + } + + for handle in custody + .list_active_handles(SecretKind::RelationshipGrant) + .await? + { + if !live_handles.contains(handle.as_str()) { + let _ = custody.remove(&handle).await; + } + } + Ok(()) + } + + pub(crate) async fn list(&self) -> Result, VnidropError> { + self.expire_pending().await?; + self.store.list().await + } + + pub(crate) async fn list_saved_devices(&self) -> Result, VnidropError> { + self.store.list_saved_devices().await + } + + /// Sets the user-owned local label for a Saved device. Labels are never + /// overwritten by remote display names. + pub(crate) async fn set_saved_device_label( + &self, + peer_endpoint_id: String, + label: Option, + ) -> Result<(), VnidropError> { + if !self + .store + .set_saved_device_label(&peer_endpoint_id, label) + .await? + { + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "peer is not a saved device" + ))); + } + self.emit_changed(&peer_endpoint_id, DeviceRelationshipState::Saved); + Ok(()) + } + + pub(crate) async fn request_pairing( + self: &Arc, + peer_endpoint_id: String, + ) -> Result { + 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; + + if let Some(existing) = self.find_row(&peer_endpoint_id).await? { + if existing.state == DeviceRelationshipState::Saved { + return Ok(true); + } + } + + if !self.can_create_new_relationship(&peer_endpoint_id).await? { + return Ok(false); + } + + let Some(taken) = self.eligibility.take_eligibility(&peer_endpoint_id).await? else { + return Ok(false); + }; + let generation = 1_u64; + let now = now_ms(); + + // Peer already prompted us for the same qualifying session: merge into + // one relationship without a second consent prompt. + if let Some(existing) = self.find_row(&peer_endpoint_id).await? { + if existing.state == DeviceRelationshipState::PendingIncoming + && existing.session_id.as_deref() == Some(taken.session_id.as_str()) + { + self.store + .upsert(RelationshipUpsert { + remote_endpoint_id: &peer_endpoint_id, + state: DeviceRelationshipState::PendingOutgoing, + generation, + minimum_protocol_version: taken.protocol_version, + session_id: Some(&taken.session_id), + issued_grant_handle: None, + held_grant_handle: None, + issued_grant_id: None, + held_grant_id: None, + peer_ack: false, + local_ack: false, + created_at: existing.created_at, + updated_at: now, + }) + .await?; + self.emit_changed(&peer_endpoint_id, DeviceRelationshipState::PendingOutgoing); + drop(_guard); + self.complete_simultaneous_merge( + &peer_endpoint_id, + generation, + taken.protocol_version, + ) + .await?; + return Ok(true); + } + } + + self.store + .upsert(RelationshipUpsert { + remote_endpoint_id: &peer_endpoint_id, + state: DeviceRelationshipState::PendingOutgoing, + generation, + minimum_protocol_version: taken.protocol_version, + session_id: Some(&taken.session_id), + issued_grant_handle: None, + held_grant_handle: None, + issued_grant_id: None, + held_grant_id: None, + peer_ack: false, + local_ack: false, + created_at: now, + updated_at: now, + }) + .await?; + self.emit_changed(&peer_endpoint_id, DeviceRelationshipState::PendingOutgoing); + drop(_guard); + + let addr = self.peer_addr(&peer_endpoint_id).await?; + let client = RelationshipClient::connect(self.endpoint.clone(), addr); + let response = match tokio::time::timeout( + self.pairing_timeout, + client.pairing_request(PairingRequest { + session_id: taken.session_id.clone(), + capability: taken.capability.to_vec(), + protocol_version: taken.protocol_version, + generation, + }), + ) + .await + { + Ok(Ok(response)) => response, + // Timeout/network failure keeps bounded PendingOutgoing for recovery. + Ok(Err(_)) | Err(_) => return Ok(true), + }; + + match response { + PairingRequestResponse::AwaitingConsent => Ok(true), + PairingRequestResponse::Merged => { + self.complete_simultaneous_merge( + &peer_endpoint_id, + generation, + taken.protocol_version, + ) + .await?; + Ok(true) + } + PairingRequestResponse::AlreadySaved => { + self.activate_saved(&peer_endpoint_id).await?; + Ok(true) + } + PairingRequestResponse::Rejected => { + // Keep pending: a transient reject must not erase recoverable state. + // Explicit decline is handled on the peer via consent=false. + Ok(true) + } + } + } + + pub(crate) async fn respond_to_pairing( + self: &Arc, + peer_endpoint_id: String, + accepted: bool, + ) -> Result { + 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); + }; + if row.state == DeviceRelationshipState::Saved { + return Ok(true); + } + if row.state != DeviceRelationshipState::PendingIncoming { + return Ok(false); + } + if !accepted { + if let Some(session_id) = &row.session_id { + let _ = self + .eligibility + .consume_session(&peer_endpoint_id, session_id) + .await; + } + self.delete_relationship(&peer_endpoint_id).await?; + let addr = self.peer_addr(&peer_endpoint_id).await?; + let client = RelationshipClient::connect(self.endpoint.clone(), addr); + let _ = tokio::time::timeout( + self.pairing_timeout, + client.pairing_consent(PairingConsent { + accepted: false, + grant: None, + challenge: None, + generation: row.generation, + protocol_version: row.minimum_protocol_version, + }), + ) + .await; + return Ok(true); + } + + if let Some(session_id) = &row.session_id { + let _ = self + .eligibility + .consume_session(&peer_endpoint_id, session_id) + .await; + } + + let grant = self + .mint_and_store_issued_grant( + &peer_endpoint_id, + row.generation, + row.minimum_protocol_version, + ) + .await?; + let challenge = Challenge::generate(); + let addr = self.peer_addr(&peer_endpoint_id).await?; + let client = RelationshipClient::connect(self.endpoint.clone(), addr); + let response = match tokio::time::timeout( + self.pairing_timeout, + client.pairing_consent(PairingConsent { + accepted: true, + grant: Some(grant.clone()), + challenge: Some(challenge.encode()), + generation: row.generation, + protocol_version: row.minimum_protocol_version, + }), + ) + .await + { + Ok(Ok(response)) => response, + // Leave PendingIncoming so the operator can retry consent. + Ok(Err(_)) | Err(_) => return Ok(true), + }; + + match response { + PairingConsentResponse::Completed { + grant: peer_grant, + possession_proof, + ack_challenge, + } => { + self.verify_issued_possession( + &peer_endpoint_id, + &challenge, + &possession_proof, + row.generation, + row.minimum_protocol_version, + ) + .await?; + self.store_held_grant(&peer_endpoint_id, &peer_grant) + .await?; + let ack_challenge = + Challenge::decode(&ack_challenge).map_err(VnidropError::invalid_input)?; + let proof = self + .prove_held_possession( + &peer_endpoint_id, + &ack_challenge, + peer_grant.generation, + peer_grant.protocol_version, + ) + .await?; + match tokio::time::timeout( + self.pairing_timeout, + client.pairing_ack(PairingAck { + possession_proof: proof, + challenge: ack_challenge.encode(), + generation: row.generation, + protocol_version: row.minimum_protocol_version, + }), + ) + .await + { + Ok(Ok(PairingAckResponse::Acknowledged | PairingAckResponse::AlreadySaved)) => { + self.set_acks(&peer_endpoint_id, true, true).await?; + self.activate_saved(&peer_endpoint_id).await?; + Ok(true) + } + // Ack lost/failed: grants are stored; pending remains recoverable. + _ => Ok(true), + } + } + PairingConsentResponse::AlreadySaved => { + self.activate_saved(&peer_endpoint_id).await?; + Ok(true) + } + PairingConsentResponse::Rejected => Ok(false), + } + } + + pub(super) async fn handle_pairing_request( + self: &Arc, + 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; + + if let Ok(Some(existing)) = self.find_row(&remote_endpoint_id).await { + if existing.state == DeviceRelationshipState::Saved { + return PairingRequestResponse::AlreadySaved; + } + // Simultaneous initiation: both sides proved eligibility for the same session. + if existing.state == DeviceRelationshipState::PendingOutgoing + && existing.session_id.as_deref() == Some(request.session_id.as_str()) + { + let generation = existing.generation; + let protocol_version = existing.minimum_protocol_version; + let should_lead = self.local_endpoint_id.as_str() < remote_endpoint_id.as_str(); + drop(_guard); + if should_lead { + let _ = self + .complete_simultaneous_merge( + &remote_endpoint_id, + generation, + protocol_version, + ) + .await; + } + return PairingRequestResponse::Merged; + } + } + + let capability = match SecretMaterial::new(request.capability) { + Ok(capability) => capability, + Err(_) => return PairingRequestResponse::Rejected, + }; + let local_protocol = experimental_saved_device_capabilities().relationship_protocol_version; + // Peers without a compatible saved-device protocol cannot pair; they + // retain ordinary invitation flow outside this ALPN. + if request.protocol_version != local_protocol { + return PairingRequestResponse::Rejected; + } + let accepted = match self + .eligibility + .validate_presented_capability(&remote_endpoint_id, &request.session_id, &capability) + .await + { + Ok(Some(_)) => true, + Ok(None) | Err(_) => false, + }; + if !accepted { + return PairingRequestResponse::Rejected; + } + + match self.can_create_new_relationship(&remote_endpoint_id).await { + Ok(true) => {} + Ok(false) | Err(_) => return PairingRequestResponse::Rejected, + } + + let now = now_ms(); + if self + .store + .upsert(RelationshipUpsert { + remote_endpoint_id: &remote_endpoint_id, + state: DeviceRelationshipState::PendingIncoming, + generation: request.generation, + minimum_protocol_version: request.protocol_version, + session_id: Some(&request.session_id), + issued_grant_handle: None, + held_grant_handle: None, + issued_grant_id: None, + held_grant_id: None, + peer_ack: false, + local_ack: false, + created_at: now, + updated_at: now, + }) + .await + .is_err() + { + return PairingRequestResponse::Rejected; + } + self.emit_changed( + &remote_endpoint_id, + DeviceRelationshipState::PendingIncoming, + ); + PairingRequestResponse::AwaitingConsent + } + + pub(super) async fn handle_pairing_consent( + self: &Arc, + 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; + }; + if row.state == DeviceRelationshipState::Saved { + return PairingConsentResponse::AlreadySaved; + } + if row.state != DeviceRelationshipState::PendingOutgoing { + return PairingConsentResponse::Rejected; + } + if consent.protocol_version < row.minimum_protocol_version + || consent.generation != row.generation + { + return PairingConsentResponse::Rejected; + } + if !consent.accepted { + // Peer declined: clear local pending; eligibility was already consumed by requester. + let _ = self.delete_relationship(&remote_endpoint_id).await; + return PairingConsentResponse::Rejected; + } + let (Some(peer_grant), Some(challenge_hex)) = (consent.grant, consent.challenge) else { + return PairingConsentResponse::Rejected; + }; + let Ok(challenge) = Challenge::decode(&challenge_hex) else { + return PairingConsentResponse::Rejected; + }; + if self + .store_held_grant(&remote_endpoint_id, &peer_grant) + .await + .is_err() + { + return PairingConsentResponse::Rejected; + } + let Ok(local_grant) = self + .mint_and_store_issued_grant( + &remote_endpoint_id, + consent.generation, + consent.protocol_version, + ) + .await + else { + return PairingConsentResponse::Rejected; + }; + let Ok(possession_proof) = self + .prove_held_possession( + &remote_endpoint_id, + &challenge, + peer_grant.generation, + peer_grant.protocol_version, + ) + .await + else { + return PairingConsentResponse::Rejected; + }; + let ack_challenge = Challenge::generate(); + if self + .set_acks(&remote_endpoint_id, false, false) + .await + .is_err() + { + return PairingConsentResponse::Rejected; + } + PairingConsentResponse::Completed { + grant: Box::new(local_grant), + possession_proof, + ack_challenge: ack_challenge.encode(), + } + } + + pub(super) async fn handle_pairing_ack( + &self, + 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; + }; + if row.state == DeviceRelationshipState::Saved { + return PairingAckResponse::AlreadySaved; + } + if row.state != DeviceRelationshipState::PendingOutgoing + && row.state != DeviceRelationshipState::PendingIncoming + { + return PairingAckResponse::Rejected; + } + // Peer proves possession of our issued grant over the ack challenge we + // sent in Completed; the challenge travels again on the ack message. + let Ok(challenge) = Challenge::decode(&ack.challenge) else { + return PairingAckResponse::Rejected; + }; + if self + .verify_issued_possession( + &remote_endpoint_id, + &challenge, + &ack.possession_proof, + ack.generation, + ack.protocol_version, + ) + .await + .is_err() + { + return PairingAckResponse::Rejected; + } + if self + .set_acks(&remote_endpoint_id, true, true) + .await + .is_err() + { + return PairingAckResponse::Rejected; + } + if self.activate_saved(&remote_endpoint_id).await.is_err() { + return PairingAckResponse::Rejected; + } + PairingAckResponse::Acknowledged + } + + async fn complete_simultaneous_merge( + self: &Arc, + peer_endpoint_id: &str, + generation: u64, + protocol_version: u16, + ) -> 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? { + if row.state == DeviceRelationshipState::Saved { + return Ok(()); + } + // Another merge attempt already minted; do not rotate the issued grant. + if row.issued_grant_handle.is_some() { + return Ok(()); + } + } + // Deterministic role: smaller endpoint ID leads the consent+ack exchange. + if self.local_endpoint_id.as_str() >= peer_endpoint_id { + return Ok(()); + } + let grant = self + .mint_and_store_issued_grant(peer_endpoint_id, generation, protocol_version) + .await?; + let challenge = Challenge::generate(); + drop(_guard); + let addr = self.peer_addr(peer_endpoint_id).await?; + let client = RelationshipClient::connect(self.endpoint.clone(), addr); + let response = match tokio::time::timeout( + self.pairing_timeout, + client.pairing_consent(PairingConsent { + accepted: true, + grant: Some(grant), + challenge: Some(challenge.encode()), + generation, + protocol_version, + }), + ) + .await + { + Ok(Ok(response)) => response, + Ok(Err(_)) | Err(_) => return Ok(()), + }; + match response { + PairingConsentResponse::Completed { + grant: peer_grant, + possession_proof, + ack_challenge, + } => { + self.verify_issued_possession( + peer_endpoint_id, + &challenge, + &possession_proof, + generation, + protocol_version, + ) + .await?; + self.store_held_grant(peer_endpoint_id, &peer_grant).await?; + let ack_challenge = + Challenge::decode(&ack_challenge).map_err(VnidropError::invalid_input)?; + let proof = self + .prove_held_possession( + peer_endpoint_id, + &ack_challenge, + peer_grant.generation, + peer_grant.protocol_version, + ) + .await?; + if let Ok(Ok(PairingAckResponse::Acknowledged | PairingAckResponse::AlreadySaved)) = + tokio::time::timeout( + self.pairing_timeout, + client.pairing_ack(PairingAck { + possession_proof: proof, + challenge: ack_challenge.encode(), + generation, + protocol_version, + }), + ) + .await + { + self.set_acks(peer_endpoint_id, true, true).await?; + self.activate_saved(peer_endpoint_id).await?; + } + } + PairingConsentResponse::AlreadySaved => { + self.activate_saved(peer_endpoint_id).await?; + } + PairingConsentResponse::Rejected => {} + } + Ok(()) + } + + pub(super) async fn mint_and_store_issued_grant( + &self, + peer_endpoint_id: &str, + generation: u64, + protocol_version: u16, + ) -> Result { + let custody = + self.custody + .as_ref() + .ok_or_else(|| VnidropError::SecureStorageUnavailable { + reason: "relationship grants require protected custody".to_string(), + })?; + let secret = GrantSecret::generate(); + let grant_id = GrantId::generate(); + let material = encode_relationship_grant_secret(&secret)?; + let handle = custody + .protect(SecretKind::RelationshipGrant, material, None) + .await?; + self.store + .set_issued_grant(peer_endpoint_id, handle.as_str(), &grant_id.encode()) + .await?; + Ok(WireGrant { + grant_id: grant_id.encode(), + secret: secret.encode(), + issuer_endpoint_id: self.local_endpoint_id.clone(), + holder_endpoint_id: peer_endpoint_id.to_string(), + generation, + protocol_version, + }) + } + + async fn store_held_grant( + &self, + peer_endpoint_id: &str, + grant: &WireGrant, + ) -> Result<(), VnidropError> { + if grant.holder_endpoint_id != self.local_endpoint_id + || grant.issuer_endpoint_id != peer_endpoint_id + { + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "relationship grant endpoint binding mismatch" + ))); + } + let custody = + self.custody + .as_ref() + .ok_or_else(|| VnidropError::SecureStorageUnavailable { + reason: "relationship grants require protected custody".to_string(), + })?; + let grant_id = GrantId::decode(&grant.grant_id).map_err(VnidropError::invalid_input)?; + let secret = GrantSecret::decode(&grant.secret).map_err(VnidropError::invalid_input)?; + let material = encode_relationship_grant_secret(&secret)?; + let handle = custody + .protect(SecretKind::RelationshipGrant, material, None) + .await?; + self.store + .set_held_grant(peer_endpoint_id, handle.as_str(), &grant_id.encode()) + .await?; + Ok(()) + } + + /// Prove possession of the held grant for a Saved peer (targeted offers). + pub(crate) async fn prove_saved_possession( + &self, + peer_endpoint_id: &str, + challenge: &Challenge, + ) -> Result<(WireProof, u64, u16), VnidropError> { + let row = self.find_row(peer_endpoint_id).await?.ok_or_else(|| { + VnidropError::permission(anyhow::anyhow!("no saved relationship with peer")) + })?; + if row.state != DeviceRelationshipState::Saved { + return Err(VnidropError::permission(anyhow::anyhow!( + "peer is not a saved device" + ))); + } + let proof = self + .prove_held_possession( + peer_endpoint_id, + challenge, + row.generation, + row.minimum_protocol_version, + ) + .await?; + Ok((proof, row.generation, row.minimum_protocol_version)) + } + + /// Verify a Saved peer's held-grant proof against our issued grant. + pub(crate) async fn verify_saved_possession( + &self, + peer_endpoint_id: &str, + challenge: &Challenge, + proof: &WireProof, + generation: u64, + protocol_version: u16, + ) -> Result<(), VnidropError> { + let row = self.find_row(peer_endpoint_id).await?.ok_or_else(|| { + VnidropError::permission(anyhow::anyhow!("no saved relationship with peer")) + })?; + if row.state != DeviceRelationshipState::Saved { + return Err(VnidropError::permission(anyhow::anyhow!( + "peer is not a saved device" + ))); + } + if row.generation != generation { + return Err(VnidropError::permission(anyhow::anyhow!( + "relationship generation mismatch" + ))); + } + // Established relationships record a protocol floor and reject silent + // downgrade attempts (design §7 / §15). + if protocol_version < row.minimum_protocol_version { + return Err(VnidropError::protocol_incompatible(anyhow::anyhow!( + "relationship protocol downgrade is forbidden" + ))); + } + self.verify_issued_possession( + peer_endpoint_id, + challenge, + proof, + generation, + protocol_version, + ) + .await + } + + pub(crate) async fn require_saved(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> { + let row = self.find_row(peer_endpoint_id).await?.ok_or_else(|| { + VnidropError::permission(anyhow::anyhow!("no saved relationship with peer")) + })?; + if row.state != DeviceRelationshipState::Saved { + return Err(VnidropError::permission(anyhow::anyhow!( + "peer is not a saved device" + ))); + } + Ok(()) + } + + #[cfg(test)] + pub(crate) async fn force_minimum_protocol_version_for_test( + &self, + peer_endpoint_id: &str, + minimum_protocol_version: u16, + ) -> Result<(), VnidropError> { + self.store + .set_minimum_protocol_version(peer_endpoint_id, minimum_protocol_version) + .await + } + + async fn prove_held_possession( + &self, + peer_endpoint_id: &str, + challenge: &Challenge, + generation: u64, + protocol_version: u16, + ) -> Result { + let custody = + self.custody + .as_ref() + .ok_or_else(|| VnidropError::SecureStorageUnavailable { + reason: "relationship grants require protected custody".to_string(), + })?; + let row = self.find_row(peer_endpoint_id).await?.ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!("missing relationship for proof")) + })?; + let handle = row.held_grant_handle.ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!("missing held grant for proof")) + })?; + let grant_id = GrantId::decode(row.held_grant_id.as_deref().ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!("missing held grant id")) + })?) + .map_err(VnidropError::invalid_input)?; + let material = custody.load(&SecretHandle::from_stored(handle)).await?; + let secret = secret_from_material(&material)?; + let proof = prove_relationship_grant( + grant_id, + &secret, + challenge, + peer_endpoint_id, + &self.local_endpoint_id, + generation, + protocol_version, + ); + Ok(WireProof { + grant_id: proof.grant_id.encode(), + mac: HEXLOWER.encode(proof.mac()), + challenge: challenge.encode(), + }) + } + + async fn verify_issued_possession( + &self, + peer_endpoint_id: &str, + challenge: &Challenge, + proof: &WireProof, + 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() + .ok_or_else(|| VnidropError::SecureStorageUnavailable { + reason: "relationship grants require protected custody".to_string(), + })?; + let row = self.find_row(peer_endpoint_id).await?.ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!("missing relationship for verify")) + })?; + let handle = row.issued_grant_handle.ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!("missing issued grant for verify")) + })?; + let expected_id = row.issued_grant_id.ok_or_else(|| { + VnidropError::invalid_input(anyhow::anyhow!("missing issued grant id")) + })?; + if proof.grant_id != expected_id { + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "relationship grant id mismatch" + ))); + } + let material = custody.load(&SecretHandle::from_stored(handle)).await?; + let secret = secret_from_material(&material)?; + let grant_id = GrantId::decode(&proof.grant_id).map_err(VnidropError::invalid_input)?; + let mac_bytes = HEXLOWER + .decode(proof.mac.as_bytes()) + .context("invalid proof mac") + .map_err(VnidropError::invalid_input)?; + let mac: [u8; 32] = mac_bytes.try_into().map_err(|_| { + VnidropError::invalid_input(anyhow::anyhow!("invalid proof mac length")) + })?; + let presented = GrantProof::from_parts(grant_id, mac); + verify_relationship_grant( + &secret, + &presented, + challenge, + &self.local_endpoint_id, + peer_endpoint_id, + generation, + protocol_version, + ) + .map_err(|error| VnidropError::invalid_input(anyhow::anyhow!(error)))?; + Ok(()) + } + + async fn set_acks( + &self, + peer_endpoint_id: &str, + local_ack: bool, + peer_ack: bool, + ) -> Result<(), VnidropError> { + self.store + .set_acks(peer_endpoint_id, local_ack, peer_ack) + .await + } + + async fn activate_saved(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> { + self.store + .set_state(peer_endpoint_id, DeviceRelationshipState::Saved) + .await?; + self.emit_changed(peer_endpoint_id, DeviceRelationshipState::Saved); + Ok(()) + } + + async fn expire_pending(&self) -> Result<(), VnidropError> { + let peers = self + .store + .list_expired_pending_peers(now_ms() - PENDING_TTL_MS) + .await?; + for peer in peers { + self.delete_relationship(&peer).await?; + } + Ok(()) + } + + pub(super) async fn delete_relationship( + &self, + peer_endpoint_id: &str, + ) -> Result<(), VnidropError> { + if let Ok(Some(row)) = self.find_row(peer_endpoint_id).await { + if let Some(custody) = &self.custody { + for handle in [row.issued_grant_handle, row.held_grant_handle] + .into_iter() + .flatten() + { + let _ = custody.remove(&SecretHandle::from_stored(handle)).await; + } + } + } + self.store.delete(peer_endpoint_id).await + } + + pub(super) async fn find_row( + &self, + peer_endpoint_id: &str, + ) -> Result, VnidropError> { + self.store.find_row(peer_endpoint_id).await + } + + pub(crate) async fn peer_addr( + &self, + peer_endpoint_id: &str, + ) -> Result { + let parsed: EndpointId = peer_endpoint_id + .parse() + .context("unusable peer endpoint id") + .map_err(VnidropError::invalid_input)?; + let raw = if let Some(info) = self.endpoint.remote_info(parsed).await { + let mut addr = EndpointAddr::from(parsed); + addr.addrs = info.addrs().map(|entry| entry.addr().clone()).collect(); + let _ = encode_persisted_sender_address(&addr); + addr + } else { + EndpointAddr::from(parsed) + }; + filter_peer_addr_for_relay_mode(&raw, self.relay_mode, &self.custom_relay_urls).map_err( + |error| { + VnidropError::relay_policy_incompatible(anyhow::anyhow!( + "peer address is unusable under the active network profile: {error}" + )) + }, + ) + } + + pub(super) fn emit_changed(&self, peer_endpoint_id: &str, state: DeviceRelationshipState) { + self.event_hub.emit_endpoint( + "pairing", + "relationship-changed", + json!({ + "peer_endpoint_id": peer_endpoint_id, + "state": state_as_str(state), + }), + ); + } + + /// 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, + ) { + let Ok(addr) = self.peer_addr(peer_endpoint_id).await else { + return; + }; + let client = RelationshipClient::connect(self.endpoint.clone(), addr); + let _ = tokio::time::timeout( + self.pairing_timeout, + client.revoke_notice(RevokeNotice { + generation, + issued_grant_id, + }), + ) + .await; + } +} diff --git a/crates/vnidrop/src/device_relationship/store.rs b/crates/vnidrop/src/device_relationship/store.rs new file mode 100644 index 0000000..bd2eccb --- /dev/null +++ b/crates/vnidrop/src/device_relationship/store.rs @@ -0,0 +1,574 @@ +//! Durable device-relationship rows (schema + queries). +//! +//! Orchestration (custody, pairing RPC, events) stays on +//! [`super::DeviceRelationshipService`]; this store is the domain adapter held +//! in [`crate::persistence::AppDataStores`]. + +use sqlx::{Row, SqlitePool}; + +use crate::{ + api::{DeviceRelationship, DeviceRelationshipState, SavedDevice}, + error::VnidropError, + 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, + pub(crate) held_grant_id: Option, + pub(crate) revoked_at: i64, +} + +#[derive(Debug, Clone)] +pub(super) struct RelationshipRow { + pub(super) state: DeviceRelationshipState, + pub(super) generation: u64, + pub(super) minimum_protocol_version: u16, + pub(super) session_id: Option, + pub(super) issued_grant_handle: Option, + pub(super) held_grant_handle: Option, + pub(super) issued_grant_id: Option, + pub(super) held_grant_id: Option, + pub(super) created_at: i64, +} + +/// Compact projection used by grant-secret reconcile. +#[derive(Debug, Clone)] +pub(super) struct ReconcileRow { + pub(super) remote_endpoint_id: String, + pub(super) state: DeviceRelationshipState, + pub(super) issued_grant_handle: Option, + pub(super) held_grant_handle: Option, +} + +pub(super) struct RelationshipUpsert<'a> { + pub(super) remote_endpoint_id: &'a str, + pub(super) state: DeviceRelationshipState, + pub(super) generation: u64, + pub(super) minimum_protocol_version: u16, + pub(super) session_id: Option<&'a str>, + pub(super) issued_grant_handle: Option<&'a str>, + pub(super) held_grant_handle: Option<&'a str>, + pub(super) issued_grant_id: Option<&'a str>, + pub(super) held_grant_id: Option<&'a str>, + pub(super) peer_ack: bool, + pub(super) local_ack: bool, + pub(super) created_at: i64, + pub(super) updated_at: i64, +} + +/// Domain store for `device_relationships` (+ generation tombstones). +#[derive(Clone)] +pub(crate) struct DeviceRelationshipStore { + pool: SqlitePool, +} + +impl DeviceRelationshipStore { + pub(crate) fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS device_relationships ( + remote_endpoint_id TEXT PRIMARY KEY, + state TEXT NOT NULL, + generation INTEGER NOT NULL, + minimum_protocol_version INTEGER NOT NULL, + session_id TEXT, + issued_grant_handle TEXT, + held_grant_handle TEXT, + issued_grant_id TEXT, + held_grant_id TEXT, + peer_ack INTEGER NOT NULL DEFAULT 0, + local_ack INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + "#, + ) + .execute(pool) + .await?; + let columns = sqlx::query("PRAGMA table_info(device_relationships)") + .fetch_all(pool) + .await?; + let has = |name: &str| columns.iter().any(|row| row.get::(1) == name); + if !has("issued_grant_id") { + sqlx::query("ALTER TABLE device_relationships ADD COLUMN issued_grant_id TEXT") + .execute(pool) + .await?; + } + if !has("held_grant_id") { + sqlx::query("ALTER TABLE device_relationships ADD COLUMN held_grant_id TEXT") + .execute(pool) + .await?; + } + if !has("local_label") { + sqlx::query("ALTER TABLE device_relationships ADD COLUMN local_label TEXT") + .execute(pool) + .await?; + } + 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(()) + } + + pub(super) async fn count_active_slots(&self) -> Result { + let row = sqlx::query( + r#" + SELECT COUNT(*) AS n FROM device_relationships + WHERE state IN ('saved', 'pending_outgoing', 'pending_incoming') + "#, + ) + .fetch_one(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(row.get::("n") as u64) + } + + pub(super) async fn list_reconcile_rows(&self) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT remote_endpoint_id, issued_grant_handle, held_grant_handle, state + FROM device_relationships + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + rows.into_iter() + .map(|row| { + Ok(ReconcileRow { + remote_endpoint_id: row.get("remote_endpoint_id"), + state: parse_state(&row.get::("state"))?, + issued_grant_handle: row.get("issued_grant_handle"), + held_grant_handle: row.get("held_grant_handle"), + }) + }) + .collect() + } + + pub(super) async fn list(&self) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT remote_endpoint_id, state, generation, minimum_protocol_version, created_at, updated_at + FROM device_relationships + ORDER BY updated_at DESC + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + rows.into_iter().map(row_to_relationship).collect() + } + + pub(super) async fn list_saved_devices(&self) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT remote_endpoint_id, local_label, created_at, updated_at + FROM device_relationships + WHERE state = 'saved' + ORDER BY updated_at DESC + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows + .into_iter() + .map(|row| SavedDevice { + endpoint_id: row.get("remote_endpoint_id"), + local_label: row.get("local_label"), + remote_display_name: None, + created_at: row.get("created_at"), + last_authenticated_at: Some(row.get("updated_at")), + }) + .collect()) + } + + pub(super) async fn set_saved_device_label( + &self, + peer_endpoint_id: &str, + label: Option, + ) -> Result { + let result = sqlx::query( + r#" + UPDATE device_relationships + SET local_label = ?2, updated_at = ?3 + WHERE remote_endpoint_id = ?1 AND state = 'saved' + "#, + ) + .bind(peer_endpoint_id) + .bind(label) + .bind(now_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(result.rows_affected() > 0) + } + + pub(super) async fn set_issued_grant( + &self, + peer_endpoint_id: &str, + handle: &str, + grant_id: &str, + ) -> Result<(), VnidropError> { + sqlx::query( + r#" + UPDATE device_relationships + SET issued_grant_handle = ?2, issued_grant_id = ?3, updated_at = ?4 + WHERE remote_endpoint_id = ?1 + "#, + ) + .bind(peer_endpoint_id) + .bind(handle) + .bind(grant_id) + .bind(now_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(super) async fn set_held_grant( + &self, + peer_endpoint_id: &str, + handle: &str, + grant_id: &str, + ) -> Result<(), VnidropError> { + sqlx::query( + r#" + UPDATE device_relationships + SET held_grant_handle = ?2, held_grant_id = ?3, updated_at = ?4 + WHERE remote_endpoint_id = ?1 + "#, + ) + .bind(peer_endpoint_id) + .bind(handle) + .bind(grant_id) + .bind(now_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + #[cfg(test)] + pub(super) async fn set_minimum_protocol_version( + &self, + peer_endpoint_id: &str, + minimum_protocol_version: u16, + ) -> Result<(), VnidropError> { + sqlx::query( + "UPDATE device_relationships SET minimum_protocol_version = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1", + ) + .bind(peer_endpoint_id) + .bind(i64::from(minimum_protocol_version)) + .bind(now_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(super) async fn set_acks( + &self, + peer_endpoint_id: &str, + local_ack: bool, + peer_ack: bool, + ) -> Result<(), VnidropError> { + sqlx::query( + "UPDATE device_relationships SET local_ack = ?2, peer_ack = ?3, updated_at = ?4 WHERE remote_endpoint_id = ?1", + ) + .bind(peer_endpoint_id) + .bind(i64::from(local_ack)) + .bind(i64::from(peer_ack)) + .bind(now_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(super) async fn set_state( + &self, + peer_endpoint_id: &str, + state: DeviceRelationshipState, + ) -> Result<(), VnidropError> { + sqlx::query( + "UPDATE device_relationships SET state = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1", + ) + .bind(peer_endpoint_id) + .bind(state_as_str(state)) + .bind(now_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(super) async fn list_expired_pending_peers( + &self, + cutoff_ms: i64, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT remote_endpoint_id FROM device_relationships + WHERE state IN ('pending_outgoing', 'pending_incoming') AND updated_at < ?1 + "#, + ) + .bind(cutoff_ms) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows.into_iter().map(|row| row.get(0)).collect()) + } + + pub(super) async fn delete(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> { + sqlx::query("DELETE FROM device_relationships WHERE remote_endpoint_id = ?1") + .bind(peer_endpoint_id) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(super) async fn find_row( + &self, + peer_endpoint_id: &str, + ) -> Result, VnidropError> { + let row = sqlx::query( + r#" + SELECT remote_endpoint_id, state, generation, minimum_protocol_version, session_id, + issued_grant_handle, held_grant_handle, issued_grant_id, held_grant_id, + peer_ack, local_ack, created_at, updated_at + FROM device_relationships WHERE remote_endpoint_id = ?1 + "#, + ) + .bind(peer_endpoint_id) + .fetch_optional(&self.pool) + .await + .map_err(VnidropError::repository)?; + row.map(relationship_row_from_sql).transpose() + } + + pub(super) async fn upsert(&self, entry: RelationshipUpsert<'_>) -> Result<(), VnidropError> { + sqlx::query( + r#" + INSERT INTO device_relationships ( + remote_endpoint_id, state, generation, minimum_protocol_version, session_id, + issued_grant_handle, held_grant_handle, issued_grant_id, held_grant_id, + peer_ack, local_ack, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ON CONFLICT(remote_endpoint_id) DO UPDATE SET + state = excluded.state, + generation = excluded.generation, + minimum_protocol_version = excluded.minimum_protocol_version, + session_id = excluded.session_id, + issued_grant_handle = COALESCE(excluded.issued_grant_handle, device_relationships.issued_grant_handle), + held_grant_handle = COALESCE(excluded.held_grant_handle, device_relationships.held_grant_handle), + issued_grant_id = COALESCE(excluded.issued_grant_id, device_relationships.issued_grant_id), + held_grant_id = COALESCE(excluded.held_grant_id, device_relationships.held_grant_id), + peer_ack = excluded.peer_ack, + local_ack = excluded.local_ack, + updated_at = excluded.updated_at + "#, + ) + .bind(entry.remote_endpoint_id) + .bind(state_as_str(entry.state)) + .bind(entry.generation as i64) + .bind(i64::from(entry.minimum_protocol_version)) + .bind(entry.session_id) + .bind(entry.issued_grant_handle) + .bind(entry.held_grant_handle) + .bind(entry.issued_grant_id) + .bind(entry.held_grant_id) + .bind(i64::from(entry.peer_ack)) + .bind(i64::from(entry.local_ack)) + .bind(entry.created_at) + .bind(entry.updated_at) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + /// Bump generation and clear grant columns after a prior generation was tombstoned. + pub(super) async fn begin_grant_rotation( + &self, + peer_endpoint_id: &str, + new_generation: u64, + ) -> Result<(), VnidropError> { + 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_ms()) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(super) async fn insert_tombstone( + &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(()) + } + + pub(super) async fn find_tombstone( + &self, + peer_endpoint_id: &str, + generation: u64, + ) -> Result, 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::("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"), + })) + } + + #[cfg(test)] + pub(crate) async fn list_tombstones( + &self, + peer_endpoint_id: &str, + ) -> Result, 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::("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()) + } +} + +pub(super) fn state_as_str(state: DeviceRelationshipState) -> &'static str { + match state { + DeviceRelationshipState::PendingOutgoing => "pending_outgoing", + DeviceRelationshipState::PendingIncoming => "pending_incoming", + DeviceRelationshipState::Saved => "saved", + DeviceRelationshipState::Revoked => "revoked", + DeviceRelationshipState::Blocked => "blocked", + } +} + +fn parse_state(value: &str) -> Result { + match value { + "pending_outgoing" => Ok(DeviceRelationshipState::PendingOutgoing), + "pending_incoming" => Ok(DeviceRelationshipState::PendingIncoming), + "saved" => Ok(DeviceRelationshipState::Saved), + "revoked" => Ok(DeviceRelationshipState::Revoked), + "blocked" => Ok(DeviceRelationshipState::Blocked), + _ => Err(VnidropError::Internal { + reason: "unknown device relationship state".to_string(), + }), + } +} + +fn row_to_relationship(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(DeviceRelationship { + remote_endpoint_id: row.get("remote_endpoint_id"), + state: parse_state(&row.get::("state"))?, + generation: row.get::("generation") as u64, + minimum_protocol_version: row.get::("minimum_protocol_version") as u16, + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) +} + +fn relationship_row_from_sql( + row: sqlx::sqlite::SqliteRow, +) -> Result { + Ok(RelationshipRow { + state: parse_state(&row.get::("state"))?, + generation: row.get::("generation") as u64, + minimum_protocol_version: row.get::("minimum_protocol_version") as u16, + session_id: row.get("session_id"), + issued_grant_handle: row.get("issued_grant_handle"), + held_grant_handle: row.get("held_grant_handle"), + issued_grant_id: row.get("issued_grant_id"), + held_grant_id: row.get("held_grant_id"), + created_at: row.get("created_at"), + }) +} diff --git a/crates/vnidrop/src/event_hub.rs b/crates/vnidrop/src/event_hub.rs index 2297b43..e1f5af4 100644 --- a/crates/vnidrop/src/event_hub.rs +++ b/crates/vnidrop/src/event_hub.rs @@ -9,7 +9,7 @@ use tokio::{ use crate::{ api::{CoreEvent, CoreEventSink}, control_plane::redact_json, - repository::Repository, + invitation::Repository, transfer_state::TransferDirection, util::now_ms, }; diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/invitation/mod.rs similarity index 85% rename from crates/vnidrop/src/repository.rs rename to crates/vnidrop/src/invitation/mod.rs index d8e1a35..ff0e2f4 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/invitation/mod.rs @@ -1,3 +1,9 @@ +//! Invitation-transfer domain store (history, approvals, delivery receipts). +//! +//! This is the invitation half of [`crate::persistence::AppDataStores`]. It owns +//! only invitation-transfer tables — not relationships, eligibility, blocks, or +//! secret metadata (those have their own domain stores). + #[cfg(test)] use std::path::Path; @@ -13,19 +19,16 @@ use uuid::Uuid; use crate::{ access_policy::mode_from_storage, - api::{ - CoreEvent, PairingEligibilitySummary, ReceivedArtifact, ReceivedLocatorKind, - ReceiverRequest, StoredTransfer, - }, - blocked_devices::BlockStore, - error::VnidropError, - pairing_eligibility::{PairingEligibilityInsert, PairingEligibilityRecord}, + api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, util::now_ms, }; const SCHEMA_VERSION: i64 = 13; +/// Invitation-transfer durable store (history, receiver requests, receipts, events). +/// +/// Type name kept for call-site stability; module path is [`crate::invitation`]. #[derive(Debug, Clone)] pub(crate) struct Repository { pool: SqlitePool, @@ -120,8 +123,9 @@ impl Repository { } pub(crate) async fn ensure_schema(&self) -> Result<()> { - // The app owns this SQLite file. Keep migrations explicit so future - // desktop/mobile releases can move user history forward in place. + // Invitation-transfer tables only. Other domains apply schema from + // [`crate::persistence::open_all`]. Keep migrations explicit so releases + // can move invitation history forward in place. sqlx::query( r#" CREATE TABLE IF NOT EXISTS transfers ( @@ -330,196 +334,12 @@ impl Repository { .await?; } - crate::blocked_devices::ensure_schema(&self.pool).await?; - crate::secure_secret::ensure_schema(&self.pool).await?; - crate::device_relationship::DeviceRelationshipService::ensure_schema(&self.pool).await?; - crate::targeted_transfer::ensure_schema(&self.pool).await?; - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS pairing_eligibilities ( - session_id TEXT PRIMARY KEY, - peer_endpoint_id TEXT NOT NULL, - protocol_version INTEGER NOT NULL, - secret_handle TEXT NOT NULL UNIQUE, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL - ); - "#, - ) - .execute(&self.pool) - .await?; - sqlx::query( - r#" - CREATE INDEX IF NOT EXISTS pairing_eligibilities_peer - ON pairing_eligibilities(peer_endpoint_id); - "#, - ) - .execute(&self.pool) - .await?; - sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}")) .execute(&self.pool) .await?; Ok(()) } - /// Identity-wide deny list for saved-device and invitation traffic. - pub(crate) fn blocked_devices(&self) -> BlockStore { - BlockStore::new(self.pool.clone()) - } - - #[allow( - dead_code, - reason = "the private custody seam is activated by platform credential adapters" - )] - pub(crate) fn protected_secrets(&self) -> crate::secure_secret::SecretMetadataStore { - crate::secure_secret::SecretMetadataStore::new(self.pool.clone()) - } - - pub(crate) async fn insert_pairing_eligibility( - &self, - entry: PairingEligibilityInsert<'_>, - ) -> Result<(), VnidropError> { - sqlx::query( - r#" - INSERT INTO pairing_eligibilities ( - session_id, peer_endpoint_id, protocol_version, secret_handle, created_at, expires_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) - "#, - ) - .bind(entry.session_id) - .bind(entry.peer_endpoint_id) - .bind(i64::from(entry.protocol_version)) - .bind(entry.secret_handle) - .bind(entry.created_at) - .bind(entry.expires_at) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - - pub(crate) async fn list_pairing_eligibilities( - &self, - ) -> Result, VnidropError> { - let rows = sqlx::query( - r#" - SELECT peer_endpoint_id, session_id, protocol_version, created_at, expires_at - FROM pairing_eligibilities - ORDER BY created_at DESC - "#, - ) - .fetch_all(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(rows - .into_iter() - .map(|row| PairingEligibilitySummary { - peer_endpoint_id: row.get("peer_endpoint_id"), - session_id: row.get("session_id"), - protocol_version: row.get::("protocol_version") as u16, - created_at: row.get("created_at"), - expires_at: row.get("expires_at"), - }) - .collect()) - } - - pub(crate) async fn list_pairing_eligibility_records( - &self, - ) -> Result, VnidropError> { - let rows = sqlx::query( - r#" - SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at - FROM pairing_eligibilities - "#, - ) - .fetch_all(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(rows.into_iter().map(row_to_pairing_eligibility).collect()) - } - - pub(crate) async fn list_pairing_eligibilities_for_peer( - &self, - peer_endpoint_id: &str, - ) -> Result, VnidropError> { - let rows = sqlx::query( - r#" - SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at - FROM pairing_eligibilities - WHERE peer_endpoint_id = ?1 - "#, - ) - .bind(peer_endpoint_id) - .fetch_all(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(rows.into_iter().map(row_to_pairing_eligibility).collect()) - } - - pub(crate) async fn list_expired_pairing_eligibilities( - &self, - now_ms: i64, - ) -> Result, VnidropError> { - let rows = sqlx::query( - r#" - SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at - FROM pairing_eligibilities - WHERE expires_at <= ?1 - "#, - ) - .bind(now_ms) - .fetch_all(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(rows.into_iter().map(row_to_pairing_eligibility).collect()) - } - - pub(crate) async fn find_pairing_eligibility_by_session( - &self, - session_id: &str, - ) -> Result, VnidropError> { - let row = sqlx::query( - r#" - SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at - FROM pairing_eligibilities - WHERE session_id = ?1 - "#, - ) - .bind(session_id) - .fetch_optional(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(row.map(row_to_pairing_eligibility)) - } - - pub(crate) async fn delete_pairing_eligibility( - &self, - session_id: &str, - ) -> Result<(), VnidropError> { - sqlx::query("DELETE FROM pairing_eligibilities WHERE session_id = ?1") - .bind(session_id) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - - #[cfg(test)] - pub(crate) async fn force_pairing_eligibility_expiry_for_test( - &self, - session_id: &str, - expires_at: i64, - ) -> Result<(), VnidropError> { - sqlx::query("UPDATE pairing_eligibilities SET expires_at = ?2 WHERE session_id = ?1") - .bind(session_id) - .bind(expires_at) - .execute(&self.pool) - .await - .map_err(VnidropError::repository)?; - Ok(()) - } - #[cfg(test)] pub(crate) async fn schema_version(&self) -> Result { let row = sqlx::query("PRAGMA user_version") @@ -1450,14 +1270,3 @@ fn row_to_receiver_request(row: sqlx::sqlite::SqliteRow) -> ReceiverRequest { completed_at: row.get("completed_at"), } } - -fn row_to_pairing_eligibility(row: sqlx::sqlite::SqliteRow) -> PairingEligibilityRecord { - PairingEligibilityRecord { - peer_endpoint_id: row.get("peer_endpoint_id"), - session_id: row.get("session_id"), - protocol_version: row.get::("protocol_version") as u16, - secret_handle: row.get("secret_handle"), - created_at: row.get("created_at"), - expires_at: row.get("expires_at"), - } -} diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index cc75cba..e396f59 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -9,10 +9,10 @@ mod event_hub; mod filesystem; mod grant; mod handshake; +mod invitation; mod logging; mod pairing_eligibility; mod persistence; -mod repository; mod runtime; mod secret; #[allow( diff --git a/crates/vnidrop/src/pairing_eligibility.rs b/crates/vnidrop/src/pairing_eligibility/mod.rs similarity index 89% rename from crates/vnidrop/src/pairing_eligibility.rs rename to crates/vnidrop/src/pairing_eligibility/mod.rs index c24a892..5c7f2f4 100644 --- a/crates/vnidrop/src/pairing_eligibility.rs +++ b/crates/vnidrop/src/pairing_eligibility/mod.rs @@ -9,11 +9,14 @@ use std::sync::Arc; use serde_json::json; +mod store; + +pub(crate) use store::PairingEligibilityStore; + use crate::{ api::{experimental_saved_device_capabilities, PairingEligibilitySummary}, error::VnidropError, event_hub::EventHub, - repository::Repository, secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial}, util::now_ms, }; @@ -23,7 +26,7 @@ const CAPABILITY_CONTEXT: &str = "vnidrop-pairing-eligibility-v1"; #[derive(Clone)] pub(crate) struct PairingEligibilityService { - repository: Repository, + store: PairingEligibilityStore, custody: Option>, event_hub: Arc, local_endpoint_id: String, @@ -31,13 +34,13 @@ pub(crate) struct PairingEligibilityService { impl PairingEligibilityService { pub(crate) fn new( - repository: Repository, + store: PairingEligibilityStore, custody: Option>, event_hub: Arc, local_endpoint_id: String, ) -> Self { Self { - repository, + store, custody, event_hub, local_endpoint_id, @@ -46,7 +49,7 @@ impl PairingEligibilityService { /// Removes orphaned eligibility secrets and rows whose secrets are missing. pub(crate) async fn reconcile(&self) -> Result<(), VnidropError> { - let records = self.repository.list_pairing_eligibility_records().await?; + let records = self.store.list_records().await?; let mut referenced = HashSet::new(); for entry in records { referenced.insert(entry.secret_handle.clone()); @@ -73,7 +76,7 @@ impl PairingEligibilityService { pub(crate) async fn list(&self) -> Result, VnidropError> { self.expire_due(true).await?; - self.repository.list_pairing_eligibilities().await + self.store.list_summaries().await } /// Activates eligibility after a durable completed authenticated transfer. @@ -89,12 +92,7 @@ impl PairingEligibilityService { if peer_endpoint_id.is_empty() || session_id.is_empty() || approval_token.is_empty() { return Ok(()); } - if self - .repository - .find_pairing_eligibility_by_session(session_id) - .await? - .is_some() - { + if self.store.find_by_session(session_id).await?.is_some() { return Ok(()); } @@ -116,8 +114,8 @@ impl PairingEligibilityService { let created_at = now_ms(); let expires_at = created_at + ELIGIBILITY_TTL_MS; if let Err(error) = self - .repository - .insert_pairing_eligibility(PairingEligibilityInsert { + .store + .insert(PairingEligibilityInsert { peer_endpoint_id, session_id, protocol_version, @@ -165,10 +163,7 @@ impl PairingEligibilityService { peer_endpoint_id: &str, ) -> Result, VnidropError> { self.expire_due(true).await?; - let entries = self - .repository - .list_pairing_eligibilities_for_peer(peer_endpoint_id) - .await?; + let entries = self.store.list_for_peer(peer_endpoint_id).await?; let Some(entry) = entries.into_iter().next() else { return Ok(None); }; @@ -225,11 +220,7 @@ impl PairingEligibilityService { peer_endpoint_id: &str, session_id: &str, ) -> Result<(), VnidropError> { - if let Some(entry) = self - .repository - .find_pairing_eligibility_by_session(session_id) - .await? - { + if let Some(entry) = self.store.find_by_session(session_id).await? { if entry.peer_endpoint_id == peer_endpoint_id { self.delete_entry(&entry).await?; } @@ -242,10 +233,7 @@ impl PairingEligibilityService { } pub(crate) async fn remove_for_peer(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> { - let entries = self - .repository - .list_pairing_eligibilities_for_peer(peer_endpoint_id) - .await?; + let entries = self.store.list_for_peer(peer_endpoint_id).await?; for entry in entries { self.delete_entry(&entry).await?; } @@ -268,11 +256,7 @@ impl PairingEligibilityService { let Some(custody) = &self.custody else { return Ok(None); }; - let Some(entry) = self - .repository - .find_pairing_eligibility_by_session(session_id) - .await? - else { + let Some(entry) = self.store.find_by_session(session_id).await? else { return Ok(None); }; if entry.peer_endpoint_id != peer_endpoint_id || entry.expires_at <= now_ms() { @@ -294,10 +278,7 @@ impl PairingEligibilityService { async fn expire_due(&self, emit_events: bool) -> Result<(), VnidropError> { let now = now_ms(); - let expired = self - .repository - .list_expired_pairing_eligibilities(now) - .await?; + let expired = self.store.list_expired(now).await?; for entry in expired { if emit_events { self.delete_entry(&entry).await?; @@ -321,6 +302,17 @@ impl PairingEligibilityService { Ok(()) } + #[cfg(test)] + pub(crate) async fn force_expiry_for_test( + &self, + session_id: &str, + expires_at: i64, + ) -> Result<(), VnidropError> { + self.store + .force_expiry_for_test(session_id, expires_at) + .await + } + async fn delete_entry_silent( &self, entry: &PairingEligibilityRecord, @@ -329,9 +321,7 @@ impl PairingEligibilityService { let handle = SecretHandle::from_stored(entry.secret_handle.clone()); let _ = custody.remove(&handle).await; } - self.repository - .delete_pairing_eligibility(&entry.session_id) - .await + self.store.delete(&entry.session_id).await } } diff --git a/crates/vnidrop/src/pairing_eligibility/store.rs b/crates/vnidrop/src/pairing_eligibility/store.rs new file mode 100644 index 0000000..2bcac15 --- /dev/null +++ b/crates/vnidrop/src/pairing_eligibility/store.rs @@ -0,0 +1,195 @@ +//! Durable pairing-eligibility rows (schema + queries). + +use sqlx::{Row, SqlitePool}; + +use crate::{api::PairingEligibilitySummary, error::VnidropError}; + +use super::{PairingEligibilityInsert, PairingEligibilityRecord}; + +/// Domain store for `pairing_eligibilities`. +#[derive(Clone)] +pub(crate) struct PairingEligibilityStore { + pool: SqlitePool, +} + +impl PairingEligibilityStore { + pub(crate) fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS pairing_eligibilities ( + session_id TEXT PRIMARY KEY, + peer_endpoint_id TEXT NOT NULL, + protocol_version INTEGER NOT NULL, + secret_handle TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + "#, + ) + .execute(pool) + .await?; + sqlx::query( + r#" + CREATE INDEX IF NOT EXISTS pairing_eligibilities_peer + ON pairing_eligibilities(peer_endpoint_id); + "#, + ) + .execute(pool) + .await?; + Ok(()) + } + + pub(crate) async fn insert( + &self, + entry: PairingEligibilityInsert<'_>, + ) -> Result<(), VnidropError> { + sqlx::query( + r#" + INSERT INTO pairing_eligibilities ( + session_id, peer_endpoint_id, protocol_version, secret_handle, created_at, expires_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "#, + ) + .bind(entry.session_id) + .bind(entry.peer_endpoint_id) + .bind(i64::from(entry.protocol_version)) + .bind(entry.secret_handle) + .bind(entry.created_at) + .bind(entry.expires_at) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + pub(crate) async fn list_summaries( + &self, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT peer_endpoint_id, session_id, protocol_version, created_at, expires_at + FROM pairing_eligibilities + ORDER BY created_at DESC + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows + .into_iter() + .map(|row| PairingEligibilitySummary { + peer_endpoint_id: row.get("peer_endpoint_id"), + session_id: row.get("session_id"), + protocol_version: row.get::("protocol_version") as u16, + created_at: row.get("created_at"), + expires_at: row.get("expires_at"), + }) + .collect()) + } + + pub(crate) async fn list_records(&self) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at + FROM pairing_eligibilities + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows.into_iter().map(row_to_record).collect()) + } + + pub(crate) async fn list_for_peer( + &self, + peer_endpoint_id: &str, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at + FROM pairing_eligibilities + WHERE peer_endpoint_id = ?1 + "#, + ) + .bind(peer_endpoint_id) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows.into_iter().map(row_to_record).collect()) + } + + pub(crate) async fn list_expired( + &self, + now_ms: i64, + ) -> Result, VnidropError> { + let rows = sqlx::query( + r#" + SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at + FROM pairing_eligibilities + WHERE expires_at <= ?1 + "#, + ) + .bind(now_ms) + .fetch_all(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(rows.into_iter().map(row_to_record).collect()) + } + + pub(crate) async fn find_by_session( + &self, + session_id: &str, + ) -> Result, VnidropError> { + let row = sqlx::query( + r#" + SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at + FROM pairing_eligibilities + WHERE session_id = ?1 + "#, + ) + .bind(session_id) + .fetch_optional(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(row.map(row_to_record)) + } + + pub(crate) async fn delete(&self, session_id: &str) -> Result<(), VnidropError> { + sqlx::query("DELETE FROM pairing_eligibilities WHERE session_id = ?1") + .bind(session_id) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } + + #[cfg(test)] + pub(crate) async fn force_expiry_for_test( + &self, + session_id: &str, + expires_at: i64, + ) -> Result<(), VnidropError> { + sqlx::query("UPDATE pairing_eligibilities SET expires_at = ?2 WHERE session_id = ?1") + .bind(session_id) + .bind(expires_at) + .execute(&self.pool) + .await + .map_err(VnidropError::repository)?; + Ok(()) + } +} + +fn row_to_record(row: sqlx::sqlite::SqliteRow) -> PairingEligibilityRecord { + PairingEligibilityRecord { + peer_endpoint_id: row.get("peer_endpoint_id"), + session_id: row.get("session_id"), + protocol_version: row.get::("protocol_version") as u16, + secret_handle: row.get("secret_handle"), + created_at: row.get("created_at"), + expires_at: row.get("expires_at"), + } +} diff --git a/crates/vnidrop/src/persistence.rs b/crates/vnidrop/src/persistence.rs index 9c87ed2..7268e8b 100644 --- a/crates/vnidrop/src/persistence.rs +++ b/crates/vnidrop/src/persistence.rs @@ -1,18 +1,20 @@ //! Persistence open: one SQLite pool, every domain schema, [`AppDataStores`]. //! -//! Runtime talks to domain stores — not a raw pool. Unmigrated modules may still -//! take [`AppDataStores::pool_for_unmigrated`] until their own stores deepen. +//! Runtime talks to domain stores — not a raw pool. Schema application for each +//! domain is owned here (not orchestrated from the invitation store). use std::{path::Path, str::FromStr}; use anyhow::{Context, Result}; -use sqlx::{ - sqlite::{SqliteConnectOptions, SqlitePoolOptions}, - SqlitePool, -}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use crate::{ - blocked_devices::BlockStore, repository::Repository, targeted_transfer::TargetedTransferStore, + blocked_devices::{self, BlockStore}, + device_relationship::DeviceRelationshipStore, + invitation::Repository, + pairing_eligibility::PairingEligibilityStore, + secure_secret::{self, SecretMetadataStore}, + targeted_transfer::{self, TargetedTransferStore}, }; /// Concrete domain stores for one app-data profile. @@ -22,17 +24,14 @@ pub(crate) struct AppDataStores { pub(crate) invitation: Repository, /// Targeted-transfer durable rows. pub(crate) targeted: TargetedTransferStore, + /// Mutual-consent device relationships (+ generation tombstones). + pub(crate) relationships: DeviceRelationshipStore, + /// Post-transfer pairing eligibility rows. + pub(crate) eligibility: PairingEligibilityStore, + /// Non-secret metadata for protected credential handles. + pub(crate) secrets: SecretMetadataStore, /// Identity-wide deny list. pub(crate) blocked: BlockStore, - pool: SqlitePool, -} - -impl AppDataStores { - /// Temporary: remaining domain modules still construct on a shared pool. - /// Do not add new callers — migrate them to domain stores instead. - pub(crate) fn pool_for_unmigrated(&self) -> SqlitePool { - self.pool.clone() - } } /// Create the profile pool, apply all domain schemas, return [`AppDataStores`]. @@ -47,15 +46,27 @@ pub(crate) async fn open_all(app_data_dir: &Path) -> Result { .await .context("failed to open app data sqlite")?; - // Invitation ensure_schema still orchestrates cross-domain schemas until - // each domain store owns its ensure_schema call from this path alone. + // Unreleased device-history prototype tables — no migration path. + for table in ["held_offers", "grants_held", "grants_issued", "contacts"] { + sqlx::query(&format!("DROP TABLE IF EXISTS {table}")) + .execute(&pool) + .await?; + } + let invitation = Repository::from_pool(pool.clone()); invitation.ensure_schema().await?; + blocked_devices::ensure_schema(&pool).await?; + secure_secret::ensure_schema(&pool).await?; + DeviceRelationshipStore::ensure_schema(&pool).await?; + targeted_transfer::ensure_schema(&pool).await?; + PairingEligibilityStore::ensure_schema(&pool).await?; Ok(AppDataStores { targeted: TargetedTransferStore::new(pool.clone()), - blocked: BlockStore::new(pool.clone()), + relationships: DeviceRelationshipStore::new(pool.clone()), + eligibility: PairingEligibilityStore::new(pool.clone()), + secrets: SecretMetadataStore::new(pool.clone()), + blocked: BlockStore::new(pool), invitation, - pool, }) } diff --git a/crates/vnidrop/src/runtime/delivery.rs b/crates/vnidrop/src/runtime/delivery.rs index 8938e2c..599b712 100644 --- a/crates/vnidrop/src/runtime/delivery.rs +++ b/crates/vnidrop/src/runtime/delivery.rs @@ -7,7 +7,7 @@ use crate::{ handshake::{ DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService, }, - repository::PendingDeliveryReceipt, + invitation::PendingDeliveryReceipt, ticket::parse_persisted_sender_address, }; diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index 579b9ef..7df21db 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -130,8 +130,8 @@ impl VnidropCore { ) -> Result<(), VnidropError> { self.block_on( self.inner - .repository - .force_pairing_eligibility_expiry_for_test(&session_id, expires_at), + .pairing_eligibility + .force_expiry_for_test(&session_id, expires_at), ) } diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index f4f09da..b940b6a 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -59,9 +59,9 @@ use crate::{ device_relationship::{DeviceRelationshipService, RelationshipProtocol}, event_hub::EventHub, handshake::HandshakeService, + invitation::Repository, logging::init_logging, pairing_eligibility::PairingEligibilityService, - repository::Repository, secret::load_or_create_secret, secure_secret::{start_endpoint_identity, ProfileLock, SecureSecretStore}, targeted_transfer::{TargetedOfferInbox, TargetedTransferProtocol}, @@ -163,7 +163,7 @@ impl CoreInner { profile_lock, } => { let (secret_key, custody) = start_endpoint_identity( - repository.protected_secrets(), + stores.secrets.clone(), store, &app_data_dir.join("iroh.secret"), ) @@ -358,13 +358,14 @@ impl CoreInner { } } let pairing_eligibility = PairingEligibilityService::new( - repository.clone(), + stores.eligibility.clone(), secret_custody.clone(), event_hub.clone(), endpoint.id().to_string(), ); let approval = ApprovalService::new( repository.clone(), + blocked_devices.clone(), event_hub.clone(), access_policy.clone(), limits.max_pending_approvals as usize, @@ -383,7 +384,8 @@ impl CoreInner { limits.offer_timeout_ms, ); let device_relationships = Arc::new(DeviceRelationshipService::new( - stores.pool_for_unmigrated(), + stores.relationships.clone(), + stores.blocked.clone(), secret_custody.clone(), pairing_eligibility.clone(), event_hub.clone(), diff --git a/crates/vnidrop/src/runtime/receive.rs b/crates/vnidrop/src/runtime/receive.rs index f6dc73e..bf986de 100644 --- a/crates/vnidrop/src/runtime/receive.rs +++ b/crates/vnidrop/src/runtime/receive.rs @@ -27,7 +27,7 @@ use crate::{ AtomicOutputFile, }, handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService}, - repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert}, + invitation::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert}, ticket::{ encode_persisted_sender_address, parse_transfer_ticket_with_limits, ParsedTransferTicket, }, diff --git a/crates/vnidrop/src/runtime/share.rs b/crates/vnidrop/src/runtime/share.rs index 849bba3..91181cb 100644 --- a/crates/vnidrop/src/runtime/share.rs +++ b/crates/vnidrop/src/runtime/share.rs @@ -22,7 +22,7 @@ use crate::{ collect_import_files_with_limits, default_collection_name, read_stream_from_blocking_reader, TransferImport, }, - repository::TransferUpsert, + invitation::TransferUpsert, ticket::VnidropTicket, transfer_state::{TransferDirection, TransferStatus}, util::non_empty, diff --git a/crates/vnidrop/src/tests/blocked_devices.rs b/crates/vnidrop/src/tests/blocked_devices.rs index edd116e..b37da0b 100644 --- a/crates/vnidrop/src/tests/blocked_devices.rs +++ b/crates/vnidrop/src/tests/blocked_devices.rs @@ -1,4 +1,4 @@ -use crate::{blocked_devices::BlockStore, persistence, repository::Repository}; +use crate::{blocked_devices::BlockStore, invitation::Repository, persistence}; async fn store(temp: &tempfile::TempDir) -> BlockStore { persistence::open_all(temp.path()).await.unwrap().blocked @@ -45,7 +45,12 @@ async fn opening_app_data_drops_unreleased_prototype_tables() { } let stores = persistence::open_all(temp.path()).await.unwrap(); - let pool = stores.pool_for_unmigrated(); + let pool = { + let options = sqlx::sqlite::SqliteConnectOptions::new() + .filename(temp.path().join("vnidrop.sqlite3")) + .create_if_missing(false); + sqlx::SqlitePool::connect_with(options).await.unwrap() + }; for table in ["contacts", "grants_issued", "grants_held", "held_offers"] { let row = sqlx::query(&format!( "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = '{table}'" diff --git a/crates/vnidrop/src/tests/control_plane.rs b/crates/vnidrop/src/tests/control_plane.rs index e51ea4d..5be6a33 100644 --- a/crates/vnidrop/src/tests/control_plane.rs +++ b/crates/vnidrop/src/tests/control_plane.rs @@ -6,7 +6,7 @@ use crate::{ api::{CoreEvent, CoreEventSink, CoreLimits, PendingTargetedOffer}, control_plane::IdentityCooldown, event_hub::EventHub, - repository::Repository, + invitation::Repository, secure_secret::FaultInjectingSecretStore, targeted_transfer::inbox::{TargetedOfferDecision, TargetedOfferInbox}, CoreNetworkConfig, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind, diff --git a/crates/vnidrop/src/tests/persistence.rs b/crates/vnidrop/src/tests/persistence.rs index 19b73ce..cb63c95 100644 --- a/crates/vnidrop/src/tests/persistence.rs +++ b/crates/vnidrop/src/tests/persistence.rs @@ -1,13 +1,52 @@ //! Persistence open returns domain stores without exporting a raw pool to callers. +use sqlx::Row; + use crate::persistence; +async fn open_profile_pool(app_data_dir: &std::path::Path) -> sqlx::SqlitePool { + let db = app_data_dir.join("vnidrop.sqlite3"); + let options = sqlx::sqlite::SqliteConnectOptions::new() + .filename(&db) + .create_if_missing(false); + sqlx::SqlitePool::connect_with(options).await.unwrap() +} + #[tokio::test] -async fn open_all_returns_invitation_targeted_and_blocked_stores() { +async fn open_all_returns_all_domain_stores_and_schemas() { let temp = tempfile::tempdir().unwrap(); let stores = persistence::open_all(temp.path()).await.unwrap(); assert!(stores.blocked.list_blocked().await.unwrap().is_empty()); assert!(stores.targeted.list().await.unwrap().is_empty()); assert!(stores.invitation.list_transfers().await.unwrap().is_empty()); + assert!(stores + .eligibility + .list_summaries() + .await + .unwrap() + .is_empty()); + + let pool = open_profile_pool(temp.path()).await; + for table in [ + "device_relationships", + "relationship_generation_tombstones", + "pairing_eligibilities", + "protected_secret_refs", + "blocked_endpoints", + "targeted_transfers", + "transfers", + ] { + let row = sqlx::query(&format!( + "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = '{table}'" + )) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + row.get::("n"), + 1, + "{table} must exist after open_all" + ); + } } diff --git a/crates/vnidrop/src/tests/repository.rs b/crates/vnidrop/src/tests/repository.rs index 9186be6..90bdde2 100644 --- a/crates/vnidrop/src/tests/repository.rs +++ b/crates/vnidrop/src/tests/repository.rs @@ -1,6 +1,6 @@ use crate::{ api::{CoreEvent, ReceivedLocatorKind}, - repository::{ + invitation::{ PendingDeliveryReceiptInsert, ReceivedArtifactInsert, ReceiverRequestInsert, Repository, TransferUpsert, }, diff --git a/crates/vnidrop/src/tests/runtime.rs b/crates/vnidrop/src/tests/runtime.rs index bafd01e..b3e2285 100644 --- a/crates/vnidrop/src/tests/runtime.rs +++ b/crates/vnidrop/src/tests/runtime.rs @@ -9,7 +9,7 @@ use iroh_blobs::{ }; use crate::{ - repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert}, + invitation::{PendingDeliveryReceiptInsert, Repository, TransferUpsert}, runtime::{consume_request_updates, CoreInner, IdentityMode, RequestStreamOutcome}, secure_secret::{lock_profile, FaultInjectingSecretStore}, transfer_state::{TransferDirection, TransferStatus}, diff --git a/crates/vnidrop/src/tests/secure_secret.rs b/crates/vnidrop/src/tests/secure_secret.rs index 62e4dc3..b3fe813 100644 --- a/crates/vnidrop/src/tests/secure_secret.rs +++ b/crates/vnidrop/src/tests/secure_secret.rs @@ -7,7 +7,7 @@ use data_encoding::HEXLOWER; use iroh::SecretKey; use crate::{ - repository::Repository, + persistence, secure_secret::{ lock_profile, scope_store, CustodyCrashPoint, FaultInjectingSecretStore, ReferenceStoreFailure, SecretCustody, SecretKind, SecretMaterial, SecureSecretStore, @@ -55,10 +55,10 @@ async fn reconciliation_is_scoped_to_one_application_profile() { let shared_platform_store = Arc::new(FaultInjectingSecretStore::default()); let first_store = scope_store(&first_dir, shared_platform_store.clone()); let second_store = scope_store(&second_dir, shared_platform_store); - let first_repository = Repository::open(&first_dir).await.unwrap(); - let second_repository = Repository::open(&second_dir).await.unwrap(); - let first = SecretCustody::new(first_repository.protected_secrets(), first_store.clone()); - let second = SecretCustody::new(second_repository.protected_secrets(), second_store.clone()); + let first_stores = persistence::open_all(&first_dir).await.unwrap(); + let second_stores = persistence::open_all(&second_dir).await.unwrap(); + let first = SecretCustody::new(first_stores.secrets.clone(), first_store.clone()); + let second = SecretCustody::new(second_stores.secrets.clone(), second_store.clone()); let first_handle = first .protect( SecretKind::RelationshipGrant, @@ -77,10 +77,9 @@ async fn reconciliation_is_scoped_to_one_application_profile() { .unwrap(); drop(first); - let (restarted, summary) = - SecretCustody::start(first_repository.protected_secrets(), first_store) - .await - .unwrap(); + let (restarted, summary) = SecretCustody::start(first_stores.secrets.clone(), first_store) + .await + .unwrap(); assert_eq!(summary.orphans_deleted, 0); assert_eq!( @@ -96,9 +95,9 @@ async fn reconciliation_is_scoped_to_one_application_profile() { #[tokio::test] async fn custody_maps_reference_store_failures_to_typed_core_errors() { let temp = tempfile::tempdir().unwrap(); - let repository = Repository::open(temp.path()).await.unwrap(); + let stores = persistence::open_all(temp.path()).await.unwrap(); let store = Arc::new(FaultInjectingSecretStore::default()); - let custody = SecretCustody::new(repository.protected_secrets(), store.clone()); + let custody = SecretCustody::new(stores.secrets.clone(), store.clone()); let secret = SecretMaterial::new(vec![0x5a; 32]).unwrap(); let handle = custody .protect(SecretKind::RelationshipGrant, secret.clone(), None) @@ -140,9 +139,9 @@ async fn custody_maps_reference_store_failures_to_typed_core_errors() { #[tokio::test] async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets() { let temp = tempfile::tempdir().unwrap(); - let mut repository = Repository::open(temp.path()).await.unwrap(); + let mut stores = persistence::open_all(temp.path()).await.unwrap(); let store = Arc::new(FaultInjectingSecretStore::default()); - let custody = SecretCustody::new(repository.protected_secrets(), store.clone()); + let custody = SecretCustody::new(stores.secrets.clone(), store.clone()); custody.crash_once_at(CustodyCrashPoint::StoreWrite); assert!(custody @@ -154,9 +153,9 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets() .await .is_err()); drop(custody); - drop(repository); - repository = Repository::open(temp.path()).await.unwrap(); - let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone()) + drop(stores); + stores = persistence::open_all(temp.path()).await.unwrap(); + let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone()) .await .unwrap(); assert_eq!(summary.orphans_deleted, 1); @@ -173,9 +172,9 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets() .is_err()); let staged_handle = store.only_handle_for_test(); drop(custody); - drop(repository); - repository = Repository::open(temp.path()).await.unwrap(); - let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone()) + drop(stores); + stores = persistence::open_all(temp.path()).await.unwrap(); + let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone()) .await .unwrap(); assert_eq!(summary.staged_activated, 1); @@ -186,9 +185,9 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets() store.remove_for_test(&staged_handle); drop(custody); - drop(repository); - repository = Repository::open(temp.path()).await.unwrap(); - let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone()) + drop(stores); + stores = persistence::open_all(temp.path()).await.unwrap(); + let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone()) .await .unwrap(); assert_eq!(summary.disabled, 1); @@ -207,9 +206,8 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets() .unwrap(); store.corrupt_for_test(&corrupted); drop(custody); - drop(repository); - let repository = Repository::open(temp.path()).await.unwrap(); - let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone()) + let stores = persistence::open_all(temp.path()).await.unwrap(); + let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone()) .await .unwrap(); assert_eq!(summary.disabled, 1); @@ -225,9 +223,9 @@ async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacem let legacy_path = temp.path().join("iroh.secret"); let original = SecretKey::generate(); std::fs::write(&legacy_path, HEXLOWER.encode(&original.to_bytes())).unwrap(); - let repository = Repository::open(temp.path()).await.unwrap(); + let stores = persistence::open_all(temp.path()).await.unwrap(); let store = Arc::new(FaultInjectingSecretStore::default()); - let custody = SecretCustody::new(repository.protected_secrets(), store.clone()); + let custody = SecretCustody::new(stores.secrets.clone(), store.clone()); custody.crash_once_at(CustodyCrashPoint::MetadataActivation); assert!(custody @@ -240,9 +238,8 @@ async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacem ); drop(custody); - drop(repository); - let repository = Repository::open(temp.path()).await.unwrap(); - let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone()) + let stores = persistence::open_all(temp.path()).await.unwrap(); + let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone()) .await .unwrap(); assert_eq!(summary.staged_activated, 0); @@ -272,8 +269,8 @@ async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacem let empty_store = Arc::new(FaultInjectingSecretStore::default()); let other_dir = temp.path().join("other"); std::fs::create_dir(&other_dir).unwrap(); - let other_repository = Repository::open(&other_dir).await.unwrap(); - let empty_custody = SecretCustody::new(other_repository.protected_secrets(), empty_store); + let other_stores = persistence::open_all(&other_dir).await.unwrap(); + let empty_custody = SecretCustody::new(other_stores.secrets.clone(), empty_store); assert!(matches!( empty_custody .migrate_legacy_endpoint_identity(&missing) @@ -286,9 +283,9 @@ async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacem async fn first_install_identity_is_protected_once_and_never_silently_replaced() { let temp = tempfile::tempdir().unwrap(); let legacy_path = temp.path().join("iroh.secret"); - let repository = Repository::open(temp.path()).await.unwrap(); + let stores = persistence::open_all(temp.path()).await.unwrap(); let store = Arc::new(FaultInjectingSecretStore::default()); - let (custody, _) = SecretCustody::start(repository.protected_secrets(), store.clone()) + let (custody, _) = SecretCustody::start(stores.secrets.clone(), store.clone()) .await .unwrap(); @@ -299,10 +296,9 @@ async fn first_install_identity_is_protected_once_and_never_silently_replaced() assert!(!legacy_path.exists()); let handle = store.only_handle_for_test(); drop(custody); - drop(repository); - let repository = Repository::open(temp.path()).await.unwrap(); - let (custody, _) = SecretCustody::start(repository.protected_secrets(), store.clone()) + let stores = persistence::open_all(temp.path()).await.unwrap(); + let (custody, _) = SecretCustody::start(stores.secrets.clone(), store.clone()) .await .unwrap(); assert_eq!( @@ -315,9 +311,8 @@ async fn first_install_identity_is_protected_once_and_never_silently_replaced() store.remove_for_test(&handle); drop(custody); - drop(repository); - let repository = Repository::open(temp.path()).await.unwrap(); - let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone()) + let stores = persistence::open_all(temp.path()).await.unwrap(); + let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone()) .await .unwrap(); assert_eq!(summary.disabled, 1); @@ -332,10 +327,10 @@ async fn first_install_identity_is_protected_once_and_never_silently_replaced() async fn concurrent_first_starts_converge_on_one_protected_endpoint_identity() { let temp = tempfile::tempdir().unwrap(); let legacy_path = temp.path().join("iroh.secret"); - let repository = Repository::open(temp.path()).await.unwrap(); + let stores = persistence::open_all(temp.path()).await.unwrap(); let store = Arc::new(FaultInjectingSecretStore::default()); - let first = SecretCustody::new(repository.protected_secrets(), store.clone()); - let second = SecretCustody::new(repository.protected_secrets(), store.clone()); + let first = SecretCustody::new(stores.secrets.clone(), store.clone()); + let second = SecretCustody::new(stores.secrets.clone(), store.clone()); let (first_identity, second_identity) = tokio::join!( first.initialize_endpoint_identity(&legacy_path), @@ -349,9 +344,9 @@ async fn concurrent_first_starts_converge_on_one_protected_endpoint_identity() { #[tokio::test] async fn protected_material_is_absent_from_database_and_diagnostics() { let temp = tempfile::tempdir().unwrap(); - let repository = Repository::open(temp.path()).await.unwrap(); + let stores = persistence::open_all(temp.path()).await.unwrap(); let store = Arc::new(FaultInjectingSecretStore::default()); - let custody = SecretCustody::new(repository.protected_secrets(), store.clone()); + let custody = SecretCustody::new(stores.secrets.clone(), store.clone()); let raw = (0u8..32).map(|value| value + 1).collect::>(); let encoded = HEXLOWER.encode(&raw); let material = SecretMaterial::new(raw.clone()).unwrap(); @@ -380,6 +375,7 @@ async fn protected_material_is_absent_from_database_and_diagnostics() { let diagnostics = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap(); assert!(!diagnostics.contains(&encoded)); + let repository = stores.invitation.clone(); assert!(repository.list_events(None, 500).await.unwrap().is_empty()); let mut persisted = Vec::new(); diff --git a/crates/vnidrop/src/tests/secure_secret_linux.rs b/crates/vnidrop/src/tests/secure_secret_linux.rs index ec3241c..92885c9 100644 --- a/crates/vnidrop/src/tests/secure_secret_linux.rs +++ b/crates/vnidrop/src/tests/secure_secret_linux.rs @@ -6,7 +6,7 @@ use std::{ use secret_service::Error; use crate::{ - repository::Repository, + persistence, secure_secret::{ linux::{map_error, LinuxSecretServiceApi, LinuxSecretServiceStore}, SecretCustody, SecretHandle, SecretKind, SecretMaterial, SecureSecretStore, @@ -101,10 +101,10 @@ fn adapter_survives_restart_and_deletes_only_the_selected_item() { #[tokio::test] async fn transient_backend_failures_do_not_delete_protected_metadata_or_material() { let temp = tempfile::tempdir().unwrap(); - let repository = Repository::open(temp.path()).await.unwrap(); + let stores = persistence::open_all(temp.path()).await.unwrap(); let api = Arc::new(RecordingSecretService::default()); let store = Arc::new(LinuxSecretServiceStore::with_api(api.clone())); - let custody = SecretCustody::new(repository.protected_secrets(), store.clone()); + let custody = SecretCustody::new(stores.secrets.clone(), store.clone()); let protected = custody .protect( SecretKind::RelationshipGrant, @@ -117,12 +117,12 @@ async fn transient_backend_failures_do_not_delete_protected_metadata_or_material *api.failure.lock().unwrap() = Some(SecureSecretStoreError::Unavailable); drop(custody); assert!(matches!( - SecretCustody::start(repository.protected_secrets(), store.clone()).await, + SecretCustody::start(stores.secrets.clone(), store.clone()).await, Err(VnidropError::SecureStorageUnavailable { .. }) )); *api.failure.lock().unwrap() = None; - let (restarted, _) = SecretCustody::start(repository.protected_secrets(), store) + let (restarted, _) = SecretCustody::start(stores.secrets.clone(), store) .await .unwrap(); assert_eq!( diff --git a/crates/vnidrop/src/tests/secure_secret_windows.rs b/crates/vnidrop/src/tests/secure_secret_windows.rs index 858459e..3f426b1 100644 --- a/crates/vnidrop/src/tests/secure_secret_windows.rs +++ b/crates/vnidrop/src/tests/secure_secret_windows.rs @@ -4,7 +4,7 @@ use data_encoding::HEXLOWER; use iroh::SecretKey; use crate::{ - repository::Repository, + persistence, secure_secret::{ windows::WindowsDpapiSecretStore, CustodyCrashPoint, SecretCustody, SecretMaterial, SecureSecretStore, SecureSecretStoreError, @@ -144,10 +144,10 @@ async fn endpoint_migration_survives_activation_crash_without_changing_identity( let original = SecretKey::generate(); fs::write(&legacy, HEXLOWER.encode(&original.to_bytes())).unwrap(); - let repository = Repository::open(&app_data).await.unwrap(); + let stores = persistence::open_all(&app_data).await.unwrap(); let protected_directory = app_data.join("protected-secrets"); let store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap()); - let custody = SecretCustody::new(repository.protected_secrets(), store); + let custody = SecretCustody::new(stores.secrets.clone(), store); custody.crash_once_at(CustodyCrashPoint::MetadataActivation); assert!(custody .migrate_legacy_endpoint_identity(&legacy) @@ -155,11 +155,10 @@ async fn endpoint_migration_survives_activation_crash_without_changing_identity( .is_err()); assert!(legacy.exists()); drop(custody); - drop(repository); - let repository = Repository::open(&app_data).await.unwrap(); + let stores = persistence::open_all(&app_data).await.unwrap(); let restarted_store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap()); - let (custody, _) = SecretCustody::start(repository.protected_secrets(), restarted_store) + let (custody, _) = SecretCustody::start(stores.secrets.clone(), restarted_store) .await .unwrap(); let handle = custody