mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
fix(core): recover unrecoverable device identity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +261,39 @@ 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()
|
||||
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))
|
||||
@@ -265,9 +301,19 @@ private struct CoreStartingOverlay: View {
|
||||
.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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,13 @@ bytes through Kotlin memory.
|
||||
work before durable cleanup; forget and block revoke affected relationships
|
||||
and active targeted work within their core operation. All four durably deny
|
||||
reuse and perform idempotent payload/secret cleanup.
|
||||
7. Saved devices, relationships, pairing eligibility, and targeted transfers are
|
||||
7. A missing or corrupted endpoint credential remains fail-closed during normal
|
||||
startup. The explicit pre-start identity-reset constructor accepts only that
|
||||
unrecoverable state, atomically invalidates identity-bound relationships,
|
||||
eligibility, targeted authorization, and retry state, then creates a new
|
||||
protected identity. Transfer history and received files remain; old active
|
||||
Invitation transfers are stopped and Saved devices must be paired again.
|
||||
8. Saved devices, relationships, pairing eligibility, and targeted transfers are
|
||||
shipped Rust core domains. Graduating the KMP and Apple Saved-device UI and
|
||||
their existing experimental preference gates is outside this release gate.
|
||||
|
||||
|
||||
127
crates/vnidrop/src/identity_recovery.rs
Normal file
127
crates/vnidrop/src/identity_recovery.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
//! Explicit recovery for an unrecoverable protected endpoint identity.
|
||||
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::{error::VnidropError, util::now_ms};
|
||||
|
||||
/// Owns the cross-domain transaction that invalidates trust bound to an old identity.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct IdentityRecoveryStore {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl IdentityRecoveryStore {
|
||||
pub(crate) fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Preserve completed history and received artifacts, but revoke every
|
||||
/// capability that could authenticate or resume work as the lost identity.
|
||||
pub(crate) async fn reset_identity_bound_state(&self) -> Result<Vec<String>, VnidropError> {
|
||||
let now = now_ms();
|
||||
let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?;
|
||||
let handles = sqlx::query("SELECT handle FROM protected_secret_refs ORDER BY handle")
|
||||
.fetch_all(&mut *transaction)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?
|
||||
.into_iter()
|
||||
.map(|row| row.get::<String, _>(0))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE transfers
|
||||
SET status = CASE
|
||||
WHEN status = 'sharing' THEN 'stopped'
|
||||
WHEN status IN ('importing', 'receiving') THEN 'cancelled'
|
||||
ELSE status
|
||||
END,
|
||||
ticket = CASE
|
||||
WHEN status IN ('sharing', 'importing', 'receiving') THEN NULL
|
||||
ELSE ticket
|
||||
END,
|
||||
updated_at = CASE
|
||||
WHEN status IN ('sharing', 'importing', 'receiving') THEN ?1
|
||||
ELSE updated_at
|
||||
END
|
||||
WHERE status IN ('sharing', 'importing', 'receiving')
|
||||
"#,
|
||||
)
|
||||
.bind(now)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE receiver_requests
|
||||
SET status = CASE WHEN status = 'requested' THEN 'expired' ELSE 'failed' END,
|
||||
reason = 'device identity reset',
|
||||
responded_at = COALESCE(responded_at, ?1)
|
||||
WHERE status IN ('requested', 'accepted')
|
||||
"#,
|
||||
)
|
||||
.bind(now)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
sqlx::query("DELETE FROM pending_delivery_receipts")
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
|
||||
for table in [
|
||||
"targeted_accepted_offer_intents",
|
||||
"targeted_authorization_delivery_outbox",
|
||||
"targeted_completion_outbox",
|
||||
"targeted_payload_release_outbox",
|
||||
] {
|
||||
sqlx::query(&format!("DELETE FROM {table}"))
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
}
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE targeted_transfers
|
||||
SET state = CASE
|
||||
WHEN state IN (
|
||||
'preparing', 'offering', 'awaiting_approval', 'approved',
|
||||
'connecting', 'transferring', 'interrupted'
|
||||
) THEN 'cancelled'
|
||||
ELSE state
|
||||
END,
|
||||
blob_ticket = NULL,
|
||||
authorization_secret_handle = NULL,
|
||||
updated_at = CASE
|
||||
WHEN state IN (
|
||||
'preparing', 'offering', 'awaiting_approval', 'approved',
|
||||
'connecting', 'transferring', 'interrupted'
|
||||
) OR blob_ticket IS NOT NULL OR authorization_secret_handle IS NOT NULL
|
||||
THEN ?1
|
||||
ELSE updated_at
|
||||
END
|
||||
"#,
|
||||
)
|
||||
.bind(now)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
|
||||
for table in [
|
||||
"pairing_eligibilities",
|
||||
"device_relationships",
|
||||
"relationship_generation_tombstones",
|
||||
"protected_secret_refs",
|
||||
] {
|
||||
sqlx::query(&format!("DELETE FROM {table}"))
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
}
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(handles)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod event_hub;
|
||||
mod filesystem;
|
||||
mod grant;
|
||||
mod handshake;
|
||||
mod identity_recovery;
|
||||
mod invitation;
|
||||
mod logging;
|
||||
mod pairing_eligibility;
|
||||
|
||||
@@ -11,6 +11,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use crate::{
|
||||
blocked_devices::{self, BlockStore},
|
||||
device_relationship::DeviceRelationshipStore,
|
||||
identity_recovery::IdentityRecoveryStore,
|
||||
invitation::Repository,
|
||||
pairing_eligibility::PairingEligibilityStore,
|
||||
secure_secret::{self, SecretMetadataStore},
|
||||
@@ -32,6 +33,8 @@ pub(crate) struct AppDataStores {
|
||||
pub(crate) secrets: SecretMetadataStore,
|
||||
/// Identity-wide deny list.
|
||||
pub(crate) blocked: BlockStore,
|
||||
/// Explicit endpoint-identity reset transaction.
|
||||
pub(crate) identity_recovery: IdentityRecoveryStore,
|
||||
}
|
||||
|
||||
/// Create the profile pool, apply all domain schemas, return [`AppDataStores`].
|
||||
@@ -66,6 +69,7 @@ pub(crate) async fn open_all(app_data_dir: &Path) -> Result<AppDataStores> {
|
||||
relationships: DeviceRelationshipStore::new(pool.clone()),
|
||||
eligibility: PairingEligibilityStore::new(pool.clone()),
|
||||
secrets: SecretMetadataStore::new(pool.clone()),
|
||||
identity_recovery: IdentityRecoveryStore::new(pool.clone()),
|
||||
blocked: BlockStore::new(pool),
|
||||
invitation,
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ impl VnidropCore {
|
||||
self.runtime.handle().block_on(future)
|
||||
}
|
||||
|
||||
fn initialize_with_identity_mode(
|
||||
pub(super) fn initialize_with_identity_mode(
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
limits: CoreLimits,
|
||||
@@ -497,6 +497,24 @@ impl VnidropCore {
|
||||
Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
|
||||
}
|
||||
|
||||
/// Explicitly replace an endpoint identity whose protected credential is
|
||||
/// missing or corrupted, invalidate identity-bound trust, and initialize.
|
||||
/// A readable identity is never reset by this constructor.
|
||||
#[uniffi::constructor]
|
||||
pub fn reset_unrecoverable_identity_with_limits_and_network_config(
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
limits: CoreLimits,
|
||||
network_config: CoreNetworkConfig,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
Self::reset_unrecoverable_identity_protected(
|
||||
app_data_dir,
|
||||
event_sink,
|
||||
limits,
|
||||
network_config,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn status(&self) -> RuntimeStatus {
|
||||
self.block_on(self.inner.status())
|
||||
}
|
||||
|
||||
89
crates/vnidrop/src/runtime/identity_recovery.rs
Normal file
89
crates/vnidrop/src/runtime/identity_recovery.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Pre-start recovery for a missing or corrupted protected endpoint identity.
|
||||
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use super::{IdentityMode, VnidropCore};
|
||||
use crate::{
|
||||
api::{CoreEventSink, CoreLimits, CoreNetworkConfig},
|
||||
error::VnidropError,
|
||||
secure_secret::{lock_profile, platform_secret_store, SecretCustody, SecureSecretStore},
|
||||
};
|
||||
|
||||
impl VnidropCore {
|
||||
pub(super) fn reset_unrecoverable_identity_protected(
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
limits: CoreLimits,
|
||||
network_config: CoreNetworkConfig,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
let app_data_path = PathBuf::from(app_data_dir);
|
||||
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
|
||||
let app_data_path =
|
||||
std::fs::canonicalize(app_data_path).map_err(VnidropError::filesystem)?;
|
||||
let profile_lock = lock_profile(&app_data_path)?;
|
||||
let store = platform_secret_store(&app_data_path)?;
|
||||
Self::reset_unrecoverable_identity_with_store(
|
||||
app_data_path,
|
||||
event_sink,
|
||||
limits,
|
||||
network_config,
|
||||
store,
|
||||
profile_lock,
|
||||
)
|
||||
}
|
||||
|
||||
fn reset_unrecoverable_identity_with_store(
|
||||
app_data_path: PathBuf,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
limits: CoreLimits,
|
||||
network_config: CoreNetworkConfig,
|
||||
store: Arc<dyn SecureSecretStore>,
|
||||
profile_lock: crate::secure_secret::ProfileLock,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
let recovery_runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
recovery_runtime.block_on(async {
|
||||
let stores = crate::persistence::open_all(&app_data_path)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
let custody =
|
||||
SecretCustody::for_explicit_identity_reset(stores.secrets.clone(), store.clone());
|
||||
custody.require_unrecoverable_endpoint_identity().await?;
|
||||
let handles = stores
|
||||
.identity_recovery
|
||||
.reset_identity_bound_state()
|
||||
.await?;
|
||||
custody.delete_reset_handles(handles).await
|
||||
})?;
|
||||
Self::initialize_with_identity_mode(
|
||||
app_data_path.to_string_lossy().into_owned(),
|
||||
event_sink,
|
||||
limits,
|
||||
network_config,
|
||||
IdentityMode::Protected {
|
||||
store,
|
||||
profile_lock,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_unrecoverable_identity_with_test_secret_store(
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
store: Arc<dyn SecureSecretStore>,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
let app_data_path = PathBuf::from(&app_data_dir);
|
||||
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
|
||||
let profile_lock = crate::secure_secret::unlocked_profile_for_test(&app_data_path)?;
|
||||
Self::reset_unrecoverable_identity_with_store(
|
||||
app_data_path,
|
||||
event_sink,
|
||||
CoreLimits::default(),
|
||||
CoreNetworkConfig::default(),
|
||||
store,
|
||||
profile_lock,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
mod delivery;
|
||||
mod facade;
|
||||
mod identity_recovery;
|
||||
mod lifecycle;
|
||||
mod provider;
|
||||
mod receive;
|
||||
|
||||
@@ -389,6 +389,64 @@ impl SecretCustody {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn for_explicit_identity_reset(
|
||||
metadata: SecretMetadataStore,
|
||||
store: Arc<dyn SecureSecretStore>,
|
||||
) -> Self {
|
||||
Self::from_parts(metadata, store)
|
||||
}
|
||||
|
||||
/// Reject reset while the current identity remains readable. Missing or
|
||||
/// corrupted endpoint custody is unrecoverable without an explicit reset.
|
||||
pub(crate) async fn require_unrecoverable_endpoint_identity(&self) -> Result<(), VnidropError> {
|
||||
let endpoints = self
|
||||
.metadata
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
entry.kind == SecretKind::EndpointIdentity
|
||||
&& entry.state != SecretMetadataState::Disabled
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if endpoints.is_empty() {
|
||||
// Idempotent continuation after a reset transaction committed but
|
||||
// the process stopped before a replacement identity was activated.
|
||||
return Ok(());
|
||||
}
|
||||
for endpoint in endpoints {
|
||||
match self.store_get_raw(endpoint.handle).await? {
|
||||
Ok(material) => {
|
||||
if validate_material(
|
||||
SecretKind::EndpointIdentity,
|
||||
&material,
|
||||
endpoint.expected_identity.as_deref(),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return Err(VnidropError::InvalidInput {
|
||||
reason: "protected endpoint identity is still available".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {}
|
||||
Err(error) => return Err(map_store_error(error)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_reset_handles(
|
||||
&self,
|
||||
handles: Vec<String>,
|
||||
) -> Result<(), VnidropError> {
|
||||
for handle in handles {
|
||||
self.delete_if_present(&SecretHandle::from_stored(handle))
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn protect(
|
||||
&self,
|
||||
kind: SecretKind,
|
||||
@@ -872,6 +930,23 @@ impl FaultInjectingSecretStore {
|
||||
handles.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn endpoint_identity_handle_for_test(&self) -> SecretHandle {
|
||||
let handles = self
|
||||
.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|handle| handle.as_str().contains("/endpoint-identity/"))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
handles.len(),
|
||||
1,
|
||||
"expected exactly one protected endpoint identity"
|
||||
);
|
||||
handles.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
fn check_available(&self) -> Result<(), SecureSecretStoreError> {
|
||||
match *self.failure.lock().unwrap() {
|
||||
Some(ReferenceStoreFailure::Locked) => Err(SecureSecretStoreError::Locked),
|
||||
|
||||
@@ -62,6 +62,7 @@ fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_point
|
||||
"fn share_files(",
|
||||
"fn receive(",
|
||||
"saved_device_capabilities",
|
||||
"fn reset_unrecoverable_identity_with_limits_and_network_config(",
|
||||
] {
|
||||
assert!(
|
||||
facade.contains(required) || api.contains(required) || lib.contains(required),
|
||||
|
||||
@@ -682,6 +682,73 @@ fn saved_remote_name_and_local_label_survive_restart() {
|
||||
restarted.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_identity_reset_recovers_missing_credential_without_silent_replacement() {
|
||||
let alice = ProtectedNode::new();
|
||||
let bob = ProtectedNode::new();
|
||||
reach_saved(&alice, &bob, 90_063);
|
||||
let original_endpoint_id = alice.core.status().endpoint_id;
|
||||
let invitation_history = alice
|
||||
.core
|
||||
.list_transfers()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|transfer| (transfer.local_id, transfer.transfer_name))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!alice.core.list_saved_devices().unwrap().is_empty());
|
||||
|
||||
alice.core.shutdown();
|
||||
let endpoint_handle = alice.store.endpoint_identity_handle_for_test();
|
||||
alice.store.remove_for_test(&endpoint_handle);
|
||||
let app_data_dir = alice._data_dir.path().to_string_lossy().into_owned();
|
||||
assert!(matches!(
|
||||
VnidropCore::initialize_with_test_secret_store(
|
||||
app_data_dir.clone(),
|
||||
Arc::new(RecordingSink {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}),
|
||||
alice.store.clone(),
|
||||
),
|
||||
Err(crate::VnidropError::SecureStorageMissing { .. })
|
||||
));
|
||||
|
||||
let recovered = VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
|
||||
app_data_dir,
|
||||
Arc::new(RecordingSink {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}),
|
||||
alice.store.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(recovered.status().endpoint_id, original_endpoint_id);
|
||||
assert!(recovered.list_saved_devices().unwrap().is_empty());
|
||||
assert!(recovered.list_device_relationships().unwrap().is_empty());
|
||||
assert!(recovered.list_pairing_eligibilities().unwrap().is_empty());
|
||||
let recovered_history = recovered.list_transfers().unwrap();
|
||||
assert_eq!(recovered_history.len(), invitation_history.len());
|
||||
assert_eq!(recovered_history[0].status, "stopped");
|
||||
assert_eq!(
|
||||
(
|
||||
recovered_history[0].local_id.clone(),
|
||||
recovered_history[0].transfer_name.clone(),
|
||||
),
|
||||
invitation_history[0]
|
||||
);
|
||||
recovered.shutdown();
|
||||
|
||||
assert!(matches!(
|
||||
VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
|
||||
alice._data_dir.path().to_string_lossy().into_owned(),
|
||||
Arc::new(RecordingSink {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}),
|
||||
alice.store.clone(),
|
||||
),
|
||||
Err(crate::VnidropError::InvalidInput { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_carry_stable_ids_and_monotonic_revisions() {
|
||||
let alice = ProtectedNode::new();
|
||||
|
||||
@@ -847,6 +847,10 @@ fn apple_public_bindings_omit_raw_secrets_and_generic_mutation() {
|
||||
source.contains("initializeWithLimitsAndNetworkConfig"),
|
||||
"Swift bindings must expose standard protected initialization"
|
||||
);
|
||||
assert!(
|
||||
source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"),
|
||||
"Swift bindings must expose explicit endpoint-identity recovery"
|
||||
);
|
||||
assert!(
|
||||
source.contains("public struct SavedDeviceCapabilities")
|
||||
&& source.contains("public func savedDeviceCapabilities()"),
|
||||
|
||||
@@ -338,6 +338,57 @@ fn create_targeted_transfer_is_immutable_and_saved_only() {
|
||||
assert_eq!(listed.total_size, transfer.total_size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_reset_cancels_targeted_authorization_bound_to_the_lost_endpoint() {
|
||||
let mut alice = ProtectedNode::new();
|
||||
let bob = ProtectedNode::new();
|
||||
let bob_id = bob.core().status().endpoint_id;
|
||||
establish_saved(&alice, &bob, 10_002);
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("identity-bound.txt");
|
||||
std::fs::write(&source_path, b"identity-bound authorization").unwrap();
|
||||
let bob_core = bob.core();
|
||||
let accept = std::thread::spawn(move || {
|
||||
let offer = wait_for_pending_offer(&bob_core);
|
||||
bob_core
|
||||
.respond_to_targeted_offer(offer.transfer_id, true)
|
||||
.unwrap()
|
||||
});
|
||||
let transfer = alice
|
||||
.core()
|
||||
.create_targeted_transfer(
|
||||
bob_id,
|
||||
vec![targeted_source(&source_path)],
|
||||
Some("identity-bound.txt".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
accept.join().unwrap(),
|
||||
crate::TargetedOfferResponse::Approved { .. }
|
||||
));
|
||||
|
||||
alice.core.take().unwrap().shutdown();
|
||||
let endpoint_handle = alice.secret_store.endpoint_identity_handle_for_test();
|
||||
alice.secret_store.remove_for_test(&endpoint_handle);
|
||||
let recovered = VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
|
||||
alice.data_dir.path().to_string_lossy().into_owned(),
|
||||
alice.sink.clone(),
|
||||
alice.secret_store.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let snapshot = recovered
|
||||
.get_targeted_transfer(transfer.id.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.state, TargetedTransferState::Cancelled);
|
||||
assert!(recovered
|
||||
.targeted_blob_ticket_for_test(transfer.id)
|
||||
.is_err());
|
||||
assert!(recovered.list_saved_devices().unwrap().is_empty());
|
||||
recovered.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preapproval_offer_is_authenticated_without_ordinary_share_ticket() {
|
||||
let alice = ProtectedNode::new();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user