Merge remote-tracking branch 'origin/feat/device-history' into feat/device-history-apple

# Conflicts:
#	apple/VniDrop/App/RootView.swift
#	apple/VniDrop/Features/App/AppModel.swift
This commit is contained in:
2026-08-13 22:31:38 +02:00
25 changed files with 748 additions and 32 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 the endpoint identity. Confirmed unrecoverable loss or an explicit identity
reset is required before replacement. 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 Secrets must not synchronize through platform cloud backup. Restored metadata
without its device-bound secrets reconciles to disabled relationships, never a without its device-bound secrets reconciles to disabled relationships, never a
cloned identity. 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 Apple `CoreGateway` / `CoreRepository` boundary rather than invoking generated
bindings from SwiftUI views. 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 Use SF Symbols and native iOS/macOS controls even when that duplicates Compose
presentation code. Share behavior and vocabulary across platforms, not widget presentation code. Share behavior and vocabulary across platforms, not widget
implementations. Before handoff, run `make check-localization` and implementations. Before handoff, run `make check-localization` and

View File

@@ -1,4 +1,5 @@
import XCTest import XCTest
import VnidropCore
@testable import VniDrop @testable import VniDrop
/// Ports app-level assertions: core initialization on launch, destination /// Ports app-level assertions: core initialization on launch, destination
@@ -50,4 +51,21 @@ final class AppModelTests: XCTestCase {
await waitUntil { model.themeMode == .dark } await waitUntil { model.themeMode == .dark }
XCTAssertEqual(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 throw BlockingCoreFactoryError.stopped
} }
func resetUnrecoverableIdentity(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
throw BlockingCoreFactoryError.stopped
}
func waitUntilInitializationStarts() async { func waitUntilInitializationStarts() async {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
lock.lock() lock.lock()

View File

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

View File

@@ -39,19 +39,20 @@ final class SavedDeviceCoreContractTests: XCTestCase {
defer { try? FileManager.default.removeItem(at: directory) } defer { try? FileManager.default.removeItem(at: directory) }
let sink = RecordingSink() let sink = RecordingSink()
let first = try VnidropCore.initializeWithLimitsAndNetworkConfig( var first: VnidropCore? = try VnidropCore.initializeWithLimitsAndNetworkConfig(
appDataDir: directory.path, appDataDir: directory.path,
eventSink: sink, eventSink: sink,
limits: defaultCoreLimits(), limits: defaultCoreLimits(),
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: []) networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
) )
let endpointId = first.status().endpointId let endpointId = first!.status().endpointId
XCTAssertFalse(endpointId.isEmpty) XCTAssertFalse(endpointId.isEmpty)
XCTAssertFalse( XCTAssertFalse(
FileManager.default.fileExists(atPath: directory.appendingPathComponent("iroh.secret").path), FileManager.default.fileExists(atPath: directory.appendingPathComponent("iroh.secret").path),
"protected identity must not fall back to plaintext" "protected identity must not fall back to plaintext"
) )
first.shutdown() first?.shutdown()
first = nil
let restarted = try VnidropCore.initializeWithLimitsAndNetworkConfig( let restarted = try VnidropCore.initializeWithLimitsAndNetworkConfig(
appDataDir: directory.path, appDataDir: directory.path,
@@ -134,6 +135,9 @@ final class SavedDeviceCoreContractTests: XCTestCase {
let _: ( let _: (
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore (String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
) = VnidropCore.initializeWithLimitsAndNetworkConfig ) = VnidropCore.initializeWithLimitsAndNetworkConfig
let _: (
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
) = VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig
let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities() let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities()
XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1) XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1)
XCTAssertNotNil(defaultCoreLimits().maxSavedDevices) XCTAssertNotNil(defaultCoreLimits().maxSavedDevices)
@@ -165,6 +169,7 @@ final class SavedDeviceCoreContractTests: XCTestCase {
XCTAssertFalse(source.contains("ExperimentalSavedDeviceCapabilities")) XCTAssertFalse(source.contains("ExperimentalSavedDeviceCapabilities"))
XCTAssertFalse(source.contains("experimentalSavedDeviceCapabilities")) XCTAssertFalse(source.contains("experimentalSavedDeviceCapabilities"))
XCTAssertTrue(source.contains("initializeWithLimitsAndNetworkConfig")) XCTAssertTrue(source.contains("initializeWithLimitsAndNetworkConfig"))
XCTAssertTrue(source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"))
XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities")) XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities"))
XCTAssertTrue(source.contains("public func savedDeviceCapabilities()")) XCTAssertTrue(source.contains("public func savedDeviceCapabilities()"))
XCTAssertTrue(source.contains("setSavedDeviceLabel")) 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)) XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput) XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").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.Network(reason: "offline").canRetryWithoutChangingInput)
XCTAssertTrue(VnidropError.SecureStorageLocked(reason: "credential store is locked").canRetryWithoutChangingInput)
} }
} }

View File

@@ -96,7 +96,12 @@ struct RootView: View {
CoreStartingOverlay( CoreStartingOverlay(
error: appModel.startupError, error: appModel.startupError,
detail: appModel.startupErrorDetail, detail: appModel.startupErrorDetail,
onRetry: appModel.retryStartup onRetry: appModel.retryStartup,
recovery: appModel.startupRecovery,
isResettingIdentity: appModel.isResettingIdentity,
onResetIdentity: {
Task { await appModel.resetUnrecoverableIdentity() }
}
) )
} }
} }
@@ -293,11 +298,41 @@ private struct CoreStartingOverlay: View {
/// Debug builds only; nil in Release. /// Debug builds only; nil in Release.
let detail: String? let detail: String?
let onRetry: () -> Void let onRetry: () -> Void
let recovery: AppStartupRecovery?
let isResettingIdentity: Bool
let onResetIdentity: () -> Void
@State private var confirmsIdentityReset = false
var body: some View { var body: some View {
ZStack { ZStack {
backgroundColor.ignoresSafeArea() backgroundColor.ignoresSafeArea()
if let error { // A repairable identity comes first: it is the one failure the user can
// actually act on, and its own copy explains the consequences.
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 if let error {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemSymbol: .exclamationmarkTriangleFill) Image(systemSymbol: .exclamationmarkTriangleFill)
.font(.system(size: 34)) .font(.system(size: 34))
@@ -341,8 +376,28 @@ private struct CoreStartingOverlay: View {
} }
} }
.transition(.opacity) .transition(.opacity)
.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))
}
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityLabel(Text(error?.resolved() ?? String(localized: L10n.App.starting))) .accessibilityLabel(Text(accessibilityLabel))
}
/// Mirrors the three visual states, so VoiceOver never announces "Starting"
/// over a screen that has actually stopped and is asking for a decision.
private var accessibilityLabel: String {
if recovery == .identityUnrecoverable {
return String(localized: L10n.App.identityResetTitle)
}
return error?.resolved() ?? String(localized: L10n.App.starting)
} }
private var backgroundColor: Color { private var backgroundColor: Color {

View File

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

View File

@@ -28,36 +28,50 @@ protocol CoreBindingFactory: Sendable {
eventSink: CoreEventSink, eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration networkConfiguration: RelayConfiguration
) throws -> VnidropCore ) throws -> VnidropCore
func resetUnrecoverableIdentity(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore
} }
struct NativeCoreBindingFactory: CoreBindingFactory { 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( func initialize(
appDataDir: String, appDataDir: String,
eventSink: CoreEventSink, eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration networkConfiguration: RelayConfiguration
) throws -> VnidropCore { ) 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( return try VnidropCore.initializeWithLimitsAndNetworkConfig(
appDataDir: appDataDir, appDataDir: appDataDir,
eventSink: eventSink, eventSink: eventSink,
limits: defaultCoreLimits(), 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() { func shutdown() {
core?.shutdown() core?.shutdown()
core = nil core = nil
@@ -763,4 +806,3 @@ private extension ReceiverRequest {

View File

@@ -1,5 +1,10 @@
import Foundation import Foundation
import Combine import Combine
import VnidropCore
enum AppStartupRecovery: Equatable {
case identityUnrecoverable
}
/// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the /// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the
/// core on launch and tracks the selected destination + theme. /// core on launch and tracks the selected destination + theme.
@@ -10,16 +15,21 @@ final class AppModel: ObservableObject {
/// Why startup failed, or nil while it is still in progress or has succeeded. /// Why startup failed, or nil while it is still in progress or has succeeded.
/// The startup overlay covers the snackbar, so without this a failed /// The startup overlay covers the snackbar, so without this a failed
/// `initialize` was indistinguishable from an app that never finished loading. /// `initialize` was indistinguishable from an app that never finished loading.
/// Stays nil for failures `startupRecovery` can offer a repair for, so the
/// user is shown the repair rather than a dead end.
@Published private(set) var startupError: UiText? @Published private(set) var startupError: UiText?
/// Untranslated failure detail, kept for the debug overlay only. The friendly /// Untranslated failure detail, kept for the debug overlay only. The friendly
/// message alone cannot distinguish a missing keychain item from a database /// message alone cannot distinguish a missing keychain item from a database
/// fault, which makes a startup failure undiagnosable on a real device. /// fault, which makes a startup failure undiagnosable on a real device.
@Published private(set) var startupErrorDetail: String? @Published private(set) var startupErrorDetail: String?
@Published private(set) var startupRecovery: AppStartupRecovery?
@Published private(set) var isResettingIdentity = false
private let environment: PlatformEnvironment private let environment: PlatformEnvironment
private let repository: CoreGateway private let repository: CoreGateway
private let messages: UiMessageController private let messages: UiMessageController
private let relayConfiguration: RelayConfiguration private let appDataDir: String
private let networkConfiguration: RelayConfiguration
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init( init(
@@ -31,7 +41,8 @@ final class AppModel: ObservableObject {
self.environment = environment self.environment = environment
self.repository = repository self.repository = repository
self.messages = messages self.messages = messages
self.relayConfiguration = preferences.preferences.relayConfiguration self.appDataDir = environment.defaultCoreDataDir
self.networkConfiguration = preferences.preferences.relayConfiguration
AppLogger.info("lifecycle", "app started", ["platform": environment.name]) AppLogger.info("lifecycle", "app started", ["platform": environment.name])
@@ -49,12 +60,19 @@ final class AppModel: ObservableObject {
func initializeCore() async { func initializeCore() async {
startupError = nil startupError = nil
startupErrorDetail = nil startupErrorDetail = nil
startupRecovery = nil
let result = await repository.initialize( let result = await repository.initialize(
appDataDir: environment.defaultCoreDataDir, appDataDir: appDataDir,
networkConfiguration: relayConfiguration networkConfiguration: networkConfiguration
) )
if case .failure(let error) = result { if case .failure(let error) = result {
AppLogger.error("lifecycle", "core initialization failed", error) AppLogger.error("lifecycle", "core initialization failed", error)
// A repairable identity gets the reset flow instead of a generic
// failure, which would offer only a retry that cannot succeed.
if error.hasUnrecoverableEndpointIdentity {
startupRecovery = .identityUnrecoverable
return
}
startupError = error.toUiText() startupError = error.toUiText()
#if DEBUG #if DEBUG
startupErrorDetail = error.technicalDetail startupErrorDetail = error.technicalDetail
@@ -72,4 +90,33 @@ final class AppModel: ObservableObject {
guard destination != self.destination else { return } guard destination != self.destination else { return }
self.destination = destination 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
startupError = nil
startupErrorDetail = nil
case .failure(let error):
AppLogger.error("lifecycle", "identity reset failed", 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 { var canRetryWithoutChangingInput: Bool {
guard let vni = self as? VnidropError else { return true } guard let vni = self as? VnidropError else { return true }
switch vni { switch vni {
case .FilesystemPermission, .DestinationExists, .InvalidInput: return false case .FilesystemPermission, .DestinationExists, .InvalidInput,
.SecureStorageMissing, .SecureStorageCorrupted:
return false
default: return true default: return true
} }
} }

View File

@@ -61,7 +61,13 @@ bytes through Kotlin memory.
work before durable cleanup; forget and block revoke affected relationships work before durable cleanup; forget and block revoke affected relationships
and active targeted work within their core operation. All four durably deny and active targeted work within their core operation. All four durably deny
reuse and perform idempotent payload/secret cleanup. 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 shipped Rust core domains. Graduating the KMP and Apple Saved-device UI and
their existing experimental preference gates is outside this release gate. 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 filesystem;
mod grant; mod grant;
mod handshake; mod handshake;
mod identity_recovery;
mod invitation; mod invitation;
mod logging; mod logging;
mod pairing_eligibility; mod pairing_eligibility;

View File

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

View File

@@ -63,7 +63,7 @@ impl VnidropCore {
self.runtime.handle().block_on(future) self.runtime.handle().block_on(future)
} }
fn initialize_with_identity_mode( pub(super) fn initialize_with_identity_mode(
app_data_dir: String, app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>, event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits, limits: CoreLimits,
@@ -497,6 +497,24 @@ impl VnidropCore {
Self::initialize_protected(app_data_dir, event_sink, limits, network_config) 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 { pub fn status(&self) -> RuntimeStatus {
self.block_on(self.inner.status()) 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 delivery;
mod facade; mod facade;
mod identity_recovery;
mod lifecycle; mod lifecycle;
mod provider; mod provider;
mod receive; 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( pub(crate) async fn protect(
&self, &self,
kind: SecretKind, kind: SecretKind,
@@ -872,6 +930,23 @@ impl FaultInjectingSecretStore {
handles.into_iter().next().unwrap() 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> { fn check_available(&self) -> Result<(), SecureSecretStoreError> {
match *self.failure.lock().unwrap() { match *self.failure.lock().unwrap() {
Some(ReferenceStoreFailure::Locked) => Err(SecureSecretStoreError::Locked), 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 share_files(",
"fn receive(", "fn receive(",
"saved_device_capabilities", "saved_device_capabilities",
"fn reset_unrecoverable_identity_with_limits_and_network_config(",
] { ] {
assert!( assert!(
facade.contains(required) || api.contains(required) || lib.contains(required), 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(); 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] #[test]
fn events_carry_stable_ids_and_monotonic_revisions() { fn events_carry_stable_ids_and_monotonic_revisions() {
let alice = ProtectedNode::new(); let alice = ProtectedNode::new();

View File

@@ -847,6 +847,10 @@ fn apple_public_bindings_omit_raw_secrets_and_generic_mutation() {
source.contains("initializeWithLimitsAndNetworkConfig"), source.contains("initializeWithLimitsAndNetworkConfig"),
"Swift bindings must expose standard protected initialization" "Swift bindings must expose standard protected initialization"
); );
assert!(
source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"),
"Swift bindings must expose explicit endpoint-identity recovery"
);
assert!( assert!(
source.contains("public struct SavedDeviceCapabilities") source.contains("public struct SavedDeviceCapabilities")
&& source.contains("public func 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); 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] #[test]
fn preapproval_offer_is_authenticated_without_ordinary_share_ticket() { fn preapproval_offer_is_authenticated_without_ordinary_share_ticket() {
let alice = ProtectedNode::new(); let alice = ProtectedNode::new();

View File

@@ -335,6 +335,66 @@
"ru": "Запуск…" "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": { "appearance_auto_description": {
"context": "Settings > Appearance: description for the System/auto option.", "context": "Settings > Appearance: description for the System/auto option.",
"translations": { "translations": {