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

@@ -161,6 +161,15 @@ A crash at any step must preserve at least one valid copy and must not change
the endpoint identity. Confirmed unrecoverable loss or an explicit identity
reset is required before replacement.
An identity reset is a destructive, user-confirmed recovery operation exposed
before normal core initialization. It is accepted only when the protected
endpoint credential is missing or corrupted; a readable identity can never be
reset through this path. The reset preserves received files and transfer
history, stops old active Invitation transfers, cancels resumable Targeted
transfers, and removes relationships, pairing eligibility, grants,
authorizations, and retry state bound to the lost identity. A replacement
identity is minted only after that invalidation transaction commits.
Secrets must not synchronize through platform cloud backup. Restored metadata
without its device-bound secrets reconciles to disabled relationships, never a
cloned identity.

View File

@@ -95,6 +95,13 @@ label, forget, and block operations. Wrap those calls through the existing
Apple `CoreGateway` / `CoreRepository` boundary rather than invoking generated
bindings from SwiftUI views.
If startup reports a missing or corrupted endpoint identity, platforms must
offer the explicit `resetUnrecoverableIdentityWithLimitsAndNetworkConfig`
recovery flow rather than an endless Retry action. Confirm that Saved devices
must be paired again; transfer history and received files are retained. Locked
or temporarily unavailable credential storage remains retryable and must not
offer identity replacement.
Use SF Symbols and native iOS/macOS controls even when that duplicates Compose
presentation code. Share behavior and vocabulary across platforms, not widget
implementations. Before handoff, run `make check-localization` and

View File

@@ -1,4 +1,5 @@
import XCTest
import VnidropCore
@testable import VniDrop
/// Ports app-level assertions: core initialization on launch, destination
@@ -50,4 +51,21 @@ final class AppModelTests: XCTestCase {
await waitUntil { model.themeMode == .dark }
XCTAssertEqual(model.themeMode, .dark)
}
func testMissingEndpointIdentityOffersExplicitResetAndRecoversStartup() async {
let core = FakeCoreGateway()
core.initializeResult = .failure(
VnidropError.SecureStorageMissing(reason: "credential is missing")
)
let model = makeModel(core, preferences: Fixtures.preferences())
await waitUntil { model.startupRecovery == .identityUnrecoverable }
XCTAssertFalse(core.state.isInitialized)
await model.resetUnrecoverableIdentity()
XCTAssertEqual(core.resetUnrecoverableIdentityCount, 1)
XCTAssertNil(model.startupRecovery)
XCTAssertTrue(core.state.isInitialized)
}
}

View File

@@ -40,6 +40,14 @@ private final class BlockingCoreBindingFactory: CoreBindingFactory, @unchecked S
throw BlockingCoreFactoryError.stopped
}
func resetUnrecoverableIdentity(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
throw BlockingCoreFactoryError.stopped
}
func waitUntilInitializationStarts() async {
await withCheckedContinuation { continuation in
lock.lock()

View File

@@ -27,6 +27,7 @@ final class FakeCoreGateway: CoreGateway {
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
var initializeResult: Result<Void, Error> = .success(())
var initializeResults: [Result<Void, Error>] = []
var resetUnrecoverableIdentityResult: Result<Void, Error> = .success(())
// Recorded calls
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
@@ -38,6 +39,7 @@ final class FakeCoreGateway: CoreGateway {
private(set) var lastReceiveReceiverName: String?
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
private(set) var resetUnrecoverableIdentityCount = 0
func setState(_ state: CoreState) { stateSubject.send(state) }
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
@@ -54,6 +56,19 @@ final class FakeCoreGateway: CoreGateway {
stateSubject.send(s)
return .success(())
}
func resetUnrecoverableIdentity(
appDataDir: String,
networkConfiguration: RelayConfiguration
) async -> Result<Void, Error> {
resetUnrecoverableIdentityCount += 1
guard case .success = resetUnrecoverableIdentityResult else {
return resetUnrecoverableIdentityResult
}
var s = stateSubject.value
s.isInitialized = true
stateSubject.send(s)
return .success(())
}
func shutdown() {}
func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
lastShareAccessPolicy = accessPolicy

View File

@@ -39,19 +39,20 @@ final class SavedDeviceCoreContractTests: XCTestCase {
defer { try? FileManager.default.removeItem(at: directory) }
let sink = RecordingSink()
let first = try VnidropCore.initializeWithLimitsAndNetworkConfig(
var first: VnidropCore? = try VnidropCore.initializeWithLimitsAndNetworkConfig(
appDataDir: directory.path,
eventSink: sink,
limits: defaultCoreLimits(),
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
)
let endpointId = first.status().endpointId
let endpointId = first!.status().endpointId
XCTAssertFalse(endpointId.isEmpty)
XCTAssertFalse(
FileManager.default.fileExists(atPath: directory.appendingPathComponent("iroh.secret").path),
"protected identity must not fall back to plaintext"
)
first.shutdown()
first?.shutdown()
first = nil
let restarted = try VnidropCore.initializeWithLimitsAndNetworkConfig(
appDataDir: directory.path,
@@ -134,6 +135,9 @@ final class SavedDeviceCoreContractTests: XCTestCase {
let _: (
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
) = VnidropCore.initializeWithLimitsAndNetworkConfig
let _: (
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
) = VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig
let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities()
XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1)
XCTAssertNotNil(defaultCoreLimits().maxSavedDevices)
@@ -165,6 +169,7 @@ final class SavedDeviceCoreContractTests: XCTestCase {
XCTAssertFalse(source.contains("ExperimentalSavedDeviceCapabilities"))
XCTAssertFalse(source.contains("experimentalSavedDeviceCapabilities"))
XCTAssertTrue(source.contains("initializeWithLimitsAndNetworkConfig"))
XCTAssertTrue(source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"))
XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities"))
XCTAssertTrue(source.contains("public func savedDeviceCapabilities()"))
XCTAssertTrue(source.contains("setSavedDeviceLabel"))

View File

@@ -63,6 +63,9 @@ final class UserFacingErrorTests: XCTestCase {
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput)
XCTAssertFalse(VnidropError.SecureStorageMissing(reason: "credential is missing").canRetryWithoutChangingInput)
XCTAssertFalse(VnidropError.SecureStorageCorrupted(reason: "credential is corrupted").canRetryWithoutChangingInput)
XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput)
XCTAssertTrue(VnidropError.SecureStorageLocked(reason: "credential store is locked").canRetryWithoutChangingInput)
}
}

View File

@@ -68,7 +68,13 @@ struct RootView: View {
// A small, unobtrusive indicator while the core finishes its async
// startup otherwise the lists look empty and the app feels stalled.
if !sendModel.coreState.isInitialized {
CoreStartingOverlay()
CoreStartingOverlay(
recovery: appModel.startupRecovery,
isResettingIdentity: appModel.isResettingIdentity,
onResetIdentity: {
Task { await appModel.resetUnrecoverableIdentity() }
}
)
}
}
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
@@ -255,19 +261,59 @@ private struct ApprovalLayer: View {
/// A full-window cover with a centered spinner shown while the core is starting.
private struct CoreStartingOverlay: View {
let recovery: AppStartupRecovery?
let isResettingIdentity: Bool
let onResetIdentity: () -> Void
@State private var confirmsIdentityReset = false
var body: some View {
ZStack {
backgroundColor.ignoresSafeArea()
VStack(spacing: 16) {
ProgressView().controlSize(.large)
Text(String(localized: L10n.App.starting))
.font(.headline)
.foregroundStyle(.secondary)
if recovery == .identityUnrecoverable {
VStack(spacing: 18) {
Image(systemSymbol: .exclamationmarkTriangleFill)
.font(.system(size: 44))
.foregroundStyle(.orange)
Text(String(localized: L10n.App.identityResetTitle))
.font(.title2.bold())
Text(String(localized: L10n.App.identityResetMessage))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.frame(maxWidth: 420)
Button(role: .destructive) {
confirmsIdentityReset = true
} label: {
if isResettingIdentity {
ProgressView()
} else {
Text(String(localized: L10n.App.identityResetAction))
}
}
.buttonStyle(.borderedProminent)
.disabled(isResettingIdentity)
}
.padding(32)
} else {
VStack(spacing: 16) {
ProgressView().controlSize(.large)
Text(String(localized: L10n.App.starting))
.font(.headline)
.foregroundStyle(.secondary)
}
}
}
.transition(.opacity)
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(String(localized: L10n.App.starting)))
.alert(
String(localized: L10n.App.identityResetTitle),
isPresented: $confirmsIdentityReset
) {
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
Button(String(localized: L10n.App.identityResetAction), role: .destructive) {
onResetIdentity()
}
} message: {
Text(String(localized: L10n.App.identityResetConfirmation))
}
}
private var backgroundColor: Color {

View File

@@ -25,6 +25,7 @@ protocol CoreGateway: AnyObject {
var signals: AnyPublisher<CoreSignal, Never> { get }
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
func resetUnrecoverableIdentity(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
func shutdown()
func shareSources(
_ sources: [ShareSource],

View File

@@ -28,36 +28,50 @@ protocol CoreBindingFactory: Sendable {
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore
func resetUnrecoverableIdentity(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore
}
struct NativeCoreBindingFactory: CoreBindingFactory {
private func nativeConfiguration(_ networkConfiguration: RelayConfiguration) -> CoreNetworkConfig {
switch networkConfiguration.mode {
case .automatic:
return defaultCoreNetworkConfig()
case .strictCustom:
return CoreNetworkConfig(mode: .strictCustom, relayUrls: networkConfiguration.relayURLs)
case .customWithDirectFallback:
return CoreNetworkConfig(mode: .customWithDirectFallback, relayUrls: networkConfiguration.relayURLs)
case .localOnly:
return CoreNetworkConfig(mode: .localOnly, relayUrls: [])
}
}
func initialize(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
let nativeConfiguration: CoreNetworkConfig
switch networkConfiguration.mode {
case .automatic:
nativeConfiguration = defaultCoreNetworkConfig()
case .strictCustom:
nativeConfiguration = CoreNetworkConfig(
mode: .strictCustom,
relayUrls: networkConfiguration.relayURLs
)
case .customWithDirectFallback:
nativeConfiguration = CoreNetworkConfig(
mode: .customWithDirectFallback,
relayUrls: networkConfiguration.relayURLs
)
case .localOnly:
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
}
return try VnidropCore.initializeWithLimitsAndNetworkConfig(
appDataDir: appDataDir,
eventSink: eventSink,
limits: defaultCoreLimits(),
networkConfig: nativeConfiguration
networkConfig: nativeConfiguration(networkConfiguration)
)
}
func resetUnrecoverableIdentity(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
try VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig(
appDataDir: appDataDir,
eventSink: eventSink,
limits: defaultCoreLimits(),
networkConfig: nativeConfiguration(networkConfiguration)
)
}
}
@@ -139,6 +153,35 @@ final class CoreRepository: ObservableObject, CoreGateway {
}
}
func resetUnrecoverableIdentity(
appDataDir: String,
networkConfiguration: RelayConfiguration
) async -> Result<Void, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
isNetworkTransitionInProgress = true
defer { isNetworkTransitionInProgress = false }
let result = await runCore { [sink] in
let created = try self.coreFactory.resetUnrecoverableIdentity(
appDataDir: appDataDir,
eventSink: sink,
networkConfiguration: networkConfiguration
)
self.core = created
return created
}
switch result {
case .success:
refreshSnapshot()
state.isInitialized = true
return .success(())
case .failure(let error):
state = CoreState()
return .failure(error)
}
}
func shutdown() {
core?.shutdown()
core = nil
@@ -522,4 +565,3 @@ private extension ReceiverRequest {

View File

@@ -1,5 +1,10 @@
import Foundation
import Combine
import VnidropCore
enum AppStartupRecovery: Equatable {
case identityUnrecoverable
}
/// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the
/// core on launch and tracks the selected destination + theme.
@@ -7,10 +12,14 @@ import Combine
final class AppModel: ObservableObject {
@Published private(set) var destination: AppDestination = .send
@Published private(set) var themeMode: ThemeMode = .system
@Published private(set) var startupRecovery: AppStartupRecovery?
@Published private(set) var isResettingIdentity = false
private let environment: PlatformEnvironment
private let repository: CoreGateway
private let messages: UiMessageController
private let appDataDir: String
private let networkConfiguration: RelayConfiguration
private var cancellables = Set<AnyCancellable>()
init(
@@ -22,15 +31,23 @@ final class AppModel: ObservableObject {
self.environment = environment
self.repository = repository
self.messages = messages
self.appDataDir = environment.defaultCoreDataDir
self.networkConfiguration = preferences.preferences.relayConfiguration
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
Task {
let result = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: preferences.preferences.relayConfiguration
appDataDir: appDataDir,
networkConfiguration: networkConfiguration
)
if case .failure(let error) = result { messages.error(error) }
if case .failure(let error) = result {
if error.hasUnrecoverableEndpointIdentity {
startupRecovery = .identityUnrecoverable
} else {
messages.error(error)
}
}
}
preferences.$preferences
@@ -44,4 +61,30 @@ final class AppModel: ObservableObject {
guard destination != self.destination else { return }
self.destination = destination
}
func resetUnrecoverableIdentity() async {
guard startupRecovery == .identityUnrecoverable, !isResettingIdentity else { return }
isResettingIdentity = true
defer { isResettingIdentity = false }
let result = await repository.resetUnrecoverableIdentity(
appDataDir: appDataDir,
networkConfiguration: networkConfiguration
)
switch result {
case .success:
startupRecovery = nil
case .failure(let error):
messages.error(error)
}
}
}
private extension Error {
var hasUnrecoverableEndpointIdentity: Bool {
guard let error = self as? VnidropError else { return false }
switch error {
case .SecureStorageMissing, .SecureStorageCorrupted: return true
default: return false
}
}
}

View File

@@ -107,7 +107,9 @@ extension Error {
var canRetryWithoutChangingInput: Bool {
guard let vni = self as? VnidropError else { return true }
switch vni {
case .FilesystemPermission, .DestinationExists, .InvalidInput: return false
case .FilesystemPermission, .DestinationExists, .InvalidInput,
.SecureStorageMissing, .SecureStorageCorrupted:
return false
default: return true
}
}

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();

View File

@@ -335,6 +335,66 @@
"ru": "Запуск…"
}
},
"app_identity_reset_action": {
"context": "Startup recovery: destructive action that replaces an unrecoverable device identity.",
"targets": ["apple"],
"translations": {
"en": "Reset device identity",
"fr": "Réinitialiser lidentité de lappareil",
"es": "Restablecer la identidad del dispositivo",
"it": "Reimposta lidentità del dispositivo",
"de": "Geräteidentität zurücksetzen",
"pt": "Repor a identidade do dispositivo",
"pl": "Zresetuj tożsamość urządzenia",
"nl": "Apparaatidentiteit opnieuw instellen",
"ru": "Сбросить идентификатор устройства"
}
},
"app_identity_reset_confirmation": {
"context": "Startup recovery: confirmation explaining the effects of replacing an unrecoverable identity.",
"targets": ["apple"],
"translations": {
"en": "Your transfer history and received files stay on this device. Saved devices will be removed and must be paired again.",
"fr": "Lhistorique des transferts et les fichiers reçus restent sur cet appareil. Les appareils enregistrés seront supprimés et devront être associés à nouveau.",
"es": "El historial de transferencias y los archivos recibidos permanecerán en este dispositivo. Los dispositivos guardados se eliminarán y deberán vincularse de nuevo.",
"it": "La cronologia dei trasferimenti e i file ricevuti resteranno su questo dispositivo. I dispositivi salvati verranno rimossi e dovranno essere associati di nuovo.",
"de": "Übertragungsverlauf und empfangene Dateien bleiben auf diesem Gerät. Gespeicherte Geräte werden entfernt und müssen erneut gekoppelt werden.",
"pt": "O histórico de transferências e os ficheiros recebidos permanecem neste dispositivo. Os dispositivos guardados serão removidos e terão de ser emparelhados novamente.",
"pl": "Historia transferów i odebrane pliki pozostaną na tym urządzeniu. Zapisane urządzenia zostaną usunięte i trzeba będzie sparować je ponownie.",
"nl": "De overdrachtsgeschiedenis en ontvangen bestanden blijven op dit apparaat. Opgeslagen apparaten worden verwijderd en moeten opnieuw worden gekoppeld.",
"ru": "История передач и полученные файлы останутся на этом устройстве. Сохранённые устройства будут удалены, и их потребуется связать заново."
}
},
"app_identity_reset_title": {
"context": "Startup recovery: title when the protected endpoint identity is permanently unavailable.",
"targets": ["apple"],
"translations": {
"en": "Device identity unavailable",
"fr": "Identité de lappareil indisponible",
"es": "La identidad del dispositivo no está disponible",
"it": "Identità del dispositivo non disponibile",
"de": "Geräteidentität nicht verfügbar",
"pt": "Identidade do dispositivo indisponível",
"pl": "Tożsamość urządzenia jest niedostępna",
"nl": "Apparaatidentiteit niet beschikbaar",
"ru": "Идентификатор устройства недоступен"
}
},
"app_identity_reset_message": {
"context": "Startup recovery: explanation when the protected endpoint identity cannot be recovered.",
"targets": ["apple"],
"translations": {
"en": "VniDrop cant recover this devices secure identity. Reset it to start again with a new identity.",
"fr": "VniDrop ne peut pas récupérer lidentité sécurisée de cet appareil. Réinitialisez-la pour recommencer avec une nouvelle identité.",
"es": "VniDrop no puede recuperar la identidad segura de este dispositivo. Restablézcala para empezar de nuevo con una identidad nueva.",
"it": "VniDrop non può recuperare lidentità sicura di questo dispositivo. La reimposti per ricominciare con una nuova identità.",
"de": "VniDrop kann die sichere Identität dieses Geräts nicht wiederherstellen. Setzen Sie sie zurück, um mit einer neuen Identität zu beginnen.",
"pt": "O VniDrop não consegue recuperar a identidade segura deste dispositivo. Reponha-a para começar de novo com uma nova identidade.",
"pl": "VniDrop nie może odzyskać bezpiecznej tożsamości tego urządzenia. Zresetuj ją, aby zacząć od nowej tożsamości.",
"nl": "VniDrop kan de beveiligde identiteit van dit apparaat niet herstellen. Stel deze opnieuw in om met een nieuwe identiteit te beginnen.",
"ru": "VniDrop не может восстановить защищённый идентификатор этого устройства. Сбросьте его, чтобы начать с новым идентификатором."
}
},
"appearance_auto_description": {
"context": "Settings > Appearance: description for the System/auto option.",
"translations": {