fix(core): recover unrecoverable device identity

This commit is contained in:
2026-08-13 20:49:41 +02:00
parent bece2af179
commit 0ea9a8e49c
25 changed files with 739 additions and 36 deletions

View File

@@ -61,7 +61,13 @@ bytes through Kotlin memory.
work before durable cleanup; forget and block revoke affected relationships
and active targeted work within their core operation. All four durably deny
reuse and perform idempotent payload/secret cleanup.
7. Saved devices, relationships, pairing eligibility, and targeted transfers are
7. A missing or corrupted endpoint credential remains fail-closed during normal
startup. The explicit pre-start identity-reset constructor accepts only that
unrecoverable state, atomically invalidates identity-bound relationships,
eligibility, targeted authorization, and retry state, then creates a new
protected identity. Transfer history and received files remain; old active
Invitation transfers are stopped and Saved devices must be paired again.
8. Saved devices, relationships, pairing eligibility, and targeted transfers are
shipped Rust core domains. Graduating the KMP and Apple Saved-device UI and
their existing experimental preference gates is outside this release gate.

View File

@@ -0,0 +1,127 @@
//! Explicit recovery for an unrecoverable protected endpoint identity.
use sqlx::{Row, SqlitePool};
use crate::{error::VnidropError, util::now_ms};
/// Owns the cross-domain transaction that invalidates trust bound to an old identity.
#[derive(Clone)]
pub(crate) struct IdentityRecoveryStore {
pool: SqlitePool,
}
impl IdentityRecoveryStore {
pub(crate) fn new(pool: SqlitePool) -> Self {
Self { pool }
}
/// Preserve completed history and received artifacts, but revoke every
/// capability that could authenticate or resume work as the lost identity.
pub(crate) async fn reset_identity_bound_state(&self) -> Result<Vec<String>, VnidropError> {
let now = now_ms();
let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?;
let handles = sqlx::query("SELECT handle FROM protected_secret_refs ORDER BY handle")
.fetch_all(&mut *transaction)
.await
.map_err(VnidropError::repository)?
.into_iter()
.map(|row| row.get::<String, _>(0))
.collect::<Vec<_>>();
sqlx::query(
r#"
UPDATE transfers
SET status = CASE
WHEN status = 'sharing' THEN 'stopped'
WHEN status IN ('importing', 'receiving') THEN 'cancelled'
ELSE status
END,
ticket = CASE
WHEN status IN ('sharing', 'importing', 'receiving') THEN NULL
ELSE ticket
END,
updated_at = CASE
WHEN status IN ('sharing', 'importing', 'receiving') THEN ?1
ELSE updated_at
END
WHERE status IN ('sharing', 'importing', 'receiving')
"#,
)
.bind(now)
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
sqlx::query(
r#"
UPDATE receiver_requests
SET status = CASE WHEN status = 'requested' THEN 'expired' ELSE 'failed' END,
reason = 'device identity reset',
responded_at = COALESCE(responded_at, ?1)
WHERE status IN ('requested', 'accepted')
"#,
)
.bind(now)
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
sqlx::query("DELETE FROM pending_delivery_receipts")
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
for table in [
"targeted_accepted_offer_intents",
"targeted_authorization_delivery_outbox",
"targeted_completion_outbox",
"targeted_payload_release_outbox",
] {
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
}
sqlx::query(
r#"
UPDATE targeted_transfers
SET state = CASE
WHEN state IN (
'preparing', 'offering', 'awaiting_approval', 'approved',
'connecting', 'transferring', 'interrupted'
) THEN 'cancelled'
ELSE state
END,
blob_ticket = NULL,
authorization_secret_handle = NULL,
updated_at = CASE
WHEN state IN (
'preparing', 'offering', 'awaiting_approval', 'approved',
'connecting', 'transferring', 'interrupted'
) OR blob_ticket IS NOT NULL OR authorization_secret_handle IS NOT NULL
THEN ?1
ELSE updated_at
END
"#,
)
.bind(now)
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
for table in [
"pairing_eligibilities",
"device_relationships",
"relationship_generation_tombstones",
"protected_secret_refs",
] {
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
}
transaction
.commit()
.await
.map_err(VnidropError::repository)?;
Ok(handles)
}
}

View File

@@ -9,6 +9,7 @@ mod event_hub;
mod filesystem;
mod grant;
mod handshake;
mod identity_recovery;
mod invitation;
mod logging;
mod pairing_eligibility;

View File

@@ -11,6 +11,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use crate::{
blocked_devices::{self, BlockStore},
device_relationship::DeviceRelationshipStore,
identity_recovery::IdentityRecoveryStore,
invitation::Repository,
pairing_eligibility::PairingEligibilityStore,
secure_secret::{self, SecretMetadataStore},
@@ -32,6 +33,8 @@ pub(crate) struct AppDataStores {
pub(crate) secrets: SecretMetadataStore,
/// Identity-wide deny list.
pub(crate) blocked: BlockStore,
/// Explicit endpoint-identity reset transaction.
pub(crate) identity_recovery: IdentityRecoveryStore,
}
/// Create the profile pool, apply all domain schemas, return [`AppDataStores`].
@@ -66,6 +69,7 @@ pub(crate) async fn open_all(app_data_dir: &Path) -> Result<AppDataStores> {
relationships: DeviceRelationshipStore::new(pool.clone()),
eligibility: PairingEligibilityStore::new(pool.clone()),
secrets: SecretMetadataStore::new(pool.clone()),
identity_recovery: IdentityRecoveryStore::new(pool.clone()),
blocked: BlockStore::new(pool),
invitation,
})

View File

@@ -63,7 +63,7 @@ impl VnidropCore {
self.runtime.handle().block_on(future)
}
fn initialize_with_identity_mode(
pub(super) fn initialize_with_identity_mode(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
@@ -497,6 +497,24 @@ impl VnidropCore {
Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
}
/// Explicitly replace an endpoint identity whose protected credential is
/// missing or corrupted, invalidate identity-bound trust, and initialize.
/// A readable identity is never reset by this constructor.
#[uniffi::constructor]
pub fn reset_unrecoverable_identity_with_limits_and_network_config(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
Self::reset_unrecoverable_identity_protected(
app_data_dir,
event_sink,
limits,
network_config,
)
}
pub fn status(&self) -> RuntimeStatus {
self.block_on(self.inner.status())
}

View File

@@ -0,0 +1,89 @@
//! Pre-start recovery for a missing or corrupted protected endpoint identity.
use std::{path::PathBuf, sync::Arc};
use super::{IdentityMode, VnidropCore};
use crate::{
api::{CoreEventSink, CoreLimits, CoreNetworkConfig},
error::VnidropError,
secure_secret::{lock_profile, platform_secret_store, SecretCustody, SecureSecretStore},
};
impl VnidropCore {
pub(super) fn reset_unrecoverable_identity_protected(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
let app_data_path = PathBuf::from(app_data_dir);
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
let app_data_path =
std::fs::canonicalize(app_data_path).map_err(VnidropError::filesystem)?;
let profile_lock = lock_profile(&app_data_path)?;
let store = platform_secret_store(&app_data_path)?;
Self::reset_unrecoverable_identity_with_store(
app_data_path,
event_sink,
limits,
network_config,
store,
profile_lock,
)
}
fn reset_unrecoverable_identity_with_store(
app_data_path: PathBuf,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
store: Arc<dyn SecureSecretStore>,
profile_lock: crate::secure_secret::ProfileLock,
) -> Result<Arc<Self>, VnidropError> {
let recovery_runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
recovery_runtime.block_on(async {
let stores = crate::persistence::open_all(&app_data_path)
.await
.map_err(VnidropError::repository)?;
let custody =
SecretCustody::for_explicit_identity_reset(stores.secrets.clone(), store.clone());
custody.require_unrecoverable_endpoint_identity().await?;
let handles = stores
.identity_recovery
.reset_identity_bound_state()
.await?;
custody.delete_reset_handles(handles).await
})?;
Self::initialize_with_identity_mode(
app_data_path.to_string_lossy().into_owned(),
event_sink,
limits,
network_config,
IdentityMode::Protected {
store,
profile_lock,
},
)
}
#[cfg(test)]
pub(crate) fn reset_unrecoverable_identity_with_test_secret_store(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
store: Arc<dyn SecureSecretStore>,
) -> Result<Arc<Self>, VnidropError> {
let app_data_path = PathBuf::from(&app_data_dir);
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
let profile_lock = crate::secure_secret::unlocked_profile_for_test(&app_data_path)?;
Self::reset_unrecoverable_identity_with_store(
app_data_path,
event_sink,
CoreLimits::default(),
CoreNetworkConfig::default(),
store,
profile_lock,
)
}
}

View File

@@ -11,6 +11,7 @@
mod delivery;
mod facade;
mod identity_recovery;
mod lifecycle;
mod provider;
mod receive;

View File

@@ -389,6 +389,64 @@ impl SecretCustody {
}
}
pub(crate) fn for_explicit_identity_reset(
metadata: SecretMetadataStore,
store: Arc<dyn SecureSecretStore>,
) -> Self {
Self::from_parts(metadata, store)
}
/// Reject reset while the current identity remains readable. Missing or
/// corrupted endpoint custody is unrecoverable without an explicit reset.
pub(crate) async fn require_unrecoverable_endpoint_identity(&self) -> Result<(), VnidropError> {
let endpoints = self
.metadata
.list()
.await?
.into_iter()
.filter(|entry| {
entry.kind == SecretKind::EndpointIdentity
&& entry.state != SecretMetadataState::Disabled
})
.collect::<Vec<_>>();
if endpoints.is_empty() {
// Idempotent continuation after a reset transaction committed but
// the process stopped before a replacement identity was activated.
return Ok(());
}
for endpoint in endpoints {
match self.store_get_raw(endpoint.handle).await? {
Ok(material) => {
if validate_material(
SecretKind::EndpointIdentity,
&material,
endpoint.expected_identity.as_deref(),
)
.is_ok()
{
return Err(VnidropError::InvalidInput {
reason: "protected endpoint identity is still available".to_string(),
});
}
}
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {}
Err(error) => return Err(map_store_error(error)),
}
}
Ok(())
}
pub(crate) async fn delete_reset_handles(
&self,
handles: Vec<String>,
) -> Result<(), VnidropError> {
for handle in handles {
self.delete_if_present(&SecretHandle::from_stored(handle))
.await?;
}
Ok(())
}
pub(crate) async fn protect(
&self,
kind: SecretKind,
@@ -872,6 +930,23 @@ impl FaultInjectingSecretStore {
handles.into_iter().next().unwrap()
}
pub(crate) fn endpoint_identity_handle_for_test(&self) -> SecretHandle {
let handles = self
.values
.lock()
.unwrap()
.keys()
.filter(|handle| handle.as_str().contains("/endpoint-identity/"))
.cloned()
.collect::<Vec<_>>();
assert_eq!(
handles.len(),
1,
"expected exactly one protected endpoint identity"
);
handles.into_iter().next().unwrap()
}
fn check_available(&self) -> Result<(), SecureSecretStoreError> {
match *self.failure.lock().unwrap() {
Some(ReferenceStoreFailure::Locked) => Err(SecureSecretStoreError::Locked),

View File

@@ -62,6 +62,7 @@ fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_point
"fn share_files(",
"fn receive(",
"saved_device_capabilities",
"fn reset_unrecoverable_identity_with_limits_and_network_config(",
] {
assert!(
facade.contains(required) || api.contains(required) || lib.contains(required),

View File

@@ -682,6 +682,73 @@ fn saved_remote_name_and_local_label_survive_restart() {
restarted.shutdown();
}
#[test]
fn explicit_identity_reset_recovers_missing_credential_without_silent_replacement() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
reach_saved(&alice, &bob, 90_063);
let original_endpoint_id = alice.core.status().endpoint_id;
let invitation_history = alice
.core
.list_transfers()
.unwrap()
.into_iter()
.map(|transfer| (transfer.local_id, transfer.transfer_name))
.collect::<Vec<_>>();
assert!(!alice.core.list_saved_devices().unwrap().is_empty());
alice.core.shutdown();
let endpoint_handle = alice.store.endpoint_identity_handle_for_test();
alice.store.remove_for_test(&endpoint_handle);
let app_data_dir = alice._data_dir.path().to_string_lossy().into_owned();
assert!(matches!(
VnidropCore::initialize_with_test_secret_store(
app_data_dir.clone(),
Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
}),
alice.store.clone(),
),
Err(crate::VnidropError::SecureStorageMissing { .. })
));
let recovered = VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
app_data_dir,
Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
}),
alice.store.clone(),
)
.unwrap();
assert_ne!(recovered.status().endpoint_id, original_endpoint_id);
assert!(recovered.list_saved_devices().unwrap().is_empty());
assert!(recovered.list_device_relationships().unwrap().is_empty());
assert!(recovered.list_pairing_eligibilities().unwrap().is_empty());
let recovered_history = recovered.list_transfers().unwrap();
assert_eq!(recovered_history.len(), invitation_history.len());
assert_eq!(recovered_history[0].status, "stopped");
assert_eq!(
(
recovered_history[0].local_id.clone(),
recovered_history[0].transfer_name.clone(),
),
invitation_history[0]
);
recovered.shutdown();
assert!(matches!(
VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
alice._data_dir.path().to_string_lossy().into_owned(),
Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
}),
alice.store.clone(),
),
Err(crate::VnidropError::InvalidInput { .. })
));
}
#[test]
fn events_carry_stable_ids_and_monotonic_revisions() {
let alice = ProtectedNode::new();

View File

@@ -847,6 +847,10 @@ fn apple_public_bindings_omit_raw_secrets_and_generic_mutation() {
source.contains("initializeWithLimitsAndNetworkConfig"),
"Swift bindings must expose standard protected initialization"
);
assert!(
source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"),
"Swift bindings must expose explicit endpoint-identity recovery"
);
assert!(
source.contains("public struct SavedDeviceCapabilities")
&& source.contains("public func savedDeviceCapabilities()"),

View File

@@ -338,6 +338,57 @@ fn create_targeted_transfer_is_immutable_and_saved_only() {
assert_eq!(listed.total_size, transfer.total_size);
}
#[test]
fn identity_reset_cancels_targeted_authorization_bound_to_the_lost_endpoint() {
let mut alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let bob_id = bob.core().status().endpoint_id;
establish_saved(&alice, &bob, 10_002);
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("identity-bound.txt");
std::fs::write(&source_path, b"identity-bound authorization").unwrap();
let bob_core = bob.core();
let accept = std::thread::spawn(move || {
let offer = wait_for_pending_offer(&bob_core);
bob_core
.respond_to_targeted_offer(offer.transfer_id, true)
.unwrap()
});
let transfer = alice
.core()
.create_targeted_transfer(
bob_id,
vec![targeted_source(&source_path)],
Some("identity-bound.txt".to_string()),
)
.unwrap();
assert!(matches!(
accept.join().unwrap(),
crate::TargetedOfferResponse::Approved { .. }
));
alice.core.take().unwrap().shutdown();
let endpoint_handle = alice.secret_store.endpoint_identity_handle_for_test();
alice.secret_store.remove_for_test(&endpoint_handle);
let recovered = VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
alice.data_dir.path().to_string_lossy().into_owned(),
alice.sink.clone(),
alice.secret_store.clone(),
)
.unwrap();
let snapshot = recovered
.get_targeted_transfer(transfer.id.clone())
.unwrap()
.unwrap();
assert_eq!(snapshot.state, TargetedTransferState::Cancelled);
assert!(recovered
.targeted_blob_ticket_for_test(transfer.id)
.is_err());
assert!(recovered.list_saved_devices().unwrap().is_empty());
recovered.shutdown();
}
#[test]
fn preapproval_offer_is_authenticated_without_ordinary_share_ticket() {
let alice = ProtectedNode::new();