From 0ea9a8e49c13ae043f89d4b186363f243c781b1c Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Thu, 13 Aug 2026 20:49:41 +0200 Subject: [PATCH] fix(core): recover unrecoverable device identity --- DESIGN-DEVICE-HISTORY.md | 9 ++ DEVICE-HISTORY-UI-HANDOFF.md | 7 + apple/Tests/AppModelTests.swift | 18 +++ .../Tests/CoreRepositoryLifecycleTests.swift | 8 ++ apple/Tests/Fakes.swift | 15 +++ .../Tests/SavedDeviceCoreContractTests.swift | 11 +- apple/Tests/UiFeedbackTests.swift | 3 + apple/VniDrop/App/RootView.swift | 62 +++++++-- apple/VniDrop/Core/CoreGateway.swift | 1 + apple/VniDrop/Core/CoreRepository.swift | 80 ++++++++--- apple/VniDrop/Features/App/AppModel.swift | 49 ++++++- .../VniDrop/UI/Feedback/UserFacingError.swift | 4 +- crates/vnidrop/CORE_FLOW.md | 8 +- crates/vnidrop/src/identity_recovery.rs | 127 ++++++++++++++++++ crates/vnidrop/src/lib.rs | 1 + crates/vnidrop/src/persistence.rs | 4 + crates/vnidrop/src/runtime/facade.rs | 20 ++- .../vnidrop/src/runtime/identity_recovery.rs | 89 ++++++++++++ crates/vnidrop/src/runtime/mod.rs | 1 + crates/vnidrop/src/secure_secret.rs | 75 +++++++++++ crates/vnidrop/src/tests/api_surface.rs | 1 + .../vnidrop/src/tests/device_relationship.rs | 67 +++++++++ .../src/tests/platform_contract_apple.rs | 4 + crates/vnidrop/src/tests/targeted_transfer.rs | 51 +++++++ localization/strings.json | 60 +++++++++ 25 files changed, 739 insertions(+), 36 deletions(-) create mode 100644 crates/vnidrop/src/identity_recovery.rs create mode 100644 crates/vnidrop/src/runtime/identity_recovery.rs diff --git a/DESIGN-DEVICE-HISTORY.md b/DESIGN-DEVICE-HISTORY.md index 719fd73..5580722 100644 --- a/DESIGN-DEVICE-HISTORY.md +++ b/DESIGN-DEVICE-HISTORY.md @@ -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. diff --git a/DEVICE-HISTORY-UI-HANDOFF.md b/DEVICE-HISTORY-UI-HANDOFF.md index 755000a..a002e86 100644 --- a/DEVICE-HISTORY-UI-HANDOFF.md +++ b/DEVICE-HISTORY-UI-HANDOFF.md @@ -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 diff --git a/apple/Tests/AppModelTests.swift b/apple/Tests/AppModelTests.swift index 5f38949..dbd72e2 100644 --- a/apple/Tests/AppModelTests.swift +++ b/apple/Tests/AppModelTests.swift @@ -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) + } } diff --git a/apple/Tests/CoreRepositoryLifecycleTests.swift b/apple/Tests/CoreRepositoryLifecycleTests.swift index 7d0ce97..ac4f00e 100644 --- a/apple/Tests/CoreRepositoryLifecycleTests.swift +++ b/apple/Tests/CoreRepositoryLifecycleTests.swift @@ -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() diff --git a/apple/Tests/Fakes.swift b/apple/Tests/Fakes.swift index 5c3f515..3fe7b26 100644 --- a/apple/Tests/Fakes.swift +++ b/apple/Tests/Fakes.swift @@ -27,6 +27,7 @@ final class FakeCoreGateway: CoreGateway { var clearReceiveHistoryResult: Result = .success(0) var initializeResult: Result = .success(()) var initializeResults: [Result] = [] + var resetUnrecoverableIdentityResult: Result = .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 { + 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 { lastShareAccessPolicy = accessPolicy diff --git a/apple/Tests/SavedDeviceCoreContractTests.swift b/apple/Tests/SavedDeviceCoreContractTests.swift index 1f63745..d4ed9bd 100644 --- a/apple/Tests/SavedDeviceCoreContractTests.swift +++ b/apple/Tests/SavedDeviceCoreContractTests.swift @@ -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")) diff --git a/apple/Tests/UiFeedbackTests.swift b/apple/Tests/UiFeedbackTests.swift index 9250f79..0e65ce8 100644 --- a/apple/Tests/UiFeedbackTests.swift +++ b/apple/Tests/UiFeedbackTests.swift @@ -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) } } diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index 485cb84..87e5f79 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -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 { diff --git a/apple/VniDrop/Core/CoreGateway.swift b/apple/VniDrop/Core/CoreGateway.swift index e7a5620..47acd8e 100644 --- a/apple/VniDrop/Core/CoreGateway.swift +++ b/apple/VniDrop/Core/CoreGateway.swift @@ -25,6 +25,7 @@ protocol CoreGateway: AnyObject { var signals: AnyPublisher { get } func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result + func resetUnrecoverableIdentity(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result func shutdown() func shareSources( _ sources: [ShareSource], diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index 27678ae..bd56793 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -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 { + 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 { - diff --git a/apple/VniDrop/Features/App/AppModel.swift b/apple/VniDrop/Features/App/AppModel.swift index 8357f26..8adbd25 100644 --- a/apple/VniDrop/Features/App/AppModel.swift +++ b/apple/VniDrop/Features/App/AppModel.swift @@ -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() 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 + } + } } diff --git a/apple/VniDrop/UI/Feedback/UserFacingError.swift b/apple/VniDrop/UI/Feedback/UserFacingError.swift index f8bb041..55131d5 100644 --- a/apple/VniDrop/UI/Feedback/UserFacingError.swift +++ b/apple/VniDrop/UI/Feedback/UserFacingError.swift @@ -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 } } diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index 0d6bda1..4c1217d 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -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. diff --git a/crates/vnidrop/src/identity_recovery.rs b/crates/vnidrop/src/identity_recovery.rs new file mode 100644 index 0000000..4db30f7 --- /dev/null +++ b/crates/vnidrop/src/identity_recovery.rs @@ -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, 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::(0)) + .collect::>(); + + 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) + } +} diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 4d2d8ca..e6b8657 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -9,6 +9,7 @@ mod event_hub; mod filesystem; mod grant; mod handshake; +mod identity_recovery; mod invitation; mod logging; mod pairing_eligibility; diff --git a/crates/vnidrop/src/persistence.rs b/crates/vnidrop/src/persistence.rs index 7268e8b..a4adbd7 100644 --- a/crates/vnidrop/src/persistence.rs +++ b/crates/vnidrop/src/persistence.rs @@ -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 { 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, }) diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index 124a4f2..a9eedb9 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -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, 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, + limits: CoreLimits, + network_config: CoreNetworkConfig, + ) -> Result, 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()) } diff --git a/crates/vnidrop/src/runtime/identity_recovery.rs b/crates/vnidrop/src/runtime/identity_recovery.rs new file mode 100644 index 0000000..45d5193 --- /dev/null +++ b/crates/vnidrop/src/runtime/identity_recovery.rs @@ -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, + limits: CoreLimits, + network_config: CoreNetworkConfig, + ) -> Result, 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, + limits: CoreLimits, + network_config: CoreNetworkConfig, + store: Arc, + profile_lock: crate::secure_secret::ProfileLock, + ) -> Result, 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, + store: Arc, + ) -> Result, 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, + ) + } +} diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index 734bbb7..4cd6370 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -11,6 +11,7 @@ mod delivery; mod facade; +mod identity_recovery; mod lifecycle; mod provider; mod receive; diff --git a/crates/vnidrop/src/secure_secret.rs b/crates/vnidrop/src/secure_secret.rs index 67a336e..7a894a9 100644 --- a/crates/vnidrop/src/secure_secret.rs +++ b/crates/vnidrop/src/secure_secret.rs @@ -389,6 +389,64 @@ impl SecretCustody { } } + pub(crate) fn for_explicit_identity_reset( + metadata: SecretMetadataStore, + store: Arc, + ) -> 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::>(); + 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, + ) -> 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::>(); + 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), diff --git a/crates/vnidrop/src/tests/api_surface.rs b/crates/vnidrop/src/tests/api_surface.rs index 4f1e2c9..5add434 100644 --- a/crates/vnidrop/src/tests/api_surface.rs +++ b/crates/vnidrop/src/tests/api_surface.rs @@ -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), diff --git a/crates/vnidrop/src/tests/device_relationship.rs b/crates/vnidrop/src/tests/device_relationship.rs index 3847530..cedc676 100644 --- a/crates/vnidrop/src/tests/device_relationship.rs +++ b/crates/vnidrop/src/tests/device_relationship.rs @@ -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::>(); + 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(); diff --git a/crates/vnidrop/src/tests/platform_contract_apple.rs b/crates/vnidrop/src/tests/platform_contract_apple.rs index 09935bf..e9e1399 100644 --- a/crates/vnidrop/src/tests/platform_contract_apple.rs +++ b/crates/vnidrop/src/tests/platform_contract_apple.rs @@ -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()"), diff --git a/crates/vnidrop/src/tests/targeted_transfer.rs b/crates/vnidrop/src/tests/targeted_transfer.rs index 91b52dc..a93b95b 100644 --- a/crates/vnidrop/src/tests/targeted_transfer.rs +++ b/crates/vnidrop/src/tests/targeted_transfer.rs @@ -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(); diff --git a/localization/strings.json b/localization/strings.json index 63119b9..f43d420 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -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 l’identité de l’appareil", + "es": "Restablecer la identidad del dispositivo", + "it": "Reimposta l’identità 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": "L’historique 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 l’appareil 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 can’t recover this device’s secure identity. Reset it to start again with a new identity.", + "fr": "VniDrop ne peut pas récupérer l’identité 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 l’identità 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": {