mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
fix(core): recover unrecoverable device identity
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
127
crates/vnidrop/src/identity_recovery.rs
Normal file
127
crates/vnidrop/src/identity_recovery.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod event_hub;
|
||||
mod filesystem;
|
||||
mod grant;
|
||||
mod handshake;
|
||||
mod identity_recovery;
|
||||
mod invitation;
|
||||
mod logging;
|
||||
mod pairing_eligibility;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
89
crates/vnidrop/src/runtime/identity_recovery.rs
Normal file
89
crates/vnidrop/src/runtime/identity_recovery.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
mod delivery;
|
||||
mod facade;
|
||||
mod identity_recovery;
|
||||
mod lifecycle;
|
||||
mod provider;
|
||||
mod receive;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user