Ensure that the current user is the device owner before linking a new device. (#5773)

This commit is contained in:
Doug
2026-06-26 16:24:18 +01:00
committed by GitHub
parent 3f3aa79a48
commit f3da459d16
23 changed files with 558 additions and 18 deletions
@@ -932,6 +932,7 @@
"screen_leave_space_title" = "Leave %1$@?";
"screen_leave_space_title_last_admin" = "You are the only admin for %1$@";
"screen_leave_space_title_last_owner" = "Transfer ownership";
"screen_link_new_device_authentication_reason_ios" = "Confirm it's you before linking a new device.";
"screen_link_new_device_desktop_scanning_title" = "Scan the QR code";
"screen_link_new_device_desktop_step1" = "Open %1$@ on a laptop or desktop computer";
"screen_link_new_device_desktop_step3" = "Scan the QR code with this device";
@@ -1,5 +1,5 @@
"NSCameraUsageDescription" = "To take pictures or videos and send them as a message Element X needs access to the camera.";
"NSFaceIDUsageDescription" = "Face ID is used to access your app.";
"NSFaceIDUsageDescription" = "Face ID is used to protect access to your account.";
"NSLocationAlwaysAndWhenInUseUsageDescription" = "To share your live location, Element X needs location access when the app is in the background.";
"NSLocationWhenInUseUsageDescription" = "Grant location access so that Element X can share your location.";
"NSMicrophoneUsageDescription" = "To record and send messages with audio, Element X needs to access the microphone.";
@@ -932,6 +932,7 @@
"screen_leave_space_title" = "Leave %1$@?";
"screen_leave_space_title_last_admin" = "You are the only admin for %1$@";
"screen_leave_space_title_last_owner" = "Transfer ownership";
"screen_link_new_device_authentication_reason_ios" = "Confirm it's you before linking a new device.";
"screen_link_new_device_desktop_scanning_title" = "Scan the QR code";
"screen_link_new_device_desktop_step1" = "Open %1$@ on a laptop or desktop computer";
"screen_link_new_device_desktop_step3" = "Scan the QR code with this device";
@@ -15,6 +15,7 @@ enum LinkNewDeviceFlowCoordinatorAction {
class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
private let navigationStackCoordinator: NavigationStackCoordinator
private let appLockService: AppLockServiceProtocol
private let flowParameters: CommonFlowParameters
private var cancellables = Set<AnyCancellable>()
@@ -25,8 +26,10 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
}
init(navigationStackCoordinator: NavigationStackCoordinator,
appLockService: AppLockServiceProtocol,
flowParameters: CommonFlowParameters) {
self.navigationStackCoordinator = navigationStackCoordinator
self.appLockService = appLockService
self.flowParameters = flowParameters
}
@@ -44,6 +47,7 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
private func presentLinkNewDeviceScreen() {
let coordinator = LinkNewDeviceScreenCoordinator(parameters: .init(clientProxy: flowParameters.userSession.clientProxy,
appLockService: appLockService,
orientationManager: flowParameters.appMediator.windowManager))
coordinator.actionsPublisher
.sink { [weak self] action in
@@ -181,6 +181,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
private func startLinkNewDeviceFlow() {
let stackCoordinator = NavigationStackCoordinator()
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: stackCoordinator,
appLockService: appLockService,
flowParameters: flowParameters)
flowCoordinator.actionsPublisher
.sink { [weak self] action in
+2
View File
@@ -2170,6 +2170,8 @@ internal nonisolated enum L10n {
}
/// Transfer ownership
internal static var screenLeaveSpaceTitleLastOwner: String { return L10n.tr("Localizable", "screen_leave_space_title_last_owner") }
/// Confirm it's you before linking a new device.
internal static var screenLinkNewDeviceAuthenticationReasonIos: String { return L10n.tr("Localizable", "screen_link_new_device_authentication_reason_ios") }
/// Scan the QR code
internal static var screenLinkNewDeviceDesktopScanningTitle: String { return L10n.tr("Localizable", "screen_link_new_device_desktop_scanning_title") }
/// Open %1$@ on a laptop or desktop computer
@@ -1001,6 +1001,48 @@ nonisolated class AppLockServiceMock: AppLockServiceProtocol, @unchecked Sendabl
return unlockWithBiometricsReturnValue
}
}
//MARK: - verifyDeviceOwner
private let verifyDeviceOwnerReasonCallsCountLock = NSLock()
private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingCallsCount = 0
var verifyDeviceOwnerReasonCallsCount: Int {
get { verifyDeviceOwnerReasonCallsCountLock.withLock { verifyDeviceOwnerReasonUnderlyingCallsCount } }
set { verifyDeviceOwnerReasonCallsCountLock.withLock { verifyDeviceOwnerReasonUnderlyingCallsCount = newValue } }
}
var verifyDeviceOwnerReasonCalled: Bool {
return verifyDeviceOwnerReasonCallsCount > 0
}
private let verifyDeviceOwnerReasonReceivedReasonLock = NSLock()
private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingReceivedReason: String?
var verifyDeviceOwnerReasonReceivedReason: String? {
get { verifyDeviceOwnerReasonReceivedReasonLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedReason } }
set { verifyDeviceOwnerReasonReceivedReasonLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedReason = newValue } }
}
private let verifyDeviceOwnerReasonReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingReceivedInvocations: [String] = []
var verifyDeviceOwnerReasonReceivedInvocations: [String] {
get { verifyDeviceOwnerReasonReceivedInvocationsLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedInvocations } }
set { verifyDeviceOwnerReasonReceivedInvocationsLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedInvocations = newValue } }
}
private let verifyDeviceOwnerReasonReturnValueLock = NSLock()
private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingReturnValue: AppLockDeviceOwnerResult!
var verifyDeviceOwnerReasonReturnValue: AppLockDeviceOwnerResult! {
get { verifyDeviceOwnerReasonReturnValueLock.withLock { verifyDeviceOwnerReasonUnderlyingReturnValue } }
set { verifyDeviceOwnerReasonReturnValueLock.withLock { verifyDeviceOwnerReasonUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var verifyDeviceOwnerReasonClosure: ((String) async -> AppLockDeviceOwnerResult)?
@concurrent func verifyDeviceOwner(reason: String) async -> AppLockDeviceOwnerResult {
verifyDeviceOwnerReasonCallsCountLock.withLock { verifyDeviceOwnerReasonUnderlyingCallsCount += 1 }
verifyDeviceOwnerReasonReceivedReason = reason
verifyDeviceOwnerReasonReceivedInvocationsLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedInvocations.append(reason) }
if let verifyDeviceOwnerReasonClosure = verifyDeviceOwnerReasonClosure {
return await verifyDeviceOwnerReasonClosure(reason)
} else {
return verifyDeviceOwnerReasonReturnValue
}
}
}
nonisolated class AppMediatorMock: AppMediatorProtocol, @unchecked Sendable {
var windowManager: WindowManagerProtocol {
@@ -16,6 +16,7 @@ enum LinkNewDeviceScreenCoordinatorAction {
struct LinkNewDeviceScreenCoordinatorParameters {
let clientProxy: ClientProxyProtocol
let appLockService: AppLockServiceProtocol
let orientationManager: OrientationManagerProtocol
}
@@ -31,7 +32,8 @@ final class LinkNewDeviceScreenCoordinator: CoordinatorProtocol {
}
init(parameters: LinkNewDeviceScreenCoordinatorParameters) {
viewModel = LinkNewDeviceScreenViewModel(clientProxy: parameters.clientProxy)
viewModel = LinkNewDeviceScreenViewModel(clientProxy: parameters.clientProxy,
appLockService: parameters.appLockService)
orientationManager = parameters.orientationManager
}
@@ -15,8 +15,14 @@ enum LinkNewDeviceScreenViewModelAction {
struct LinkNewDeviceScreenViewState: BindableState {
nonisolated enum Mode: Equatable {
enum ReadyState: Equatable {
case idle
case verifyingDeviceOwner
case generatingCode
}
case loading
case readyToLink(isGeneratingCode: Bool)
case readyToLink(ReadyState)
case error(QRCodeLoginState.ErrorState)
}
@@ -12,15 +12,19 @@ import SwiftUI
typealias LinkNewDeviceScreenViewModelType = StateStoreViewModelV2<LinkNewDeviceScreenViewState, LinkNewDeviceScreenViewAction>
class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDeviceScreenViewModelProtocol {
private enum Device { case mobileDevice, desktopComputer }
private let clientProxy: ClientProxyProtocol
private let appLockService: AppLockServiceProtocol
private let actionsSubject: PassthroughSubject<LinkNewDeviceScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<LinkNewDeviceScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(clientProxy: ClientProxyProtocol, initialState: LinkNewDeviceScreenViewState? = nil) {
init(clientProxy: ClientProxyProtocol, appLockService: AppLockServiceProtocol, initialState: LinkNewDeviceScreenViewState? = nil) {
self.clientProxy = clientProxy
self.appLockService = appLockService
if let initialState {
super.init(initialViewState: initialState)
@@ -41,9 +45,9 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
switch viewAction {
case .linkMobileDevice:
Task { await linkMobileDevice() }
Task { await verifyAndLink(.mobileDevice) }
case .linkDesktopComputer:
actionsSubject.send(.linkDesktopComputer)
Task { await verifyAndLink(.desktopComputer) }
case .errorAction(let action):
handleErrorAction(action)
case .dismiss:
@@ -55,14 +59,41 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
private func checkQRCodeLoginSupport() async {
if await clientProxy.isLoginWithQRCodeSupported {
state.mode = .readyToLink(isGeneratingCode: false)
state.mode = .readyToLink(.idle)
} else {
state.mode = .error(.notSupported)
}
}
private func verifyAndLink(_ device: Device) async {
state.mode = .readyToLink(.verifyingDeviceOwner)
switch await appLockService.verifyDeviceOwner(reason: L10n.screenLinkNewDeviceAuthenticationReasonIos) {
case .verified, .unavailable:
break
case .appLockPINRequired:
// Follow-up PR: present the App Lock PIN screen to verify the device owner.
return
case .cancelled, .unverified:
state.mode = .readyToLink(.idle)
return
case .error:
// TODO: Failure UX is pending product confirmation; using the generic error state for now.
state.mode = .error(.unknown)
return
}
switch device {
case .mobileDevice:
await linkMobileDevice() // Automatically sets the state.
case .desktopComputer:
actionsSubject.send(.linkDesktopComputer)
state.mode = .readyToLink(.idle)
}
}
private func linkMobileDevice() async {
state.mode = .readyToLink(isGeneratingCode: true)
state.mode = .readyToLink(.generatingCode)
let linkNewDeviceService = clientProxy.linkNewDeviceService()
@@ -77,7 +108,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
}
actionsSubject.send(.linkMobileDevice(progressPublisher))
state.mode = .readyToLink(isGeneratingCode: false)
state.mode = .readyToLink(.idle)
} catch {
// This is hard to share a mapping from the QRCodeLoginError with the
// QRCodeLoginScreen given that some of those are scan errors…
@@ -89,7 +120,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
switch action {
case .startOver:
// Reset to ready state to allow trying again.
state.mode = .readyToLink(isGeneratingCode: false)
state.mode = .readyToLink(.idle)
case .openSettings, .signInManually:
MXLog.error("Unexpected error action: \(action)")
actionsSubject.send(.dismiss)
@@ -59,7 +59,8 @@ struct LinkNewDeviceScreen: View {
}
.buttonStyle(.compound(.primary))
.disabled(true)
case .readyToLink(let isGeneratingCode):
case .readyToLink(let linkState):
let isGeneratingCode = linkState == .generatingCode
VStack(spacing: 16) {
Button { context.send(viewAction: .linkMobileDevice) } label: {
Label {
@@ -84,7 +85,7 @@ struct LinkNewDeviceScreen: View {
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer)
}
}
.disabled(isGeneratingCode)
.disabled(linkState != .idle)
case .error:
EmptyView() // Not reachable.
}
@@ -103,8 +104,9 @@ struct LinkNewDeviceScreen: View {
// MARK: - Previews
struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: false))
static let generatingViewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: true))
static let viewModel = makeViewModel(mode: .readyToLink(.idle))
static let verifyingViewModel = makeViewModel(mode: .readyToLink(.verifyingDeviceOwner))
static let generatingViewModel = makeViewModel(mode: .readyToLink(.generatingCode))
static let loadingViewModel = makeViewModel(mode: .loading)
static let unsupportedViewModel = makeViewModel(mode: .error(.notSupported))
static let unknownErrorViewModel = makeViewModel(mode: .error(.unknown))
@@ -114,13 +116,19 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
LinkNewDeviceScreen(context: viewModel.context)
}
.previewDisplayName("Ready")
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(isGeneratingCode: false) })
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(.idle) })
ElementNavigationStack {
LinkNewDeviceScreen(context: verifyingViewModel.context)
}
.previewDisplayName("Verifying")
.snapshotPreferences(expect: verifyingViewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(.verifyingDeviceOwner) })
ElementNavigationStack {
LinkNewDeviceScreen(context: generatingViewModel.context)
}
.previewDisplayName("Generating")
.snapshotPreferences(expect: generatingViewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(isGeneratingCode: true) })
.snapshotPreferences(expect: generatingViewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(.generatingCode) })
ElementNavigationStack {
LinkNewDeviceScreen(context: loadingViewModel.context)
@@ -142,6 +150,7 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
static func makeViewModel(mode: LinkNewDeviceScreenViewState.Mode) -> LinkNewDeviceScreenViewModel {
LinkNewDeviceScreenViewModel(clientProxy: ClientProxyMock(.init()),
appLockService: AppLockServiceMock.mock(),
initialState: .init(mode: mode,
showLinkDesktopComputerButton: true))
}
@@ -17,6 +17,7 @@ class AppLockService: AppLockServiceProtocol {
private let timer: AppLockTimer
private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics
private let deviceOwnerPolicy: LAPolicy = .deviceOwnerAuthentication
var isMandatory: Bool {
appSettings.appLockIsMandatory
@@ -163,6 +164,30 @@ class AppLockService: AppLockServiceProtocol {
}
}
func verifyDeviceOwner(reason: String) async -> AppLockDeviceOwnerResult {
let context = verificationContext()
var error: NSError?
guard context.canEvaluatePolicy(deviceOwnerPolicy, error: &error) else {
MXLog.warning("Device owner verification unavailable: \(String(describing: error))")
return isEnabled ? .appLockPINRequired : .unavailable
}
do {
guard try await context.evaluatePolicy(deviceOwnerPolicy, localizedReason: reason) else {
MXLog.warning("Device owner verification completed without success.")
return .unverified
}
return .verified
} catch LAError.userCancel, LAError.systemCancel {
MXLog.info("Device owner verification was cancelled.")
return .cancelled
} catch {
MXLog.warning("Device owner verification failed: \(error)")
return .error
}
}
// MARK: - Private
/// Queries the context for supported biometrics and enrolment state.
@@ -187,6 +212,12 @@ class AppLockService: AppLockServiceProtocol {
return context
}
/// Creates a fresh context for device owner verification so the user is always prompted.
private func verificationContext() -> LAContext {
// Keep using the injected context for tests etc.
type(of: context) == LAContext.self ? LAContext() : context
}
/// Shared logic for completing an unlock via a PIN or biometrics.
private func completeUnlock() {
timer.registerUnlock()
@@ -34,6 +34,22 @@ enum AppLockServiceBiometricResult {
case interrupted
}
/// The result of an attempt to verify that the user is the device owner.
enum AppLockDeviceOwnerResult {
/// The device owner was successfully verified.
case verified
/// The verification completed without verifying the user.
case unverified
/// Verification isn't available because a device passcode isn't set.
case unavailable
/// Verification isn't available, but an App Lock PIN is set and can be used instead.
case appLockPINRequired
/// The verification attempt was cancelled.
case cancelled
/// The verification couldn't be completed due to an error.
case error
}
protocol AppLockServiceProtocol: AnyObject {
/// The use of a PIN code is mandatory for this device.
var isMandatory: Bool { get }
@@ -72,6 +88,13 @@ protocol AppLockServiceProtocol: AnyObject {
/// Attempt to unlock the app using FaceID or TouchID.
func unlockWithBiometrics() async -> AppLockServiceBiometricResult
/// Verifies the device owner via the device passcode/biometrics, e.g. to gate a sensitive action.
///
/// This is a standalone identity check: it does **not** unlock the app or touch any App Lock
/// state (the grace-period timer or PIN attempt count).
/// - Parameter reason: The localised reason shown in the system prompt.
func verifyDeviceOwner(reason: String) async -> AppLockDeviceOwnerResult
/// The number of attempts the user had made to unlock with a PIN code.
///
/// Note: We don't track the biometric attempts as LAContext does that automatically.
@@ -15,7 +15,7 @@ final nonisolated class AppLockTimer {
/// Whether the timer considers the app to be locked or not. Always starts with a locked app.
///
/// Internally this value may be incorrect, always call `needsUnlock` to get the correct value.
/// Internally this value may be incorrect, always call ``computeLockState`` to get the correct value.
private var isLocked = true
/// The date when the app was last backgrounded whilst in an unlocked state.
private var lastUnlockedBackground: Date?
@@ -25,15 +25,26 @@ nonisolated class LAContextMock: LAContext {
internalEvaluatedPolicyDomainStateValue
}
var canEvaluatePolicyReturnValue: Bool?
override func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
let result = super.canEvaluatePolicy(policy, error: error)
let result = canEvaluatePolicyReturnValue ?? super.canEvaluatePolicy(policy, error: error)
updateInternalValues()
return result
}
var evaluatePolicyReturnValue: Bool!
var evaluatePolicyThrowableError: Error?
var evaluatePolicyCallsCount = 0
var evaluatePolicyCalled: Bool {
evaluatePolicyCallsCount > 0
}
override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool {
evaluatePolicyCallsCount += 1
updateInternalValues()
if let evaluatePolicyThrowableError {
throw evaluatePolicyThrowableError
}
return evaluatePolicyReturnValue
}
@@ -784,6 +784,7 @@ class MockScreen: Identifiable {
let navigationStackCoordinator = NavigationStackCoordinator()
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator,
appLockService: AppLockServiceMock.mock(),
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
bugReportService: BugReportServiceMock(.init()),
elementCallService: ElementCallServiceMock(.init()),