diff --git a/Package.swift b/Package.swift index df81bc74..0cb8d735 100644 --- a/Package.swift +++ b/Package.swift @@ -54,6 +54,7 @@ let package = Package( dependencies: [ "AskKeySystem", "AskKeyBroker", + "AskKeyDevices", .product(name: "GRDB", package: "GRDB.swift"), ], path: "Sources/AskKeyVault", @@ -73,6 +74,7 @@ let package = Package( "AskKeyVault", "AskKeySystem", "AskKeyIntegrations", + "AskKeyDevices", ], path: "Sources/AskKeyAppKit", resources: [ @@ -97,7 +99,8 @@ let package = Package( ), .target( name: "AskKeyTestSupport", - dependencies: ["AskKeyAppKit", "AskKeyVault", "AskKeySystem", "AskKeyBroker"], + dependencies: ["AskKeyAppKit", "AskKeyVault", "AskKeySystem", "AskKeyBroker", "AskKeyDevices", + "AskKeyDevicesTestSupport"], path: "Tests/AskKeyTestSupport", swiftSettings: [.swiftLanguageMode(.v5)] ), @@ -138,14 +141,16 @@ let package = Package( ), .testTarget( name: "AskKeyVaultTests", - dependencies: ["AskKeyBroker", "AskKeyVault", "AskKeyHelper", "AskKeyUnitTestSupport"], + dependencies: ["AskKeyBroker", "AskKeyVault", "AskKeyHelper", "AskKeyUnitTestSupport", "AskKeyDevices", + "AskKeyDevicesTestSupport"], path: "Tests/AskKeyVaultTests", resources: [.copy("Fixtures")], swiftSettings: [.swiftLanguageMode(.v5)] ), .testTarget( name: "AskKeyAppTests", - dependencies: ["AskKeyAppKit", "AskKeyVault", "AskKeySystem", "AskKeyIntegrations", "AskKeyTestSupport"], + dependencies: ["AskKeyAppKit", "AskKeyVault", "AskKeySystem", "AskKeyIntegrations", "AskKeyTestSupport", + "AskKeyDevices", "AskKeyDevicesTestSupport"], path: "Tests/AskKeyAppTests", swiftSettings: [.swiftLanguageMode(.v5)] ), diff --git a/Sources/AskKeyAppKit/App/AppDelegate+BrokerLifecycle.swift b/Sources/AskKeyAppKit/App/AppDelegate+BrokerLifecycle.swift index b591d747..19a5ab68 100644 --- a/Sources/AskKeyAppKit/App/AppDelegate+BrokerLifecycle.swift +++ b/Sources/AskKeyAppKit/App/AppDelegate+BrokerLifecycle.swift @@ -38,6 +38,7 @@ extension AppDelegate { setupApprovalQueue() AppRuntimeState.configuration.prepareServices?() startBroker() + setupDeviceServices() didStart() return } @@ -61,6 +62,7 @@ extension AppDelegate { setupWindowBehavior() setupStatusItemMenu() setupSoftwareUpdates() + setupDeviceServices() NotificationCenter.default.addObserver( forName: .hotkeyShortcutChanged, object: nil, queue: .main ) { [weak self] note in diff --git a/Sources/AskKeyAppKit/App/AppDelegate+Devices.swift b/Sources/AskKeyAppKit/App/AppDelegate+Devices.swift new file mode 100644 index 00000000..139325b1 --- /dev/null +++ b/Sources/AskKeyAppKit/App/AppDelegate+Devices.swift @@ -0,0 +1,81 @@ +import AppKit +import AskKeyVault +import Observation + +extension AppDelegate { + /// Creates and starts the device services (ADR 0013). Without a factory + /// from the executable, the CloudKit backend exists only when + /// `CloudKitGate` allows it; otherwise Devices reports "not configured" + /// and no CloudKit object is created. + func setupDeviceServices() { + guard !ManagementAuthenticationSubprocess.isActive, DeviceServicesHost.coordinator == nil else { return } + let backend: (any DeviceServicesBackend)? + if let makeDeviceServices = AppRuntimeState.configuration.makeDeviceServices { + backend = makeDeviceServices() + } else { + backend = CloudKitGate.currentProcessDecision().containerIdentifier + .map { CloudKitDeviceServicesBackend(containerIdentifier: $0) } + } + let model = vault + let coordinator = DeviceCoordinator(environment: DeviceCoordinatorEnvironment( + vault: .shared, + backend: backend, + inboxState: VaultDeviceInboxStateStore(vault: .shared), + authenticate: { [weak model] reason in + await model?.deviceOwnerAuthentication(reason: reason) ?? .cancelled + }, + isManagementSessionActive: { [weak model] in + guard let model else { return false } + return model.hasManagementSession && !model.isLocked + } + )) + DeviceServicesHost.install(coordinator) + guard let backend else { return } + Vault.shared.setDeviceServicesStopHandler { [weak coordinator] in + // The Vault already refuses every later write of these services. + if Thread.isMainThread { + MainActor.assumeIsolated { coordinator?.stopForLocalErase() } + } else { + DispatchQueue.main.async { coordinator?.stopForLocalErase() } + } + } + observeManagementSessionForDevices() + if backend.registersForRemoteNotifications { + NSApp.registerForRemoteNotifications() + } + coordinator.start() + } + + func stopDeviceServices() { + DeviceServicesHost.coordinator?.stop() + } + + /// Locking management, its expiry or closing its window cancels an open + /// pairing session (ADR 0013 → Pairing, step 6). + private func observeManagementSessionForDevices() { + withObservationTracking { + _ = vault.hasManagementSession + _ = vault.isLocked + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + // Observe again first: cancelling sends a message and suspends. + self.observeManagementSessionForDevices() + if !self.vault.hasManagementSession || self.vault.isLocked { + await DeviceServicesHost.coordinator?.managementSessionEnded() + } + } + } + } + + /// A push is only a hint to fetch this Mac's inbox; nothing in it is + /// trusted or shown. + func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String: Any]) { + let hint = Dictionary(uniqueKeysWithValues: userInfo.map { (AnyHashable($0.key), $0.value) }) + DeviceServicesHost.coordinator?.handleRemoteNotification(hint) + } + + func application(_ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + NSLog("AskKey: remote notifications are unavailable; device messages are fetched by polling") + } +} diff --git a/Sources/AskKeyAppKit/App/AppDelegate+Termination.swift b/Sources/AskKeyAppKit/App/AppDelegate+Termination.swift index d3ea6ae6..4dc078fe 100644 --- a/Sources/AskKeyAppKit/App/AppDelegate+Termination.swift +++ b/Sources/AskKeyAppKit/App/AppDelegate+Termination.swift @@ -16,6 +16,7 @@ extension AppDelegate { func applicationWillTerminate(_ notification: Notification) { guard !ManagementAuthenticationSubprocess.isActive else { return } AppRuntimeState.configuration.willStop?() + stopDeviceServices() brokerServer?.stop() recycleBinCleanupTimer?.invalidate() timedAllowanceSessionObserver = nil diff --git a/Sources/AskKeyAppKit/AppRuntimeConfiguration.swift b/Sources/AskKeyAppKit/AppRuntimeConfiguration.swift index e8270c3e..6ff3477f 100644 --- a/Sources/AskKeyAppKit/AppRuntimeConfiguration.swift +++ b/Sources/AskKeyAppKit/AppRuntimeConfiguration.swift @@ -12,6 +12,10 @@ package struct AppRuntimeConfiguration { /// Only the production executable passes one; the App still creates the /// updater only when `SoftwareUpdateAvailability` allows it. package let makeSoftwareUpdater: SoftwareUpdaterFactory? + /// The device services' backend (ADR 0013). The production executable + /// passes none: the App then creates the CloudKit backend only when + /// `CloudKitGate` allows it. The E2E App passes a fake. + package let makeDeviceServices: DeviceServicesFactory? package init( makeViewModel: (() -> VaultViewModel)? = nil, @@ -19,7 +23,8 @@ package struct AppRuntimeConfiguration { didStart: (() -> Void)? = nil, willStop: (() -> Void)? = nil, startClient: (() -> Bool)? = nil, - makeSoftwareUpdater: SoftwareUpdaterFactory? = nil + makeSoftwareUpdater: SoftwareUpdaterFactory? = nil, + makeDeviceServices: DeviceServicesFactory? = nil ) { self.makeViewModel = makeViewModel self.prepareServices = prepareServices @@ -27,11 +32,14 @@ package struct AppRuntimeConfiguration { self.willStop = willStop self.startClient = startClient self.makeSoftwareUpdater = makeSoftwareUpdater + self.makeDeviceServices = makeDeviceServices } package static var production: Self { Self() } } +package typealias DeviceServicesFactory = @MainActor () -> any DeviceServicesBackend + extension VaultViewModel { package static func configured( languageMode: String, diff --git a/Sources/AskKeyAppKit/Devices/CloudKitGate.swift b/Sources/AskKeyAppKit/Devices/CloudKitGate.swift new file mode 100644 index 00000000..a3c95ba1 --- /dev/null +++ b/Sources/AskKeyAppKit/Devices/CloudKitGate.swift @@ -0,0 +1,49 @@ +import Foundation +import Security + +/// Decides whether this process may create the CloudKit transport (ADR 0013 +/// → CloudKit layout). Creating a `CKContainer` terminates an App that lacks +/// the entitlement, so Debug, E2E and CI builds never create one: only a +/// build whose Info.plist declares the container (`CloudKitContainerIdentifier`, +/// written by the CloudKit option of `scripts/build-app.sh`) and whose signed +/// entitlements contain that container and the CloudKit service qualifies. +/// Every other build reports Devices as not configured and creates no +/// CloudKit object. +package enum CloudKitGate { + package static let infoPlistKey = "CloudKitContainerIdentifier" + package static let containerEntitlement = "com.apple.developer.icloud-container-identifiers" + package static let servicesEntitlement = "com.apple.developer.icloud-services" + + package enum Decision: Equatable, Sendable { + case configured(containerIdentifier: String) + case notConfigured + + package var containerIdentifier: String? { + if case .configured(let identifier) = self { return identifier } + return nil + } + } + + /// The decision for an Info.plist and a reader of this process's signed + /// entitlements. The reader runs only when the Info.plist declares a + /// container. + package static func decide(infoDictionary: [String: Any], entitlement: (String) -> Any?) -> Decision { + guard let declared = infoDictionary[infoPlistKey] as? String, !declared.isEmpty else { return .notConfigured } + guard let containers = entitlement(containerEntitlement) as? [String], containers.contains(declared), + let services = entitlement(servicesEntitlement) as? [String], services.contains("CloudKit") else { + return .notConfigured + } + return .configured(containerIdentifier: declared) + } + + /// The decision for this process: its main bundle's Info.plist and its + /// own code signature's entitlements. + package static func currentProcessDecision() -> Decision { + decide(infoDictionary: Bundle.main.infoDictionary ?? [:], entitlement: signedEntitlement) + } + + private static func signedEntitlement(_ key: String) -> Any? { + guard let task = SecTaskCreateFromSelf(nil) else { return nil } + return SecTaskCopyValueForEntitlement(task, key as CFString, nil) + } +} diff --git a/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Devices.swift b/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Devices.swift new file mode 100644 index 00000000..1c601871 --- /dev/null +++ b/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Devices.swift @@ -0,0 +1,134 @@ +import AskKeyDevices +import AskKeyVault +import Foundation + +/// Delivery checks, renaming, removal and the identity reset. +extension DeviceCoordinator { + /// Sends a delivery check, after pairing and on request, then polls every + /// two seconds for up to a minute; a later reply within the check's + /// 10-minute lifetime still counts. A device stays "not reached yet" + /// until the first reply. + package func sendDeliveryCheck(to id: DeviceID) async { + guard let services, availability == .ready else { return } + let lifecycle = self.lifecycle + lastIssue = nil + do { + try await services.messenger.sendDeliveryCheck(to: id) + guard lifecycle == self.lifecycle else { return } + unreachableDevices.remove(id) + } catch DeviceTransportError.recipientUnreachable { + guard lifecycle == self.lifecycle else { return } + unreachableDevices.insert(id) + lastIssue = .deliveryCheckFailed + } catch DeviceIdentityError.unusable { + guard lifecycle == self.lifecycle else { return } + identityBecameUnusable() + } catch DeviceMessengerError.unknownDevice { + guard lifecycle == self.lifecycle else { return } + lastIssue = .deviceNotFound + } catch { + guard lifecycle == self.lifecycle else { return } + lastIssue = .deliveryCheckFailed + } + await publish() + startDeliveryPolling() + } + + func startDeliveryPolling() { + deliveryPollDeadline = now.addingTimeInterval(Self.deliveryPollDuration) + guard environment.automaticPolling, deliveryPollTask == nil else { return } + let sleep = environment.sleep + deliveryPollTask = Task { [weak self] in + while !Task.isCancelled { + try? await sleep(Self.deliveryPollInterval) + guard let self, !Task.isCancelled, let deadline = self.deliveryPollDeadline, self.now < deadline else { break } + await self.refresh() + } + self?.deliveryPollTask = nil + } + } + + /// Renames a device, in the management session. A rename prepared + /// against a row whose entry was removed or paired again since is dropped. + @discardableResult + package func renameDevice(_ row: PairedDeviceRow, to displayName: String) async -> Bool { + guard let services else { return false } + guard managementSessionIsOpen() else { + lastIssue = .managementRequired + return false + } + lastIssue = nil + let outcome: PairedDeviceChangeOutcome + do { + outcome = try await services.registry.apply(.rename(row.id, pairingID: row.pairingID, displayName: displayName)) + } catch { + lastIssue = .actionFailed + await publish() + return false + } + if outcome == .dropped { lastIssue = .deviceNotFound } + await publish() + return outcome == .applied + } + + /// Removes a device at once, in the management session: from then on its + /// messages are refused, any pairing session or delivery check with it + /// ends, and it gets an unpair notice. Removal only reduces trust, so it + /// needs no fresh authentication. + @discardableResult + package func removeDevice(_ id: DeviceID) async -> Bool { + guard let services else { return false } + guard managementSessionIsOpen() else { + lastIssue = .managementRequired + return false + } + let lifecycle = self.lifecycle + lastIssue = nil + var removed = false + do { + let noticeSent = try await services.messenger.removeDevice(id) + removed = true + if !noticeSent, lifecycle == self.lifecycle { lastIssue = .unpairNoticeNotSent } + } catch DeviceMessengerError.unknownDevice { + if lifecycle == self.lifecycle { lastIssue = .deviceNotFound } + } catch DeviceIdentityError.unusable { + // The device is off the list; only the notice could not be signed. + removed = true + if lifecycle == self.lifecycle { identityBecameUnusable() } + } catch { + if lifecycle == self.lifecycle { lastIssue = .actionFailed } + } + guard lifecycle == self.lifecycle else { return removed } + unreachableDevices.remove(id) + await publish() + return removed + } + + /// The one action an unusable identity or a refused list offers: deletes + /// this Mac's inbox when CloudKit is reachable, then the keys, the list + /// and the inbox state, so the person can pair again. Needs the + /// management session. + @discardableResult + package func resetIdentity() async -> Bool { + guard availability == .identityUnusable || availability == .listRefused else { return false } + guard managementSessionIsOpen() else { + lastIssue = .managementRequired + return false + } + let inbox = services?.inbox + stopServices() + let lifecycle = self.lifecycle + if let inbox { try? await inbox.removeInbox() } + guard lifecycle == self.lifecycle else { return false } + do { + try vault.resetDeviceIdentity() + } catch { + storageUnavailable(error) + return false + } + lastIssue = nil + storageFailureMessage = nil + availability = .ready + return true + } +} diff --git a/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Pairing.swift b/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Pairing.swift new file mode 100644 index 00000000..63d279bd --- /dev/null +++ b/Sources/AskKeyAppKit/Devices/DeviceCoordinator+Pairing.swift @@ -0,0 +1,360 @@ +import AskKeyDevices +import AskKeyVault +import Foundation + +/// Pairing as the initiator (ADR 0013 → Pairing). +extension DeviceCoordinator { + /// **Pair a Device…**, in the management session: creates the identity + /// on the first pairing (after checking the Secure Enclave and the + /// account), prepares the inbox and its subscriptions, opens a session + /// and shows its QR code, then polls about once a second while the + /// session is open. The open sheet keeps the management session from + /// expiring for idleness until the session ends or the sheet closes. + package func startPairing() async { + guard environment.backend != nil, pairing?.isActive != true else { return } + if services == nil, availability == .loading || availability == .storageUnavailable { + _ = openStoredServices() + } + switch availability { + case .ready: + break + case .secureEnclaveUnavailable: + return failPairing(.secureEnclaveUnavailable) + case .identityUnusable, .listRefused: + return failPairing(.identityUnusable) + case .storageUnavailable, .loading, .notConfigured: + return failPairing(.storageUnavailable) + } + guard managementSessionIsOpen() else { + pairing = .failed(.managementRequired) + return + } + pairingGeneration += 1 + let generation = pairingGeneration + pairing = .preparing + pairingFeedback = nil + pairingExpiresAt = nil + acquireExpiryHold() + do { + guard try await openServicesForPairing(generation: generation), generation == pairingGeneration, + let services else { return } + if account != .available { await refreshAccount() } + guard generation == pairingGeneration else { return } + guard account == .available else { return failPairing(.accountUnavailable(account)) } + do { + _ = try await services.inbox.prepareInbox() + } catch { + guard generation == pairingGeneration else { return } + return failPairing(.iCloudUnavailable) + } + guard generation == pairingGeneration, self.services === services else { return } + let qr = try await services.messenger.startPairing(displayName: environment.displayName()) + guard generation == pairingGeneration, self.services === services else { + await services.messenger.cancelPairing() + return + } + pairing = .showingQRCode(text: qr.text, expiresAt: qr.qrExpiry) + pairingExpiresAt = qr.qrExpiry.addingTimeInterval(Self.sessionExtension) + startPairingPolling() + } catch DeviceIdentityError.secureEnclaveUnavailable { + guard generation == pairingGeneration else { return } + availability = .secureEnclaveUnavailable + failPairing(.secureEnclaveUnavailable) + } catch DeviceIdentityError.unusable { + guard generation == pairingGeneration else { return } + identityBecameUnusable() + failPairing(.identityUnusable) + } catch DeviceIdentityError.keyCreationFailed { + guard generation == pairingGeneration else { return } + failPairing(.keyCreationFailed) + } catch { + guard generation == pairingGeneration else { return } + failPairing(.storageUnavailable) + } + } + + /// Opens the stored identity's services, or creates the identity: Secure + /// Enclave keys in memory, then the account check, and only with an + /// available account are the key blobs stored. Returns false when the + /// session cannot start; `pairing` says why. + private func openServicesForPairing(generation: Int) async throws -> Bool { + if openStoredServices() { return true } + guard availability == .ready, let backend = environment.backend else { + failPairing(availability == .identityUnusable ? .identityUnusable : .storageUnavailable) + return false + } + guard backend.isSecureEnclaveAvailable else { throw DeviceIdentityError.secureEnclaveUnavailable } + let created = try backend.createIdentity() + let inbox = backend.makeInbox(for: DeviceID(messagePublicKey: created.identity.messagePublicKey)) + let answer = await inbox.accountState(storedUserRecordName: nil) + guard generation == pairingGeneration else { return false } + guard case .available(let name) = answer else { + account = Self.status(of: answer) + failPairing(.accountUnavailable(account)) + return false + } + guard managementSessionIsOpen() else { + failPairing(.managementRequired) + return false + } + try vault.saveDeviceIdentityKeyBlobs(created.blobs) + let state = DeviceInboxState(listAccount: name) + try environment.inboxState.save(state) + guard openServices(identity: created.identity, inbox: inbox) else { + failPairing(.storageUnavailable) + return false + } + account = .available + await use(account: name, state: state) + return true + } + + /// The person typed the code the iPhone shows. Nothing here signs or + /// sends a confirmation; the third wrong code ends the session. + package func enterPairingCode(_ text: String) async { + guard case .enteringCode = pairing, let services else { return } + let generation = pairingGeneration + do { + try await services.messenger.enterPairingCode(text) + guard generation == pairingGeneration else { return } + pairingFeedback = nil + } catch DeviceMessengerError.invalidCodeFormat { + guard generation == pairingGeneration else { return } + pairingFeedback = .invalidCodeFormat + } catch DeviceMessengerError.wrongCode(let remaining) { + guard generation == pairingGeneration else { return } + pairingFeedback = .wrongCode(attemptsRemaining: remaining) + } catch { + guard generation == pairingGeneration else { return } + pairingFeedback = nil + } + await publish() + } + + /// Confirms an accepted code. The approval signature asks for system + /// authentication first; a cancelled or failed one sends nothing, is not + /// an attempt, and can be retried without entering the code again. The + /// device is added once the iPhone's verified confirmation is also held, + /// without asking again in this session. + package func confirmPairing() async { + guard case .codeAccepted = pairing, let services else { return } + let generation = pairingGeneration + pairingFeedback = nil + do { + try await services.messenger.confirmPairing(approvalReason: Vault.devicePairingReason) + } catch DeviceIdentityError.authenticationCancelled { + if generation == pairingGeneration { pairingFeedback = .authenticationCancelled } + } catch DeviceIdentityError.authenticationFailed, DeviceIdentityError.authenticationUnavailable { + if generation == pairingGeneration { pairingFeedback = .authenticationFailed } + } catch DeviceIdentityError.unusable { + if generation == pairingGeneration { identityBecameUnusable() } + } catch { + // The session ended or a prompt is already open; the phase shows it. + } + await publish() + } + + /// **Cancel**, closing the sheet, locking management or closing its + /// window. Ends a session that has not added the device and tells the + /// iPhone; a device already added stays. + package func cancelPairing() async { + guard pairing != nil else { return } + pairingGeneration += 1 + finishPairingSession() + pairing = nil + pairingExpiresAt = nil + pairingFeedback = nil + guard let services else { return } + await services.messenger.cancelPairing() + await publish() + } + + /// The management session ended (locked, expired or its window closed). + package func managementSessionEnded() async { + guard pairingIsLive else { + finishPairingSession() + return + } + await cancelPairing() + } + + /// Whether the sheet's session is still open: before the device was + /// added, or after it, until the session expires, because the iPhone may + /// still cancel and so undo the pairing (ADR 0013 → Pairing, step 7). + var pairingIsLive: Bool { + if pairing?.isActive == true { return true } + guard case .paired = pairing, let expiry = pairingExpiresAt else { return false } + return now < expiry + } + + /// Runs inside the approval signature: system authentication for the + /// accepted code, recorded with the Vault for this session's device. + func authorizePairingSignature(reason: String) async throws { + guard case .codeAccepted(let peer) = pairing, environment.isManagementSessionActive() else { + throw DeviceIdentityError.authenticationCancelled + } + let generation = pairingGeneration + isAuthenticatingPairing = true + let authenticate = environment.authenticate + let prompt = Task { @MainActor in await authenticate(reason) } + pairingAuthentication = prompt + let result = await prompt.value + if generation == pairingGeneration { + isAuthenticatingPairing = false + pairingAuthentication = nil + } + guard generation == pairingGeneration, !prompt.isCancelled, pairing == .codeAccepted(peer: peer), + environment.isManagementSessionActive() else { + throw DeviceIdentityError.authenticationCancelled + } + switch result { + case .authenticated(let authenticator): + do { + try vault.authorizeDevicePairing(with: peer.deviceID, using: authenticator) + } catch { + throw DeviceIdentityError.authenticationFailed + } + case .cancelled: + throw DeviceIdentityError.authenticationCancelled + case .failed: + throw DeviceIdentityError.authenticationFailed + } + } + + // MARK: Phase + + func applyPairingPhase(_ phase: PairingPhase?) { + guard let current = pairing, current != .preparing, let phase else { return } + guard current.isActive else { + // After pairing, only the iPhone undoing it changes what the sheet shows. + if case .paired = current, phase == .ended(.undoneByPeer) { + pairing = .ended(.undoneByPeer) + environment.log(.pairingEnded(.undoneByPeer)) + finishPairingSession() + } + return + } + switch phase { + case .showingQRCode(let qr): + pairing = .showingQRCode(text: qr.text, expiresAt: qr.qrExpiry) + case .waitingForResponder(let peer): + if case .waitingForIPhone(peer, _) = current { return } + pairing = .waitingForIPhone(peer: peer, since: now) + case .enteringCode(let peer, let remaining): + if case .enteringCode = current {} else { environment.log(.revealVerified) } + pairing = .enteringCode(peer: peer, attemptsRemaining: remaining) + case .codeAccepted(let peer): + pairing = .codeAccepted(peer: peer) + case .waitingForPeer(let peer): + pairing = .waitingForIPhoneConfirmation(peer: peer) + case .paired(let peer, _): + pairing = .paired(peer: peer) + pairingFeedback = nil + environment.log(.devicePaired) + // The add used the authorization. Polling and the hold on idle + // expiry go on while the sheet is open and the session can still + // be undone. + isAuthenticatingPairing = false + vault.endDevicePairingAuthorization() + deliveryCheckAfterPairing = peer.deviceID + case .ended(let reason): + pairing = .ended(reason) + environment.log(.pairingEnded(reason)) + finishPairingSession() + if reason == .identityUnusable { identityBecameUnusable() } + case .waitingForChallenge, .showingCode: + // Responder phases; the Mac is always the initiator. + break + } + } + + func failPairing(_ failure: PairingStartFailure) { + pairing = .failed(failure) + finishPairingSession() + } + + /// The session paired, ended or was left: stop its polling and drop its + /// hold on idle expiry and its authorization. + func finishPairingSession() { + pairingPollTask?.cancel() + pairingPollTask = nil + pairingAuthentication?.cancel() + pairingAuthentication = nil + releaseExpiryHold() + isAuthenticatingPairing = false + vault.endDevicePairingAuthorization() + } + + // MARK: Schedules + + func startPairingPolling() { + pairingPollTask?.cancel() + pairingPollTask = nil + guard environment.automaticPolling else { return } + let sleep = environment.sleep + pairingPollTask = Task { [weak self] in + while !Task.isCancelled { + guard let self, await self.pairingLoopStep() else { return } + try? await sleep(Self.pairingPollInterval) + } + } + } + + /// One turn of the sheet's polling loop. Returns whether to go on; when + /// the session can no longer change, also while this turn polled, its + /// polling and its hold on idle expiry end here. + func pairingLoopStep() async -> Bool { + await pairingTick() + guard !Task.isCancelled else { return false } + guard pairingIsLive else { + finishPairingSession() + return false + } + return true + } + + /// One tick of an open pairing sheet: keeps the management session open + /// for idleness, or cancels the session when it is gone, then polls. Once + /// the session can no longer change, its polling and hold end. + func pairingTick() async { + guard pairingIsLive else { + finishPairingSession() + return + } + guard managementSessionIsOpen() else { + await managementSessionEnded() + return + } + await refresh() + } + + /// Whether the App's management session is open, renewing the Vault's. + func managementSessionIsOpen() -> Bool { + guard environment.isManagementSessionActive() else { return false } + do { + try vault.requireDeviceManagementSession() + return true + } catch { + return false + } + } + + func acquireExpiryHold() { + if expiryHold == nil { expiryHold = SessionPolicy.holdIdleExpiry() } + } + + func releaseExpiryHold() { + expiryHold?.release() + expiryHold = nil + } + + static func status(of answer: CloudKitAccountState) -> DeviceAccountStatus { + switch answer { + case .available: return .available + case .accountChanged: return .accountChanged + case .noAccount: return .noAccount + case .restricted: return .restricted + case .temporarilyUnavailable: return .temporarilyUnavailable + } + } +} diff --git a/Sources/AskKeyAppKit/Devices/DeviceCoordinator.swift b/Sources/AskKeyAppKit/Devices/DeviceCoordinator.swift new file mode 100644 index 00000000..74665c96 --- /dev/null +++ b/Sources/AskKeyAppKit/Devices/DeviceCoordinator.swift @@ -0,0 +1,498 @@ +import AskKeyDevices +import AskKeyVault +import Foundation +import SystemConfiguration + +/// What the device services depend on, so tests and the E2E App can supply +/// their own. +@MainActor +package struct DeviceCoordinatorEnvironment { + package var vault: Vault + /// Nil when this build has no CloudKit configured. + package var backend: (any DeviceServicesBackend)? + package var inboxState: any DeviceInboxStateStore + /// System authentication for one pairing confirmation. + package var authenticate: @MainActor (String) async -> ManagementAuthenticationResult + /// Whether the person's management session is open in the App. + package var isManagementSessionActive: @MainActor () -> Bool + /// The name this Mac proposes to the iPhone. + package var displayName: @MainActor () -> String + package var log: @Sendable (DeviceServicesEvent) -> Void + /// Runs the polling schedules; tests turn it off and poll explicitly. + package var automaticPolling: Bool + package var sleep: @Sendable (TimeInterval) async throws -> Void + + package init( + vault: Vault, + backend: (any DeviceServicesBackend)?, + inboxState: any DeviceInboxStateStore, + authenticate: @escaping @MainActor (String) async -> ManagementAuthenticationResult, + isManagementSessionActive: @escaping @MainActor () -> Bool, + displayName: @escaping @MainActor () -> String = DeviceCoordinatorEnvironment.computerName, + log: @escaping @Sendable (DeviceServicesEvent) -> Void = DeviceServicesEvent.systemLog, + automaticPolling: Bool = true, + sleep: @escaping @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) } + ) { + self.vault = vault + self.backend = backend + self.inboxState = inboxState + self.authenticate = authenticate + self.isManagementSessionActive = isManagementSessionActive + self.displayName = displayName + self.log = log + self.automaticPolling = automaticPolling + self.sleep = sleep + } + + package static func computerName() -> String { + (SCDynamicStoreCopyComputerName(nil, nil) as String?) ?? "Mac" + } +} + +/// One identity's open services. Confined to the main actor with the coordinator. +@MainActor +final class DeviceServices { + let deviceID: DeviceID + let identity: PairingAuthorizingIdentity + let inbox: any DeviceInbox + let registry: PairedDeviceRegistry + var messenger: DeviceMessenger + /// The account whose inbox token `messenger` started from; nil until the + /// first account check. + var messengerAccount: String? + + init(identity: PairingAuthorizingIdentity, inbox: any DeviceInbox, registry: PairedDeviceRegistry, + messenger: DeviceMessenger) { + deviceID = identity.deviceID + self.identity = identity + self.inbox = inbox + self.registry = registry + self.messenger = messenger + } +} + +/// The Mac's device services (ADR 0013): it creates and restores the device +/// identity, runs pairing as the initiator, processes incoming messages at +/// launch, after a push hint and when Devices opens, runs delivery checks, +/// removes devices, follows the iCloud account, and publishes the state the +/// Devices section reads. It never exposes the pairing code. +@Observable +@MainActor +package final class DeviceCoordinator { + static let pairingPollInterval: TimeInterval = 1 + static let deliveryPollInterval: TimeInterval = 2 + static let deliveryPollDuration: TimeInterval = 60 + /// ADR 0013 → Pairing, step 1: the session expires 5 minutes after the QR code. + static let sessionExtension: TimeInterval = 5 * 60 + + package internal(set) var availability: DeviceServicesAvailability + /// Whether this Mac has its device identity and its services are open. + package internal(set) var hasIdentity = false + package internal(set) var account: DeviceAccountStatus = .unknown + package internal(set) var devices: [PairedDeviceRow] = [] + package internal(set) var pairing: PairingSheetState? + /// When the open pairing session ends whether or not it succeeded. + package internal(set) var pairingExpiresAt: Date? + package internal(set) var pairingFeedback: PairingFeedback? + package internal(set) var isAuthenticatingPairing = false + package internal(set) var lastIssue: DeviceActionIssue? + package internal(set) var storageFailureMessage: String? + + @ObservationIgnored let environment: DeviceCoordinatorEnvironment + @ObservationIgnored var services: DeviceServices? + /// Changes whenever the services stop or are replaced, so work that + /// awaited can tell. + @ObservationIgnored var lifecycle = 0 + /// Changes whenever a pairing session starts or is left. + @ObservationIgnored var pairingGeneration = 0 + @ObservationIgnored var expiryHold: SessionExpiryHold? + @ObservationIgnored var pollTask: Task? + @ObservationIgnored var pollRequested = false + @ObservationIgnored var pairingPollTask: Task? + @ObservationIgnored var deliveryPollTask: Task? + @ObservationIgnored var deliveryPollDeadline: Date? + @ObservationIgnored var accountTask: Task? + @ObservationIgnored var launchTask: Task? + /// Devices whose inbox a send found missing, until they are reached again. + @ObservationIgnored var unreachableDevices: Set = [] + @ObservationIgnored var listedDevices: [PairedDevice] = [] + /// The device a pairing just added, which gets a delivery check. + @ObservationIgnored var deliveryCheckAfterPairing: DeviceID? + /// The open system authentication prompt for the pairing confirmation; + /// it is cancelled when the session ends or is left. + @ObservationIgnored var pairingAuthentication: Task? + /// Changes with every account check, so only the latest one applies. + @ObservationIgnored var accountCheck = 0 + + package init(environment: DeviceCoordinatorEnvironment) { + self.environment = environment + availability = environment.backend == nil ? .notConfigured : .loading + } + + var vault: Vault { environment.vault } + var now: Date { environment.backend?.clock.now() ?? Date() } + + // MARK: Lifecycle + + /// At launch: restores a stored identity, checks the account, prepares + /// the inbox and processes incoming messages. A Mac without an identity + /// makes no CloudKit request. + package func start() { + guard let backend = environment.backend, launchTask == nil else { return } + let changes = backend.accountChanges() + accountTask = Task { [weak self] in + for await _ in changes { + await self?.accountMayHaveChanged() + } + } + launchTask = Task { [weak self] in + await self?.launch() + } + } + + func launch() async { + guard openStoredServices() else { return } + await refreshAccount() + await refresh() + } + + /// App termination: stops every schedule. + package func stop() { + accountTask?.cancel() + accountTask = nil + stopServices() + } + + /// A local erase is starting (ADR 0013 → Local erase): stops sessions, + /// polling, delivery checks and pending callbacks, and drops the keys and + /// the list held in memory. The Vault already refuses every later write + /// of these services. The inbox is deleted on a best-effort basis, + /// without waiting for the network. + package func stopForLocalErase() { + let inbox = services?.inbox + stopServices() + if let inbox { + Task.detached { try? await inbox.removeInbox() } + } + availability = environment.backend == nil ? .notConfigured : .ready + } + + /// Ends the open services: every task, the pairing session's hold and + /// authorization, and the identity and list in memory. + func stopServices() { + lifecycle += 1 + pairingGeneration += 1 + launchTask?.cancel() + pollTask?.cancel() + pollTask = nil + pairingPollTask?.cancel() + pairingPollTask = nil + deliveryPollTask?.cancel() + deliveryPollTask = nil + deliveryPollDeadline = nil + pairingAuthentication?.cancel() + pairingAuthentication = nil + releaseExpiryHold() + vault.endDevicePairingAuthorization() + services = nil + hasIdentity = false + pairing = nil + pairingExpiresAt = nil + pairingFeedback = nil + isAuthenticatingPairing = false + account = .unknown + listedDevices = [] + unreachableDevices = [] + deliveryCheckAfterPairing = nil + devices = [] + } + + /// The Devices section opened: re-reads the identity if it could not be + /// opened before, checks the account and processes incoming messages. + package func devicesOpened() async { + guard environment.backend != nil else { return } + if services == nil, availability != .identityUnusable { _ = openStoredServices() } + guard services != nil else { return } + await refreshAccount() + await refresh() + } + + /// A remote notification arrived. A hint for this Mac's inbox only makes + /// it fetch; nothing in the notification is trusted or shown. + package func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) { + guard let backend = environment.backend, let services, + backend.isPushHint(userInfo, for: services.deviceID) else { return } + Task { [weak self] in await self?.refresh() } + } + + // MARK: Services + + /// Restores the stored identity and opens its services. Returns whether + /// they are open; otherwise `availability` says why. + func openStoredServices() -> Bool { + if services != nil { return true } + guard let backend = environment.backend else { return false } + let blobs: DeviceIdentityKeyBlobs? + do { + blobs = try vault.storedDeviceIdentityKeyBlobs() + } catch DeviceStorageError.incompleteIdentity, DeviceStorageError.identityRefused { + identityBecameUnusable() + return false + } catch { + storageUnavailable(error) + return false + } + guard let blobs else { + availability = .ready + return false + } + let identity: any DeviceIdentity + do { + identity = try backend.restoreIdentity(from: blobs) + } catch { + // A blob that no longer restores, also on a Mac without a Secure + // Enclave, leaves an identity that has to be reset. + identityBecameUnusable() + return false + } + return openServices(identity: identity, inbox: backend.makeInbox(for: DeviceID(messagePublicKey: identity.messagePublicKey))) + } + + func openServices(identity base: any DeviceIdentity, inbox: any DeviceInbox) -> Bool { + guard let backend = environment.backend else { return false } + let registry: PairedDeviceRegistry + do { + registry = try vault.pairedDeviceRegistry() + } catch { + storageUnavailable(error) + return false + } + let identity = PairingAuthorizingIdentity(base: base) { [weak self] reason in + guard let self else { throw DeviceIdentityError.authenticationCancelled } + try await self.authorizePairingSignature(reason: reason) + } + let messenger = DeviceMessenger(identity: identity, platform: .macOS, registry: registry, transport: inbox, + clock: backend.clock) + services = DeviceServices(identity: identity, inbox: inbox, registry: registry, messenger: messenger) + lifecycle += 1 + hasIdentity = true + availability = .ready + storageFailureMessage = nil + return true + } + + func storageUnavailable(_ error: Error) { + availability = .storageUnavailable + storageFailureMessage = UserFacingCopy.message(for: error) + } + + /// A stored key no longer works: stop polling and pairing, keep the + /// services' inbox for the reset, and offer the reset. + func identityBecameUnusable() { + environment.log(.identityUnusable) + if pairing?.isActive == true { pairing = .ended(.identityUnusable) } + finishPairingSession() + deliveryPollTask?.cancel() + deliveryPollTask = nil + availability = .identityUnusable + } + + // MARK: Account + + func accountMayHaveChanged() async { + guard services != nil else { return } + await refreshAccount() + await refresh() + } + + /// Asks CloudKit for the account and compares it with the one the list + /// belongs to. + func refreshAccount() async { + guard let services, availability == .ready else { return } + accountCheck += 1 + let check = accountCheck + let lifecycle = self.lifecycle + let stored = (try? environment.inboxState.load()) ?? DeviceInboxState() + let answer = await services.inbox.accountState(storedUserRecordName: stored.listAccount) + // A check that started earlier and answered later describes an older account. + guard lifecycle == self.lifecycle, check == accountCheck else { return } + await apply(answer, stored: stored, check: check) + } + + func apply(_ answer: CloudKitAccountState, stored: DeviceInboxState, check: Int) async { + var state = stored + switch answer { + case .available(let name), .accountChanged(let name): + var isListAccount = true + if case .accountChanged = answer { isListAccount = false } + if state.listAccount == nil || !isListAccount { + // No account recorded, or another one: this account becomes the + // list's only while nothing is paired. + let lifecycle = self.lifecycle + let list = try? await services?.registry.list() + guard check == accountCheck, lifecycle == self.lifecycle else { return } + isListAccount = list?.devices.isEmpty == true + if isListAccount { + state.listAccount = name + try? environment.inboxState.save(state) + } + } + if isListAccount { + account = .available + } else { + if account != .accountChanged { environment.log(.accountChanged) } + account = .accountChanged + } + await use(account: name, state: state, check: check) + case .noAccount: + account = .noAccount + case .restricted: + account = .restricted + case .temporarilyUnavailable: + account = .temporarilyUnavailable + } + publishRows() + } + + /// Points the messenger at `name`'s inbox with that account's stored + /// token, and prepares the inbox there. A pairing session started under + /// another account ends: its messages are in the other account. + func use(account name: String, state: DeviceInboxState, check: Int? = nil) async { + guard let services, services.messengerAccount != name, let backend = environment.backend else { return } + if services.messengerAccount != nil, pairing?.isActive == true { + let lifecycle = self.lifecycle + await cancelPairing() + // A newer account check may have switched the messenger, and a new + // session started on it, while the cancellation was sent. + guard lifecycle == self.lifecycle, check.map({ $0 == accountCheck }) ?? true, + self.services === services, services.messengerAccount != name else { return } + } + let token = state.tokenAccount == name ? state.inboxToken.map(InboxChangeToken.init(data:)) : nil + services.messenger = DeviceMessenger(identity: services.identity, platform: .macOS, registry: services.registry, + transport: services.inbox, clock: backend.clock, inboxToken: token) + services.messengerAccount = name + let lifecycle = self.lifecycle + do { + _ = try await services.inbox.prepareInbox() + } catch { + guard lifecycle == self.lifecycle else { return } + environment.log(.inboxPreparationFailed) + } + } + + // MARK: Polling + + /// Processes the inbox now, or joins the poll already running and makes + /// it poll once more. + package func refresh() async { + if let running = pollTask { + pollRequested = true + await running.value + return + } + let task = Task { [weak self] in + guard let self else { return } + repeat { + self.pollRequested = false + await self.pollOnce() + } while self.pollRequested && !Task.isCancelled + } + pollTask = task + await task.value + if pollTask == task { pollTask = nil } + } + + func pollOnce() async { + guard let services, availability == .ready, account.isSignedIn, let account = services.messengerAccount else { + await publish() + return + } + let messenger = services.messenger + let lifecycle = self.lifecycle + do { + let report = try await messenger.poll() + let token = await messenger.inboxToken + guard lifecycle == self.lifecycle else { return } + for refusal in report.refusals { environment.log(.messageRefused(refusal)) } + if services.messenger === messenger, let token { saveToken(token, account: account) } + } catch DeviceIdentityError.unusable { + guard lifecycle == self.lifecycle else { return } + identityBecameUnusable() + } catch DeviceTransportError.notAuthenticated { + guard lifecycle == self.lifecycle else { return } + await refreshAccount() + } catch { + guard lifecycle == self.lifecycle else { return } + environment.log(.pollFailed) + } + await publish() + } + + /// Keeps the token after a fully processed poll, with its account. + func saveToken(_ token: InboxChangeToken, account: String) { + var state = (try? environment.inboxState.load()) ?? DeviceInboxState() + guard state.inboxToken != token.data || state.tokenAccount != account else { return } + state.inboxToken = token.data + state.tokenAccount = account + try? environment.inboxState.save(state) + } + + // MARK: Publishing + + /// Reads the list and the pairing phase from the messenger and publishes them. + func publish() async { + guard let services else { return } + let lifecycle = self.lifecycle + let messenger = services.messenger + let list: PairedDeviceList? + do { + list = try await services.registry.list() + } catch DeviceStorageError.listRefused { + guard lifecycle == self.lifecycle else { return } + availability = .listRefused + list = nil + } catch { + list = nil + } + let phase = await messenger.pairingPhase + guard lifecycle == self.lifecycle else { return } + if let list { + listedDevices = list.devices + unreachableDevices = unreachableDevices.filter { id in list.device(id) != nil } + publishRows() + } + if services.messenger === messenger { applyPairingPhase(phase) } + if let paired = deliveryCheckAfterPairing { + deliveryCheckAfterPairing = nil + await sendDeliveryCheck(to: paired) + } + } + + func publishRows() { + let reachable = account == .available + devices = listedDevices.map { device in + let reachability: DeviceReachability + if !reachable || unreachableDevices.contains(device.deviceID) { + reachability = .unreachable + } else if let reached = device.lastReachedAt { + reachability = .reached(at: reached) + } else { + reachability = .notReachedYet + } + return PairedDeviceRow(id: device.deviceID, pairingID: device.pairingID, displayName: device.displayName, + platform: device.platform, pairedAt: device.pairedAt, + lastDeliveryCheck: device.lastDeliveryCheck, reachability: reachability) + } + } +} + +/// The App's one coordinator, which the Devices section reads. The App +/// installs it at launch; it is nil in processes without device services. +@MainActor +package enum DeviceServicesHost { + package private(set) static var coordinator: DeviceCoordinator? + + static func install(_ coordinator: DeviceCoordinator) { + self.coordinator = coordinator + } +} diff --git a/Sources/AskKeyAppKit/Devices/DeviceServicesBackend.swift b/Sources/AskKeyAppKit/Devices/DeviceServicesBackend.swift new file mode 100644 index 00000000..e09ba939 --- /dev/null +++ b/Sources/AskKeyAppKit/Devices/DeviceServicesBackend.swift @@ -0,0 +1,157 @@ +import AskKeyDevices +import AskKeyVault +import CloudKit +import CryptoKit +import Foundation +import os + +/// What differs between the real device services and the ones the E2E App +/// and tests use: key creation, the inbox transport, the iCloud account and +/// the clock. The coordinator's logic is the same for all of them. The +/// production App creates `CloudKitDeviceServicesBackend` only behind +/// `CloudKitGate`; `AppRuntimeConfiguration.makeDeviceServices` replaces it. +package protocol DeviceServicesBackend: AnyObject, Sendable { + var clock: any DeviceClock { get } + var isSecureEnclaveAvailable: Bool { get } + /// Whether the App registers for remote notifications, which carry the + /// push hints of this backend's transport. + var registersForRemoteNotifications: Bool { get } + /// New keys with the Mac's protections; nothing is stored yet. + func createIdentity() throws -> (identity: any DeviceIdentity, blobs: DeviceIdentityKeyBlobs) + /// Throws `DeviceIdentityError.unusable` when a blob no longer restores. + func restoreIdentity(from blobs: DeviceIdentityKeyBlobs) throws -> any DeviceIdentity + func makeInbox(for device: DeviceID) -> any DeviceInbox + /// Yields whenever the iCloud account may have changed. + func accountChanges() -> AsyncStream + /// Whether a remote notification is a hint for `device`'s own inbox. A + /// hint only means "fetch now"; nothing in it is trusted or shown. + func isPushHint(_ userInfo: [AnyHashable: Any], for device: DeviceID) -> Bool +} + +/// One device's inbox: the message transport plus the account and inbox +/// calls `CloudKitDeviceTransport` makes. +package protocol DeviceInbox: DeviceTransport { + func prepareInbox() async throws -> InboxPreparation + func accountState(storedUserRecordName: String?) async -> CloudKitAccountState + func removeInbox() async throws +} + +extension CloudKitDeviceTransport: DeviceInbox {} + +/// The real backend: Secure Enclave keys and the CloudKit transport on the +/// container the gate allowed. +package final class CloudKitDeviceServicesBackend: DeviceServicesBackend { + /// ADR 0013 → Key access control, for the Mac: every key is usable after + /// the first unlock, on this device only, with no per-use authentication. + /// Each human step is gated in the App by the management session and + /// system authentication. + package static let macKeyProtections = DeviceKeyProtections( + message: DeviceKeyProtection(accessibility: .afterFirstUnlockThisDeviceOnly, authentication: .none), + agreement: DeviceKeyProtection(accessibility: .afterFirstUnlockThisDeviceOnly, authentication: .none), + approval: DeviceKeyProtection(accessibility: .afterFirstUnlockThisDeviceOnly, authentication: .none) + ) + + private let containerIdentifier: String + package let clock: any DeviceClock = SystemDeviceClock() + + package init(containerIdentifier: String) { + self.containerIdentifier = containerIdentifier + } + + package var isSecureEnclaveAvailable: Bool { SecureEnclaveDeviceIdentity.isAvailable } + package var registersForRemoteNotifications: Bool { true } + + package func createIdentity() throws -> (identity: any DeviceIdentity, blobs: DeviceIdentityKeyBlobs) { + let identity = try SecureEnclaveDeviceIdentity.create(protections: Self.macKeyProtections) + return (identity, identity.keyBlobs) + } + + package func restoreIdentity(from blobs: DeviceIdentityKeyBlobs) throws -> any DeviceIdentity { + try SecureEnclaveDeviceIdentity.restore(from: blobs) + } + + package func makeInbox(for device: DeviceID) -> any DeviceInbox { + CloudKitDeviceTransport(containerIdentifier: containerIdentifier, deviceID: device, platform: .macOS) + } + + package func accountChanges() -> AsyncStream { + CloudKitDeviceTransport.accountChanges() + } + + package func isPushHint(_ userInfo: [AnyHashable: Any], for device: DeviceID) -> Bool { + guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) else { return false } + return CloudKitInboxLayout.isPush(notification, for: device) + } +} + +/// Where the coordinator keeps the inbox token and the account it belongs to. +package protocol DeviceInboxStateStore: AnyObject { + func load() throws -> DeviceInboxState + func save(_ state: DeviceInboxState) throws +} + +/// The production store: a file in the library directory, through the Vault. +package final class VaultDeviceInboxStateStore: DeviceInboxStateStore { + private let vault: Vault + + package init(vault: Vault) { + self.vault = vault + } + + package func load() throws -> DeviceInboxState { try vault.loadDeviceInboxState() } + package func save(_ state: DeviceInboxState) throws { try vault.saveDeviceInboxState(state) } +} + +/// The Mac's identity as its messenger sees it. The Mac's approval key has +/// no per-use authentication (ADR 0013), so every approval signature first +/// asks for system authentication through `authorize`, which also records +/// it with the Vault for the pairing session. Only `confirmPairing` reaches +/// an approval signature, and only after an accepted code; a cancelled or +/// failed prompt throws and signs nothing. +final class PairingAuthorizingIdentity: DeviceIdentity, @unchecked Sendable { + private let base: any DeviceIdentity + private let authorize: @Sendable (String) async throws -> Void + + init(base: any DeviceIdentity, authorize: @escaping @Sendable (String) async throws -> Void) { + self.base = base + self.authorize = authorize + } + + var messagePublicKey: P256.Signing.PublicKey { base.messagePublicKey } + var agreementPublicKey: P256.KeyAgreement.PublicKey { base.agreementPublicKey } + var approvalPublicKey: P256.Signing.PublicKey { base.approvalPublicKey } + var deviceID: DeviceID { DeviceID(messagePublicKey: base.messagePublicKey) } + + func signWithMessageKey(_ data: Data) throws -> Data { + try base.signWithMessageKey(data) + } + + func signWithApprovalKey(_ data: Data, reason: String) async throws -> Data { + try await authorize(reason) + return try await base.signWithApprovalKey(data, reason: reason) + } + + func openHPKE(_ ciphertext: Data, encapsulatedKey: Data, info: Data, + preSharedKey: HPKEPreSharedKey?) throws -> Data { + try base.openHPKE(ciphertext, encapsulatedKey: encapsulatedKey, info: info, preSharedKey: preSharedKey) + } +} + +/// What the device services log. No event carries message content, codes, +/// names, keys, device IDs or account identifiers. +package enum DeviceServicesEvent: Hashable, Sendable { + /// The responder's reveal was verified; the Mac now asks for the code. + case revealVerified + case pairingEnded(PairingEndReason) + case devicePaired + case messageRefused(DeviceMessageRefusal) + case identityUnusable + case accountChanged + case inboxPreparationFailed + case pollFailed + + package static let systemLog: @Sendable (DeviceServicesEvent) -> Void = { event in + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "AskKeyAppKit", category: "Devices") + logger.notice("\(String(describing: event), privacy: .public)") + } +} diff --git a/Sources/AskKeyAppKit/Devices/DeviceServicesState.swift b/Sources/AskKeyAppKit/Devices/DeviceServicesState.swift new file mode 100644 index 00000000..a6792b81 --- /dev/null +++ b/Sources/AskKeyAppKit/Devices/DeviceServicesState.swift @@ -0,0 +1,135 @@ +import AskKeyDevices +import Foundation + +/// Whether the Devices section can pair, and if not, why (ADR 0013 → Mac App). +package enum DeviceServicesAvailability: Equatable, Sendable { + /// This build has no CloudKit configured: there is no Devices section + /// and no CloudKit object exists. + case notConfigured + case loading + /// Pairing is available; `DeviceCoordinator.hasIdentity` tells whether + /// this Mac already has its device identity. + case ready + /// This Mac has no Secure Enclave, so it cannot pair. + case secureEnclaveUnavailable + /// A stored key no longer works, for example after Migration Assistant. + /// Offer `resetIdentity()`, which deletes the keys and the list. + case identityUnusable + /// The stored paired-device list failed authentication. It is refused, + /// not repaired; offer `resetIdentity()`. + case listRefused + /// The library is not open, a local erase is pending, or the keychain + /// refused the device items (`DeviceCoordinator.storageFailureMessage`). + case storageUnavailable +} + +/// The iCloud account as the Devices section explains it. In every state +/// but `available`, every paired device shows as unreachable and the list is +/// kept (ADR 0013 → Removal and account changes). +package enum DeviceAccountStatus: Equatable, Sendable { + /// Not checked yet: this Mac has no identity, so it makes no CloudKit request. + case unknown + case available + /// Another account than the one the devices were paired in, including a + /// change while the App was not running. The person removes the devices + /// or signs back in; pairing waits until the list is empty. + case accountChanged + case noAccount + case restricted + case temporarilyUnavailable + + var isSignedIn: Bool { self == .available || self == .accountChanged } +} + +/// How a paired device is shown. +package enum DeviceReachability: Equatable, Sendable { + /// No delivery check between the two devices has succeeded since pairing, + /// so a pairing that only one side completed shows. + case notReachedYet + case reached(at: Date) + /// The account is not available, changed, or the device's inbox is gone. + case unreachable +} + +/// One row of the paired-device list. Platform and name are what the peer +/// said about itself; neither is presented as verified. +package struct PairedDeviceRow: Identifiable, Equatable, Sendable { + package let id: DeviceID + package let pairingID: PairingID + package let displayName: String + package let platform: DevicePlatform + package let pairedAt: Date + package let lastDeliveryCheck: DeliveryCheckResult? + package let reachability: DeviceReachability +} + +/// What the pairing sheet shows. The Mac never shows the pairing code: no +/// case carries it, and nothing shows whether the iPhone confirmed until the +/// person confirms on the Mac. +package enum PairingSheetState: Equatable, Sendable { + /// Creating the identity, the inbox and the session. + case preparing + /// Show this QR code until `expiresAt`, with the instruction to scan it + /// only from this window. + case showingQRCode(text: String, expiresAt: Date) + /// The QR code is hidden: a request was frozen and challenged, and the + /// iPhone's verified reveal has not arrived. Tell the person that if the + /// iPhone has shown a code for more than about 10 seconds while the Mac + /// still waits, they should cancel and pair again. + case waitingForIPhone(peer: PairingPeer, since: Date) + /// The reveal was verified: show the six-digit entry field and the + /// instruction to enter only the code on the iPhone's pairing screen. + case enteringCode(peer: PairingPeer, attemptsRemaining: Int) + /// The entered code was accepted; confirming asks for system + /// authentication, again after a cancelled or failed one. + case codeAccepted(peer: PairingPeer) + /// The Mac confirmed; the iPhone's confirmation has not arrived. + case waitingForIPhoneConfirmation(peer: PairingPeer) + case paired(peer: PairingPeer) + case ended(PairingEndReason) + /// The session could not start. + case failed(PairingStartFailure) + + var isActive: Bool { + switch self { + case .preparing, .showingQRCode, .waitingForIPhone, .enteringCode, .codeAccepted, .waitingForIPhoneConfirmation: + return true + case .paired, .ended, .failed: + return false + } + } +} + +package enum PairingStartFailure: Equatable, Sendable { + /// Pairing needs the management session. + case managementRequired + case secureEnclaveUnavailable + /// iCloud is signed out, restricted, unavailable, or changed while + /// devices from the other account are still listed. + case accountUnavailable(DeviceAccountStatus) + /// The inbox or its subscriptions could not be prepared. + case iCloudUnavailable + case identityUnusable + case storageUnavailable + case keyCreationFailed +} + +/// Feedback for the person's last pairing action. +package enum PairingFeedback: Equatable, Sendable { + /// Not six digits once white space is removed; not counted as an attempt. + case invalidCodeFormat + case wrongCode(attemptsRemaining: Int) + /// Cancelled; not an attempt, and confirming again needs no new entry. + case authenticationCancelled + case authenticationFailed +} + +/// A Devices action that did not complete. +package enum DeviceActionIssue: Equatable, Sendable { + case managementRequired + case deviceNotFound + case deliveryCheckFailed + /// The device was removed here, but the unpair notice could not be sent. + case unpairNoticeNotSent + case actionFailed +} diff --git a/Sources/AskKeyAppKit/SessionPolicy.swift b/Sources/AskKeyAppKit/SessionPolicy.swift index 2db60ee9..70e2a2cc 100644 --- a/Sources/AskKeyAppKit/SessionPolicy.swift +++ b/Sources/AskKeyAppKit/SessionPolicy.swift @@ -8,6 +8,10 @@ final class SessionPolicy { private var lockTimer: Timer? private var activeGeneration: UUID? private var expirationAction: (@MainActor () -> Void)? + private var activeTimeout: Double = 0 + /// The timer came due while idle expiry was held; the policy restarts its + /// full timeout when the last hold is released. + private var expiryDeferred = false private let scheduleTimer: ScheduleTimer private let enqueueExpiration: EnqueueExpiration @@ -42,10 +46,17 @@ final class SessionPolicy { let generation = UUID() activeGeneration = generation expirationAction = onExpire + activeTimeout = timeout let enqueueExpiration = enqueueExpiration lockTimer = scheduleTimer(timeout) { [weak self] _ in enqueueExpiration { [weak self] in guard let self, self.activeGeneration == generation else { return } + if Self.idleExpiryHolds > 0 { + // An open pairing sheet keeps the session from expiring for idleness. + self.expiryDeferred = true + Self.deferredPolicies.append(WeakSessionPolicy(policy: self)) + return + } let action = self.expirationAction // Retire this generation before calling client code, which may renew. self.cancel() @@ -59,5 +70,52 @@ final class SessionPolicy { lockTimer?.invalidate() lockTimer = nil expirationAction = nil + expiryDeferred = false + } + + // MARK: Idle expiry holds + + private struct WeakSessionPolicy { + weak var policy: SessionPolicy? + } + + private static var idleExpiryHolds = 0 + private static var deferredPolicies: [WeakSessionPolicy] = [] + + /// While any hold is open, no session policy expires for idleness; a + /// timer that comes due waits. Releasing the last hold restarts each + /// waiting policy's full timeout, the normal expiry. An open pairing + /// sheet holds one until its session ends or the sheet closes (ADR 0013 + /// → Pairing, step 6). + static func holdIdleExpiry() -> SessionExpiryHold { + idleExpiryHolds += 1 + return SessionExpiryHold() + } + + fileprivate static func releaseIdleExpiryHold() { + idleExpiryHolds = max(0, idleExpiryHolds - 1) + guard idleExpiryHolds == 0 else { return } + let waiting = deferredPolicies.compactMap(\.policy) + deferredPolicies = [] + for policy in waiting where policy.expiryDeferred { + guard let action = policy.expirationAction else { continue } + policy.renew(timeout: policy.activeTimeout, onExpire: action) + } + } + + static var isIdleExpiryHeld: Bool { idleExpiryHolds > 0 } +} + +/// One hold on idle expiry; `release()` is idempotent. +@MainActor +final class SessionExpiryHold { + private var released = false + + fileprivate init() {} + + func release() { + guard !released else { return } + released = true + SessionPolicy.releaseIdleExpiryHold() } } diff --git a/Sources/AskKeyVault/Keychain/AppKeyStore.swift b/Sources/AskKeyVault/Keychain/AppKeyStore.swift index 330b5455..fa503dc6 100644 --- a/Sources/AskKeyVault/Keychain/AppKeyStore.swift +++ b/Sources/AskKeyVault/Keychain/AppKeyStore.swift @@ -65,15 +65,15 @@ final class AppBoundKeyStore: AppKeyStore { try verify(data, service: appService, account: Self.account, context: context) try verify(data, service: pendingService, account: Self.account, context: context) } catch { - try delete(service: appService) + try delete(service: appService, account: Self.account) throw error } } } func loadAppKey() throws -> Data { try load(service: appService, missing: .missingAppKey) } - func deleteLegacyKey() throws { try delete(service: legacyService) } - func deletePendingKey() throws { try delete(service: pendingService) } - func deleteAppKey() throws { try delete(service: appService) } + func deleteLegacyKey() throws { try delete(service: legacyService, account: Self.account) } + func deletePendingKey() throws { try delete(service: pendingService, account: Self.account) } + func deleteAppKey() throws { try delete(service: appService, account: Self.account) } func loadAgentAccessPolicy() throws -> Data? { try withKeychain { context in @@ -116,6 +116,35 @@ final class AppBoundKeyStore: AppKeyStore { func deleteAgentAccessPolicy() throws { try delete(service: appService, account: Self.policyAccount) } + // Device items live under the App service with the same App-only access + // as the vault key (ADR 0013); none of these touches the vault-key item. + + func loadDeviceItem(_ account: DeviceKeychainAccount) throws -> Data? { + try withKeychain { context in + guard let item = try findItem(service: appService, account: account.rawValue, context: context) else { + return nil + } + return try context.read(item) + } + } + + func saveDeviceItem(_ data: Data, account: DeviceKeychainAccount) throws { + guard data.count <= DeviceKeychainAccount.maximumItemSize else { throw AppKeyStoreError.conflictingKey } + try withKeychain { context in + // Never update an existing item's value or access: only an item + // this save creates, with App-only access, receives the value. + if let existing = try findItem(service: appService, account: account.rawValue, context: context) { + try context.delete(existing) + } + try add(data, service: appService, account: account.rawValue, context: context) + try verify(data, service: appService, account: account.rawValue, context: context) + } + } + + func deleteDeviceItem(_ account: DeviceKeychainAccount) throws { + try delete(service: appService, account: account.rawValue) + } + private func saveAppBound(_ data: Data, service: String) throws { try withKeychain { context in if try findItem(service: service, account: Self.account, context: context) != nil { @@ -151,7 +180,7 @@ final class AppBoundKeyStore: AppKeyStore { } } - private func delete(service: String, account: String = AppBoundKeyStore.account) throws { + private func delete(service: String, account: String) throws { try withKeychain { context in if let item = try findItem(service: service, account: account, context: context) { try context.delete(item) } } @@ -164,6 +193,7 @@ final class AppBoundKeyStore: AppKeyStore { private func kind(service: String, account: String) -> AppKeychainItemKind { if account == Self.policyAccount { return .agentAccessPolicy } + if let device = DeviceKeychainAccount(rawValue: account) { return device.itemKind } if service == pendingService { return .pendingKey } if service == legacyService { return .legacyKey } return .vaultKey @@ -188,4 +218,9 @@ package protocol AppKeyStore: AnyObject { func loadAgentAccessPolicy() throws -> Data? func saveAgentAccessPolicy(_ data: Data) throws func deleteAgentAccessPolicy() throws + /// The device item, or nil when none is stored. + func loadDeviceItem(_ account: DeviceKeychainAccount) throws -> Data? + /// Replaces the device item with a new App-only item holding `data`. + func saveDeviceItem(_ data: Data, account: DeviceKeychainAccount) throws + func deleteDeviceItem(_ account: DeviceKeychainAccount) throws } diff --git a/Sources/AskKeyVault/Keychain/AppKeychainItemKind.swift b/Sources/AskKeyVault/Keychain/AppKeychainItemKind.swift index cd7ed0d5..43bde029 100644 --- a/Sources/AskKeyVault/Keychain/AppKeychainItemKind.swift +++ b/Sources/AskKeyVault/Keychain/AppKeychainItemKind.swift @@ -2,6 +2,7 @@ import Foundation package enum AppKeychainItemKind: Equatable, Sendable { case vaultKey, pendingKey, legacyKey, agentAccessPolicy + case deviceMessageKey, deviceAgreementKey, deviceApprovalKey, pairedDevices package var conflictMessage: String { switch self { @@ -13,6 +14,37 @@ package enum AppKeychainItemKind: Equatable, Sendable { return "Ask Key found a legacy vault-key item outside login.keychain-db or duplicate legacy vault-key items. Quit Ask Key. In Keychain Access, move the original Ask Key legacy key to the login keychain and remove only verified duplicates, then reopen Ask Key. Keep the original key." case .agentAccessPolicy: return "Ask Key found an agent-access-policy item outside login.keychain-db or duplicate policy items. Quit Ask Key. In Keychain Access, remove the conflicting Ask Key agent-access-policy items, then reopen Ask Key and use Resume Agent Access. Do not remove vault-key items." + case .deviceMessageKey: + return "Ask Key found a device-message-key item outside login.keychain-db or duplicate device-message-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-message-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items." + case .deviceAgreementKey: + return "Ask Key found a device-agreement-key item outside login.keychain-db or duplicate device-agreement-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-agreement-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items." + case .deviceApprovalKey: + return "Ask Key found a device-approval-key item outside login.keychain-db or duplicate device-approval-key items. Quit Ask Key. In Keychain Access, remove the Ask Key device-approval-key items that are not in the login keychain, then reopen Ask Key. If Devices then reports that this Mac's device identity is unusable, reset it and pair your devices again. Do not remove vault-key items." + case .pairedDevices: + return "Ask Key found a paired-devices item outside login.keychain-db or duplicate paired-devices items. Quit Ask Key. In Keychain Access, remove the conflicting Ask Key paired-devices items, then reopen Ask Key and pair your devices again. Do not remove vault-key items." } } } + +/// The device items under the existing App service (ADR 0013 → Identifiers): +/// the three Secure Enclave key blobs and the authenticated paired-device +/// list. Every device helper names its account explicitly. +package enum DeviceKeychainAccount: String, CaseIterable, Sendable { + case messageKey = "device-message-key" + case agreementKey = "device-agreement-key" + case approvalKey = "device-approval-key" + case pairedDevices = "paired-devices" + + package var itemKind: AppKeychainItemKind { + switch self { + case .messageKey: return .deviceMessageKey + case .agreementKey: return .deviceAgreementKey + case .approvalKey: return .deviceApprovalKey + case .pairedDevices: return .pairedDevices + } + } + + /// The largest item a store accepts: a key blob is a few hundred bytes + /// and a list of 8 devices a few kilobytes. + static let maximumItemSize = 65_536 +} diff --git a/Sources/AskKeyVault/Keychain/IsolatedAppKeyStore.swift b/Sources/AskKeyVault/Keychain/IsolatedAppKeyStore.swift index 83da2fa9..551c555d 100644 --- a/Sources/AskKeyVault/Keychain/IsolatedAppKeyStore.swift +++ b/Sources/AskKeyVault/Keychain/IsolatedAppKeyStore.swift @@ -46,8 +46,23 @@ final class IsolatedAppKeyStore: AppKeyStore { private var policyService: String { appService + "\0agent-access-policy" } - func loadAgentAccessPolicy() throws -> Data? { - let url = file(policyService) + private func deviceService(_ account: DeviceKeychainAccount) -> String { appService + "\0" + account.rawValue } + + func loadAgentAccessPolicy() throws -> Data? { try readItem(policyService) } + + func saveAgentAccessPolicy(_ data: Data) throws { try writeItem(data, service: policyService) } + + func loadDeviceItem(_ account: DeviceKeychainAccount) throws -> Data? { try readItem(deviceService(account)) } + + func saveDeviceItem(_ data: Data, account: DeviceKeychainAccount) throws { + try writeItem(data, service: deviceService(account)) + } + + func deleteDeviceItem(_ account: DeviceKeychainAccount) throws { try delete(deviceService(account)) } + + /// Reads an item of up to 64 KiB, or nil when none is stored. + private func readItem(_ service: String) throws -> Data? { + let url = file(service) guard FileManager.default.fileExists(atPath: url.path) else { return nil } try verifyFile(directory, isDirectory: true) try verifyFile(url) @@ -69,16 +84,17 @@ final class IsolatedAppKeyStore: AppKeyStore { return Data(bytes.prefix(count)) } - func saveAgentAccessPolicy(_ data: Data) throws { + /// Replaces an item of up to 64 KiB with a new file renamed into place. + private func writeItem(_ data: Data, service: String) throws { guard data.count <= 65_536 else { throw AppKeyStoreError.conflictingKey } if !FileManager.default.fileExists(atPath: directory.path) { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) } try verifyFile(directory, isDirectory: true) - let target = file(policyService) + let target = file(service) if FileManager.default.fileExists(atPath: target.path) { try verifyFile(target) } - let temporary = directory.appendingPathComponent("policy-\(UUID().uuidString).creating") + let temporary = directory.appendingPathComponent("item-\(UUID().uuidString).creating") let descriptor = temporary.path.withCString { Darwin.open($0, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, S_IRUSR | S_IWUSR) } diff --git a/Sources/AskKeyVault/Vault+Devices.swift b/Sources/AskKeyVault/Vault+Devices.swift new file mode 100644 index 00000000..08313d86 --- /dev/null +++ b/Sources/AskKeyVault/Vault+Devices.swift @@ -0,0 +1,405 @@ +import AskKeyDevices +import CryptoKit +import Darwin +import Foundation + +/// Why the Mac's device storage refused an operation (ADR 0013). +public enum DeviceStorageError: Error, Equatable, Sendable { + /// Some but not all of the three key items exist. The identity is + /// unusable; resetting it deletes the keys and the list. + case incompleteIdentity + /// A key item failed authentication: this library did not store it, for + /// example because another App planted it. The identity is refused, not + /// used; resetting it deletes the keys and the list. + case identityRefused + /// A device identity is already stored. It is never replaced, only reset. + case identityExists + /// The stored paired-device list failed authentication or does not + /// decode. It is refused, not repaired; resetting the identity deletes it. + case listRefused + /// A list change would add a device the person did not authorize, with + /// system authentication, in the current pairing session. + case pairingNotAuthorized + /// A local erase is running or was interrupted; device storage stays + /// closed until it finishes. + case eraseInProgress + /// The device services this storage was opened for were stopped by a + /// local erase or an identity reset; nothing they started can write. + case servicesStopped +} + +/// The device services' inbox position (ADR 0013 → Push is a hint), kept in +/// the library directory, so it belongs to this storage namespace and goes +/// with a local erase. Losing it only causes a full fetch. +public struct DeviceInboxState: Codable, Equatable, Sendable { + /// The iCloud account the paired-device list belongs to: the account + /// signed in when the identity was created, or one adopted while the list + /// was empty. Another signed-in account is an account change. + public var listAccount: String? + /// The inbox change token after the last fully processed poll. + public var inboxToken: Data? + /// The account `inboxToken` belongs to; a token of another account is dropped. + public var tokenAccount: String? + + public init(listAccount: String? = nil, inboxToken: Data? = nil, tokenAccount: String? = nil) { + self.listAccount = listAccount + self.inboxToken = inboxToken + self.tokenAccount = tokenAccount + } +} + +/// What the Vault keeps in memory for the device services: the storage +/// epoch, the epoch's only list registry, the pairing authorization, and the +/// handler that stops the services before a local erase. +final class VaultDeviceState: @unchecked Sendable { + private let lock = NSRecursiveLock() + private var epoch = 0 + private var registry: PairedDeviceRegistry? + private var authorizedPeer: DeviceID? + private var stopHandler: (@Sendable () -> Void)? + + func withLock(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body() + } + + var currentEpoch: Int { withLock { epoch } } + + func setStopHandler(_ handler: (@Sendable () -> Void)?) { withLock { stopHandler = handler } } + + func registry(_ make: (Int) -> PairedDeviceRegistry) -> PairedDeviceRegistry { + withLock { + if let registry { return registry } + let made = make(epoch) + registry = made + return made + } + } + + func authorize(_ peer: DeviceID?) { withLock { authorizedPeer = peer } } + + func isAuthorized(_ peer: DeviceID) -> Bool { withLock { authorizedPeer == peer } } + + /// Closes the current epoch: its store refuses every later operation and + /// the authorization is dropped. Returns the stop handler to call. + @discardableResult + func endEpoch() -> (@Sendable () -> Void)? { + withLock { + epoch += 1 + registry = nil + authorizedPeer = nil + return stopHandler + } + } + + func finishLocalErase() { endEpoch() } +} + +/// The keychain-backed list store of one storage epoch. Every load and save +/// goes through the Vault, which authenticates the item with a key derived +/// from the vault key and refuses an add the person did not authorize. +final class VaultPairedDeviceListStore: PairedDeviceListStore, @unchecked Sendable { + private weak var vault: Vault? + private let epoch: Int + + init(vault: Vault, epoch: Int) { + self.vault = vault + self.epoch = epoch + } + + func loadPairedDeviceList() throws -> PairedDeviceList { + guard let vault else { throw DeviceStorageError.servicesStopped } + return try vault.loadPairedDevices(epoch: epoch) + } + + func savePairedDeviceList(_ list: PairedDeviceList) throws { + guard let vault else { throw DeviceStorageError.servicesStopped } + try vault.savePairedDevices(list, epoch: epoch) + } +} + +/// The paired-device list item: the list's versioned serialization followed +/// by an HMAC-SHA256 tag over `AskKey paired devices v1` (with its NUL) and +/// those exact bytes, under a key derived from the vault key with +/// HKDF-SHA256, an empty salt and the info `AskKey paired devices key v1` +/// (with its NUL). The tag is checked before the bytes are decoded, and the +/// bytes are never re-encoded for the check (ADR 0013 → Paired-device list). +enum PairedDeviceListAuthentication { + static let tagLength = 32 + static let tagLabel = Data("AskKey paired devices v1\0".utf8) + static let keyInfo = Data("AskKey paired devices key v1\0".utf8) + + static func seal(_ list: PairedDeviceList, using vaultKey: SymmetricKey) throws -> Data { + let bytes = try list.serialized() + return bytes + Data(HMAC.authenticationCode(for: tagLabel + bytes, using: key(vaultKey))) + } + + static func open(_ item: Data, using vaultKey: SymmetricKey) throws -> PairedDeviceList { + guard item.count > tagLength else { throw DeviceStorageError.listRefused } + let bytes = Data(item.prefix(item.count - tagLength)) + let tag = Data(item.suffix(tagLength)) + guard HMAC.isValidAuthenticationCode(tag, authenticating: tagLabel + bytes, using: key(vaultKey)) else { + throw DeviceStorageError.listRefused + } + do { + return try PairedDeviceList(serialized: bytes) + } catch { + throw DeviceStorageError.listRefused + } + } + + private static func key(_ vaultKey: SymmetricKey) -> SymmetricKey { + HKDF.deriveKey(inputKeyMaterial: vaultKey, salt: Data(), info: keyInfo, outputByteCount: 32) + } +} + +/// A key item: the Secure Enclave blob followed by an HMAC-SHA256 tag over +/// `AskKey device key blob v1` (with its NUL), the account name (with a NUL) +/// and the blob, under a key derived from the vault key with HKDF-SHA256, +/// an empty salt and the info `AskKey device key blob key v1` (with its NUL). +/// Any process on this Mac can use a blob it holds, so an identity whose +/// items this library did not write, such as blobs another App planted +/// before the first pairing while keeping copies, is refused. +enum DeviceKeyBlobAuthentication { + static let tagLength = 32 + static let tagLabel = Data("AskKey device key blob v1\0".utf8) + static let keyInfo = Data("AskKey device key blob key v1\0".utf8) + + static func seal(_ blob: Data, account: DeviceKeychainAccount, using vaultKey: SymmetricKey) -> Data { + blob + Data(HMAC.authenticationCode(for: authenticated(blob, account), using: key(vaultKey))) + } + + static func open(_ item: Data, account: DeviceKeychainAccount, using vaultKey: SymmetricKey) throws -> Data { + guard item.count > tagLength else { throw DeviceStorageError.identityRefused } + let blob = Data(item.prefix(item.count - tagLength)) + guard HMAC.isValidAuthenticationCode(Data(item.suffix(tagLength)), + authenticating: authenticated(blob, account), + using: key(vaultKey)) else { + throw DeviceStorageError.identityRefused + } + return blob + } + + private static func authenticated(_ blob: Data, _ account: DeviceKeychainAccount) -> Data { + tagLabel + Data(account.rawValue.utf8) + Data([0]) + blob + } + + private static func key(_ vaultKey: SymmetricKey) -> SymmetricKey { + HKDF.deriveKey(inputKeyMaterial: vaultKey, salt: Data(), info: keyInfo, outputByteCount: 32) + } +} + +extension Vault { + /// The reason shown with the system authentication that adds a device. + /// The App's authentication prompt accepts only the reasons of its action + /// catalog, so pairing uses the management reason until the Devices + /// section adds its own reason and translation (#288). + public static let devicePairingReason = CredentialManagementCopy.manageReason + + private static let deviceKeyAccounts: [DeviceKeychainAccount] = [.messageKey, .agreementKey, .approvalKey] + + // MARK: Lifecycle + + /// The App's device services install the handler that stops them; a + /// local erase calls it before it deletes anything. + public func setDeviceServicesStopHandler(_ handler: (@Sendable () -> Void)?) { + deviceState.setStopHandler(handler) + } + + /// The first step of a local erase: no store of the current epoch writes + /// again, the pairing authorization is dropped, and the App stops its + /// sessions, polling, delivery checks and pending callbacks. + func stopDeviceServicesForLocalErase() { + deviceState.endEpoch()?() + } + + /// Deletes the list first, then the three keys, so an interruption never + /// leaves a list behind for a new identity. + func deleteDeviceItems(from keys: AppKeyStore) throws { + try keys.deleteDeviceItem(.pairedDevices) + for account in Self.deviceKeyAccounts { try keys.deleteDeviceItem(account) } + } + + // MARK: Identity + + /// The stored key blobs, or nil when this Mac has no identity. Needs no + /// management session: messages are verified in the background. Each + /// item's tag is checked before its blob is returned. + public func storedDeviceIdentityKeyBlobs() throws -> DeviceIdentityKeyBlobs? { + try withDeviceStorage { keys in + let items = try Self.deviceKeyAccounts.map { try keys.loadDeviceItem($0) } + if items.allSatisfy({ $0 == nil }) { return nil } + guard let message = items[0], let agreement = items[1], let approval = items[2] else { + throw DeviceStorageError.incompleteIdentity + } + let vaultKey = try requireKey() + return DeviceIdentityKeyBlobs( + message: try DeviceKeyBlobAuthentication.open(message, account: .messageKey, using: vaultKey), + agreement: try DeviceKeyBlobAuthentication.open(agreement, account: .agreementKey, using: vaultKey), + approval: try DeviceKeyBlobAuthentication.open(approval, account: .approvalKey, using: vaultKey)) + } + } + + /// Stores a new identity's key blobs, on the first pairing. Requires the + /// management session and refuses to replace a stored identity. A list + /// or inbox state left without an identity is deleted first. + public func saveDeviceIdentityKeyBlobs(_ blobs: DeviceIdentityKeyBlobs) throws { + try requireManagementSession() + try withDeviceStorage { keys in + for account in Self.deviceKeyAccounts where try keys.loadDeviceItem(account) != nil { + throw DeviceStorageError.identityExists + } + let vaultKey = try requireKey() + try keys.deleteDeviceItem(.pairedDevices) + try removeDeviceInboxStateFile() + do { + for (blob, account) in [(blobs.message, DeviceKeychainAccount.messageKey), (blobs.agreement, .agreementKey), + (blobs.approval, .approvalKey)] { + try keys.saveDeviceItem(DeviceKeyBlobAuthentication.seal(blob, account: account, using: vaultKey), + account: account) + } + } catch { + for account in Self.deviceKeyAccounts { try? keys.deleteDeviceItem(account) } + throw error + } + } + } + + /// Resets an unusable identity: closes the storage epoch, then deletes + /// the list, the three keys and the inbox state. Requires the management + /// session. The caller stops its device services first. + public func resetDeviceIdentity() throws { + try requireManagementSession() + deviceState.endEpoch() + try withDeviceStorage { keys in + try deleteDeviceItems(from: keys) + try removeDeviceInboxStateFile() + } + } + + // MARK: Paired-device list + + /// The current epoch's only registry, which commits every list change as + /// one serialized read, modify and write. Reading needs no management + /// session; adding a device needs `authorizeDevicePairing`. + public func pairedDeviceRegistry() throws -> PairedDeviceRegistry { + try withDeviceStorage { _ in + deviceState.registry { PairedDeviceRegistry(store: VaultPairedDeviceListStore(vault: self, epoch: $0)) } + } + } + + /// Records the system authentication for the current pairing session, + /// which lets one list change add `peer`. Requires the management + /// session; an authenticator that does not confirm stores nothing. + public func authorizeDevicePairing(with peer: DeviceID, using authenticator: ManagementAuthenticator) throws { + try requireManagementSession() + try authorizeManagement(authenticator, reason: Self.devicePairingReason) + deviceState.authorize(peer) + } + + /// The pairing session ended, was cancelled, or management closed. + public func endDevicePairingAuthorization() { + deviceState.authorize(nil) + } + + /// Renaming and removing a device need the management session; an open + /// pairing sheet also uses this to keep it from expiring for idleness. + public func requireDeviceManagementSession() throws { + try requireDeviceManagementSessionDroppingAuthorization() + } + + /// A management session that has ended also ends any pairing authorization. + private func requireDeviceManagementSessionDroppingAuthorization() throws { + do { + try requireManagementSession() + } catch { + deviceState.authorize(nil) + throw error + } + } + + func loadPairedDevices(epoch: Int) throws -> PairedDeviceList { + try withDeviceStorage(epoch: epoch) { keys in + try storedPairedDevices(keys: keys, vaultKey: requireKey()) + } + } + + /// Writes a list the registry prepared. A device that was not on the + /// stored list, or is there with another pairing ID, is an add: it needs + /// the management session and this pairing session's authorization for + /// that device, which the write then uses up. + func savePairedDevices(_ list: PairedDeviceList, epoch: Int) throws { + try withDeviceStorage(epoch: epoch) { keys in + let vaultKey = try requireKey() + let stored = try storedPairedDevices(keys: keys, vaultKey: vaultKey) + let added = list.devices.filter { device in + !stored.devices.contains { $0.deviceID == device.deviceID && $0.pairingID == device.pairingID } + } + if !added.isEmpty { + try requireDeviceManagementSessionDroppingAuthorization() + guard added.count == 1, deviceState.isAuthorized(added[0].deviceID) else { + throw DeviceStorageError.pairingNotAuthorized + } + } + try keys.saveDeviceItem(PairedDeviceListAuthentication.seal(list, using: vaultKey), account: .pairedDevices) + if !added.isEmpty { deviceState.authorize(nil) } + } + } + + private func storedPairedDevices(keys: AppKeyStore, vaultKey: SymmetricKey) throws -> PairedDeviceList { + guard let item = try keys.loadDeviceItem(.pairedDevices) else { return PairedDeviceList() } + return try PairedDeviceListAuthentication.open(item, using: vaultKey) + } + + // MARK: Inbox state + + public func loadDeviceInboxState() throws -> DeviceInboxState { + try withDeviceStorage { _ in + let url = deviceInboxStateURL + guard FileManager.default.fileExists(atPath: url.path) else { return DeviceInboxState() } + // A damaged file only causes a full fetch and a new account check. + return (try? JSONDecoder().decode(DeviceInboxState.self, from: Data(contentsOf: url))) ?? DeviceInboxState() + } + } + + public func saveDeviceInboxState(_ state: DeviceInboxState) throws { + try withDeviceStorage { _ in + let data = try JSONEncoder().encode(state) + let target = deviceInboxStateURL + let temporary = libraryDirectory.appendingPathComponent(".device-inbox-\(UUID().uuidString).creating") + guard FileManager.default.createFile(atPath: temporary.path, contents: data, + attributes: [.posixPermissions: 0o600]) else { + throw CocoaError(.fileWriteUnknown) + } + guard temporary.path.withCString({ source in target.path.withCString { Darwin.rename(source, $0) } }) == 0 else { + try? FileManager.default.removeItem(at: temporary) + throw CocoaError(.fileWriteUnknown) + } + } + } + + private var deviceInboxStateURL: URL { libraryDirectory.appendingPathComponent("device-inbox.json") } + + private func removeDeviceInboxStateFile() throws { + let url = deviceInboxStateURL + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } + + // MARK: Storage + + /// Runs one device storage operation under the device lock, after + /// checking that no local erase is pending and, for a list store, that + /// its epoch is still the current one. Uses only the device items; the + /// vault key comes from memory, never from its keychain item. + private func withDeviceStorage(epoch: Int? = nil, _ body: (AppKeyStore) throws -> T) throws -> T { + try deviceState.withLock { + if let epoch, epoch != deviceState.currentEpoch { throw DeviceStorageError.servicesStopped } + guard !FileManager.default.fileExists(atPath: localEraseJournalURL.path) else { + throw DeviceStorageError.eraseInProgress + } + return try body(policyKeyStore()) + } + } +} diff --git a/Sources/AskKeyVault/Vault+LocalLifecycle.swift b/Sources/AskKeyVault/Vault+LocalLifecycle.swift index a09e26ff..fd3bc6e5 100644 --- a/Sources/AskKeyVault/Vault+LocalLifecycle.swift +++ b/Sources/AskKeyVault/Vault+LocalLifecycle.swift @@ -54,6 +54,7 @@ extension Vault { } private func quiesceForLocalErase() throws { + stopDeviceServicesForLocalErase() _ = try agentAccessGate.beginExclusiveChange() quiesceAgentAccess() agentAccessGate.endExclusiveChange(paused: true) diff --git a/Sources/AskKeyVault/Vault.swift b/Sources/AskKeyVault/Vault.swift index bb31dbcd..2febb1f3 100644 --- a/Sources/AskKeyVault/Vault.swift +++ b/Sources/AskKeyVault/Vault.swift @@ -50,6 +50,8 @@ public final class Vault { private var _policyKeyStore: AppKeyStore? private var _agentAccessPolicyLatches = AgentAccessPolicyLatches() let agentAccessPolicyOwnership = AgentAccessPolicyOwnership() + /// Device services the local erase stops first (ADR 0013 → Local erase). + let deviceState = VaultDeviceState() private let pathsOverride: VaultBootstrapPaths? private let keyStoreOverride: AppKeyStore? var legacyAgentAccessRowsRemoved = false @@ -356,6 +358,7 @@ public final class Vault { stateLock.lock() _managementSessionExpiresAt = nil stateLock.unlock() + deviceState.authorize(nil) } func requireManagementSession() throws { @@ -528,6 +531,7 @@ public final class Vault { )] } for store in stores { + try deleteDeviceItems(from: store) try store.deleteAgentAccessPolicy() try store.deleteLegacyKey() try store.deletePendingKey() @@ -569,6 +573,7 @@ public final class Vault { } func finishLocalErase() { + deviceState.finishLocalErase() stateLock.lock() _key = nil _store?.bindCredentialAuthenticationKey(nil) diff --git a/Tests/AskKeyAppTests/AgentAccessPolicyTestSupport.swift b/Tests/AskKeyAppTests/AgentAccessPolicyTestSupport.swift index 0748a60d..78c883ce 100644 --- a/Tests/AskKeyAppTests/AgentAccessPolicyTestSupport.swift +++ b/Tests/AskKeyAppTests/AgentAccessPolicyTestSupport.swift @@ -82,6 +82,10 @@ final class AppPolicyMemoryStore: AppKeyStore { policy = data } func deleteAgentAccessPolicy() throws { policy = nil } + var deviceItems: [DeviceKeychainAccount: Data] = [:] + func loadDeviceItem(_ account: DeviceKeychainAccount) throws -> Data? { deviceItems[account] } + func saveDeviceItem(_ data: Data, account: DeviceKeychainAccount) throws { deviceItems[account] = data } + func deleteDeviceItem(_ account: DeviceKeychainAccount) throws { deviceItems[account] = nil } } extension Vault { diff --git a/Tests/AskKeyAppTests/CloudKitGateTests.swift b/Tests/AskKeyAppTests/CloudKitGateTests.swift new file mode 100644 index 00000000..a6adf07b --- /dev/null +++ b/Tests/AskKeyAppTests/CloudKitGateTests.swift @@ -0,0 +1,87 @@ +import AskKeyDevices +import Foundation +import XCTest +@testable import AskKeyAppKit +@testable import AskKeyVault + +/// The App creates the CloudKit transport only when its Info.plist declares +/// the container and its signed entitlements contain it (ADR 0013). These +/// tests check the gate's decision; none creates a container. +@MainActor +final class CloudKitGateTests: AskKeyAppTestCase { + private let container = "iCloud.com.sudohg.askkey" + + private func entitlements(containers: [String]?, services: [String]? = ["CloudKit"]) -> (String) -> Any? { + { key in + switch key { + case CloudKitGate.containerEntitlement: return containers + case CloudKitGate.servicesEntitlement: return services + default: return nil + } + } + } + + func testWithoutTheInfoPlistKeyNothingIsConfiguredAndNoEntitlementIsRead() { + var reads = 0 + let decision = CloudKitGate.decide(infoDictionary: ["CFBundleIdentifier": "com.sudohg.askkey.app"]) { _ in + reads += 1 + return [self.container] + } + XCTAssertEqual(decision, .notConfigured) + XCTAssertEqual(reads, 0) + XCTAssertEqual(CloudKitGate.decide(infoDictionary: [CloudKitGate.infoPlistKey: ""], + entitlement: entitlements(containers: [container])), .notConfigured) + } + + func testWithoutTheContainerInTheSignedEntitlementsNothingIsConfigured() { + let info: [String: Any] = [CloudKitGate.infoPlistKey: container] + XCTAssertEqual(CloudKitGate.decide(infoDictionary: info, entitlement: entitlements(containers: nil)), .notConfigured) + XCTAssertEqual(CloudKitGate.decide(infoDictionary: info, entitlement: entitlements(containers: ["iCloud.example.other"])), + .notConfigured) + XCTAssertEqual(CloudKitGate.decide(infoDictionary: info, entitlement: entitlements(containers: [container], services: nil)), + .notConfigured) + XCTAssertEqual(CloudKitGate.decide(infoDictionary: info, + entitlement: entitlements(containers: [container], services: ["CloudDocuments"])), + .notConfigured) + } + + func testDeclaredAndEntitledContainerIsConfigured() { + let decision = CloudKitGate.decide(infoDictionary: [CloudKitGate.infoPlistKey: container], + entitlement: entitlements(containers: [container])) + XCTAssertEqual(decision, .configured(containerIdentifier: container)) + XCTAssertEqual(decision.containerIdentifier, container) + } + + func testTheInfoPlistKeyIsTheOneTheBuildWritesAndAvoidsTheUpdaterTestingPrefix() { + XCTAssertEqual(CloudKitGate.infoPlistKey, "CloudKitContainerIdentifier") + XCTAssertFalse(CloudKitGate.infoPlistKey.hasPrefix(SoftwareUpdateAvailability.testingFlagPrefix)) + } + + func testThisTestProcessIsNotConfigured() { + XCTAssertEqual(CloudKitGate.currentProcessDecision(), .notConfigured) + } + + func testWithoutABackendDevicesReportsNotConfiguredAndDoesNothing() async throws { + let library = try AgentAccessPolicyTestLibrary() + defer { try? library.close() } + try library.vault.beginManagementSession(using: .allow) + let coordinator = DeviceCoordinator(environment: DeviceCoordinatorEnvironment( + vault: library.vault, backend: nil, inboxState: VaultDeviceInboxStateStore(vault: library.vault), + authenticate: { _ in .authenticated(.allow) }, isManagementSessionActive: { true }, + automaticPolling: false + )) + coordinator.start() + await coordinator.startPairing() + await coordinator.devicesOpened() + XCTAssertEqual(coordinator.availability, .notConfigured) + XCTAssertNil(coordinator.pairing) + XCTAssertFalse(coordinator.hasIdentity) + XCTAssertTrue(library.keys.deviceItems.isEmpty) + } + + func testTheMacPassesEachKeysProtectionExplicitly() { + let mac = DeviceKeyProtection(accessibility: .afterFirstUnlockThisDeviceOnly, authentication: .none) + XCTAssertEqual(CloudKitDeviceServicesBackend.macKeyProtections, + DeviceKeyProtections(message: mac, agreement: mac, approval: mac)) + } +} diff --git a/Tests/AskKeyAppTests/DeviceCoordinatorDevicesTests.swift b/Tests/AskKeyAppTests/DeviceCoordinatorDevicesTests.swift new file mode 100644 index 00000000..0967bea1 --- /dev/null +++ b/Tests/AskKeyAppTests/DeviceCoordinatorDevicesTests.swift @@ -0,0 +1,313 @@ +import AskKeyDevicesTestSupport +import AskKeyTestSupport +import Foundation +import XCTest +@testable import AskKeyAppKit +@testable import AskKeyDevices +@testable import AskKeyVault + +/// Delivery checks, removal, the iCloud account, the unusable identity, push +/// hints and local erase in the Mac's device services (ADR 0013). +@MainActor +final class DeviceCoordinatorDevicesTests: AskKeyAppTestCase { + private var harness: DeviceCoordinatorHarness! + + override func setUp() async throws { + harness = try DeviceCoordinatorHarness() + await harness.coordinator.launch() + } + + override func tearDown() async throws { + try harness.close() + harness = nil + } + + func testMacThatNeverPairedMakesNoCloudKitRequest() async throws { + await harness.coordinator.devicesOpened() + await harness.coordinator.refresh() + XCTAssertEqual(harness.backend.accountChecks, 0) + XCTAssertTrue(harness.backend.preparedInboxes.isEmpty) + XCTAssertTrue(harness.transport.sentEnvelopes.isEmpty) + XCTAssertEqual(harness.coordinator.account, .unknown) + } + + func testDeliveryCheckUpdatesLastReached() async throws { + try await harness.pairPhone() + let coordinator = harness.coordinator + guard case .reached(let first) = try XCTUnwrap(coordinator.devices.first).reachability else { + return XCTFail("the first delivery check reached the iPhone") + } + harness.clock.advance(by: 120) + await coordinator.sendDeliveryCheck(to: harness.phone.deviceID) + guard case .pending = try XCTUnwrap(coordinator.devices.first).lastDeliveryCheck else { + return XCTFail("the check is pending until the reply") + } + try await harness.phone.poll() + await coordinator.refresh() + let row = try XCTUnwrap(coordinator.devices.first) + guard case .replied = row.lastDeliveryCheck, case .reached(let latest) = row.reachability else { + return XCTFail("the reply updates the device") + } + XCTAssertEqual(latest.timeIntervalSince(first), 120, accuracy: 1) + } + + func testRemovalRefusesLaterMessagesAtOnceAndDeliversAnUnpairNotice() async throws { + try await harness.pairPhone() + let coordinator = harness.coordinator + let mac = try XCTUnwrap(harness.macDeviceID) + let outcome1 = await coordinator.removeDevice(harness.phone.deviceID) + XCTAssertTrue(outcome1) + XCTAssertTrue(coordinator.devices.isEmpty) + XCTAssertEqual(harness.macSent(.unpairNotice).count, 1) + + // The iPhone still lists the Mac and sends before it reads the notice. + try await harness.phone.messenger.sendDeliveryCheck(to: mac) + await coordinator.refresh() + XCTAssertTrue(harness.script.loggedEvents.contains(.messageRefused(.unknownSender))) + XCTAssertTrue(coordinator.devices.isEmpty) + + try await harness.phone.poll() + let phoneList = try await harness.phone.pairedDevices() + XCTAssertTrue(phoneList.isEmpty, "the iPhone removed the Mac after the notice") + } + + func testRemovalAndRenamingNeedTheManagementSession() async throws { + try await harness.pairPhone() + let coordinator = harness.coordinator + let row = try XCTUnwrap(coordinator.devices.first) + harness.script.managementActive = false + let outcome2 = await coordinator.removeDevice(row.id) + XCTAssertFalse(outcome2) + let outcome3 = await coordinator.renameDevice(row, to: "Renamed") + XCTAssertFalse(outcome3) + XCTAssertEqual(coordinator.lastIssue, .managementRequired) + XCTAssertEqual(coordinator.devices.map(\.displayName), ["Synthetic Phone"]) + harness.script.managementActive = true + let outcome4 = await coordinator.renameDevice(row, to: "Renamed Phone") + XCTAssertTrue(outcome4) + XCTAssertEqual(coordinator.devices.map(\.displayName), ["Renamed Phone"]) + } + + func testListChangePreparedBeforeARemovalOrANewPairingDoesNotApply() async throws { + try await harness.pairPhone() + let coordinator = harness.coordinator + let stale = try XCTUnwrap(coordinator.devices.first) + let outcome5 = await coordinator.removeDevice(stale.id) + XCTAssertTrue(outcome5) + let outcome6 = await coordinator.renameDevice(stale, to: "Late Rename") + XCTAssertFalse(outcome6) + XCTAssertEqual(coordinator.lastIssue, .deviceNotFound) + XCTAssertTrue(coordinator.devices.isEmpty, "the removed device is not restored") + + try await harness.phone.poll() + try await harness.pairPhone() + let current = try XCTUnwrap(coordinator.devices.first) + XCTAssertNotEqual(current.pairingID, stale.pairingID) + let outcome7 = await coordinator.renameDevice(stale, to: "Late Rename") + XCTAssertFalse(outcome7) + XCTAssertEqual(coordinator.devices.map(\.displayName), ["Synthetic Phone"]) + } + + func testAccountChangeMarksDevicesUnreachableAlsoWhenItHappenedWhileTheAppWasNotRunning() async throws { + try await harness.pairPhone() + guard case .reached = try XCTUnwrap(harness.coordinator.devices.first).reachability else { + return XCTFail("reached before the change") + } + + harness.backend.accountState = .available(userRecordName: "_synthetic-account-2") + await harness.coordinator.accountMayHaveChanged() + XCTAssertEqual(harness.coordinator.account, .accountChanged) + XCTAssertEqual(harness.coordinator.devices.map(\.reachability), [.unreachable]) + XCTAssertTrue(harness.script.loggedEvents.contains(.accountChanged)) + await harness.coordinator.startPairing() + XCTAssertEqual(harness.coordinator.pairing, .failed(.accountUnavailable(.accountChanged))) + + // The App was not running when the account changed. + await harness.relaunch() + XCTAssertEqual(harness.coordinator.account, .accountChanged) + XCTAssertEqual(harness.coordinator.devices.map(\.reachability), [.unreachable]) + XCTAssertEqual(harness.coordinator.devices.count, 1, "the list is kept") + + harness.backend.accountState = .noAccount + await harness.coordinator.accountMayHaveChanged() + XCTAssertEqual(harness.coordinator.account, .noAccount) + XCTAssertEqual(harness.coordinator.devices.map(\.reachability), [.unreachable]) + + // Signing back in to the account the devices were paired in. + harness.backend.accountState = .available(userRecordName: "_synthetic-account-1") + await harness.relaunch() + XCTAssertEqual(harness.coordinator.account, .available) + guard case .reached = try XCTUnwrap(harness.coordinator.devices.first).reachability else { + return XCTFail("reachable again in the original account") + } + } + + func testAccountChangeWithAnEmptyListAdoptsTheNewAccount() async throws { + try await harness.pairPhone() + let outcome8 = await harness.coordinator.removeDevice(harness.phone.deviceID) + XCTAssertTrue(outcome8) + harness.backend.accountState = .available(userRecordName: "_synthetic-account-2") + await harness.coordinator.accountMayHaveChanged() + XCTAssertEqual(harness.coordinator.account, .available) + XCTAssertEqual(try harness.library.vault.loadDeviceInboxState().listAccount, "_synthetic-account-2") + } + + func testPlantedKeyItemsAreRefusedAndOfferTheReset() async throws { + try await harness.pairPhone() + // Another App replaced a key item with a blob of its own. + harness.library.keys.deviceItems[.messageKey] = try harness.backend.createIdentity().blobs.message + await harness.relaunch() + XCTAssertEqual(harness.coordinator.availability, .identityUnusable) + XCTAssertFalse(harness.coordinator.hasIdentity) + XCTAssertTrue(harness.backend.restoredIdentities.isEmpty, "the planted blob was never restored") + let reset = await harness.coordinator.resetIdentity() + XCTAssertTrue(reset) + XCTAssertTrue(harness.library.keys.deviceItems.isEmpty) + } + + func testAnOlderAccountAnswerDoesNotOverwriteANewerAccountChange() async throws { + try await harness.pairPhone() + let coordinator = harness.coordinator + let gate = AccountGate() + let before = harness.backend.accountChecks + harness.backend.accountAnswerGate = { await gate.wait() } + let older = Task { await coordinator.accountMayHaveChanged() } + let backend = harness.backend + try await harness.waitUntil { backend.accountChecks > before } + + // The account changes while the older check is still out. + harness.backend.accountAnswerGate = nil + harness.backend.accountState = .available(userRecordName: "_synthetic-account-2") + await coordinator.accountMayHaveChanged() + XCTAssertEqual(coordinator.account, .accountChanged) + + gate.release() + await older.value + XCTAssertEqual(coordinator.account, .accountChanged, "the older answer describes the old account") + XCTAssertEqual(coordinator.devices.map(\.reachability), [.unreachable]) + } + + func testUnusableIdentityAtLaunchOffersTheResetThatDeletesKeysAndList() async throws { + try await harness.pairPhone() + harness.backend.restoreFails = true + await harness.relaunch() + let coordinator = harness.coordinator + XCTAssertEqual(coordinator.availability, .identityUnusable) + await coordinator.startPairing() + XCTAssertEqual(coordinator.pairing, .failed(.identityUnusable), "pairing waits for the reset") + + harness.script.managementActive = false + let outcome9 = await coordinator.resetIdentity() + XCTAssertFalse(outcome9) + harness.script.managementActive = true + let outcome10 = await coordinator.resetIdentity() + XCTAssertTrue(outcome10) + XCTAssertEqual(coordinator.availability, .ready) + XCTAssertFalse(coordinator.hasIdentity) + XCTAssertTrue(harness.library.keys.deviceItems.isEmpty) + + harness.backend.restoreFails = false + await coordinator.startPairing() + guard case .showingQRCode = coordinator.pairing else { return XCTFail("a new identity can pair") } + await coordinator.cancelPairing() + } + + func testKeyThatStopsWorkingOffersTheResetWhichAlsoDeletesTheInbox() async throws { + try await harness.pairPhone() + await harness.relaunch() + let mac = try XCTUnwrap(harness.macDeviceID) + try XCTUnwrap(harness.backend.restoredIdentities.last).isUnusable = true + try await harness.phone.messenger.sendDeliveryCheck(to: mac) + await harness.coordinator.refresh() + XCTAssertEqual(harness.coordinator.availability, .identityUnusable) + XCTAssertTrue(harness.script.loggedEvents.contains(.identityUnusable)) + + let outcome11 = await harness.coordinator.resetIdentity() + XCTAssertTrue(outcome11) + XCTAssertEqual(harness.backend.removedInboxes, [mac]) + XCTAssertTrue(harness.library.keys.deviceItems.isEmpty) + XCTAssertEqual(try harness.library.vault.loadDeviceInboxState(), DeviceInboxState()) + } + + func testPushHintOnlyMakesTheMacFetchItsOwnInbox() async throws { + try await harness.pairPhone() + let mac = try XCTUnwrap(harness.macDeviceID) + try await harness.phone.messenger.sendDeliveryCheck(to: mac) + + harness.coordinator.handleRemoteNotification(FakeDeviceServicesBackend.pushHint(for: harness.phone.deviceID)) + harness.coordinator.handleRemoteNotification(["aps": ["content-available": 1]]) + try await Task.sleep(for: .milliseconds(200)) + XCTAssertNil(harness.coordinator.pollTask, "no poll started") + XCTAssertEqual(harness.transport.records(in: mac).count, 1, "a hint for another inbox does nothing") + + harness.coordinator.handleRemoteNotification(FakeDeviceServicesBackend.pushHint(for: mac)) + let transport = harness.transport + try await harness.waitUntil { transport.records(in: mac).isEmpty } + XCTAssertTrue(harness.transport.records(in: mac).isEmpty, "the hint made the Mac fetch and process") + } + + func testLaunchProcessesIncomingMessagesAndKeepsTheInboxToken() async throws { + try await harness.pairPhone() + let mac = try XCTUnwrap(harness.macDeviceID) + try await harness.phone.messenger.sendDeliveryCheck(to: mac) + await harness.relaunch() + XCTAssertTrue(harness.transport.records(in: mac).isEmpty, "launch processed the inbox") + let state = try harness.library.vault.loadDeviceInboxState() + XCTAssertNotNil(state.inboxToken) + XCTAssertEqual(state.tokenAccount, "_synthetic-account-1") + XCTAssertEqual(state.listAccount, "_synthetic-account-1") + XCTAssertGreaterThanOrEqual(harness.backend.preparedInboxes.filter { $0 == mac }.count, 2, + "each launch prepares the inbox in the current account") + } + + func testLocalEraseStopsTheServicesAndDropsWhatTheyHeld() async throws { + try await harness.pairPhone() + let coordinator = harness.coordinator + let mac = try XCTUnwrap(harness.macDeviceID) + harness.library.vault.setDeviceServicesStopHandler { + MainActor.assumeIsolated { coordinator.stopForLocalErase() } + } + _ = try await harness.startAndScan() + + try harness.library.vault.eraseLocalLibrary(confirmation: LocalVaultEraseLanguage.english.confirmationText, + language: .english, using: .allow) + + XCTAssertFalse(coordinator.hasIdentity) + XCTAssertNil(coordinator.services) + XCTAssertNil(coordinator.pairing) + XCTAssertTrue(coordinator.devices.isEmpty) + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld) + XCTAssertTrue(harness.library.keys.deviceItems.isEmpty) + let backend = harness.backend + try await harness.waitUntil { !backend.removedInboxes.isEmpty } + XCTAssertEqual(harness.backend.removedInboxes, [mac], "the inbox is deleted on a best-effort basis") + } +} + + +/// Holds account checks until the test releases them. +private final class AccountGate: @unchecked Sendable { + private let lock = NSLock() + private var waiting: [CheckedContinuation] = [] + private var released = false + + func wait() async { + await withCheckedContinuation { continuation in + let resumeNow = lock.withLock { + if released { return true } + waiting.append(continuation) + return false + } + if resumeNow { continuation.resume() } + } + } + + func release() { + let continuations = lock.withLock { + released = true + defer { waiting = [] } + return waiting + } + continuations.forEach { $0.resume() } + } +} diff --git a/Tests/AskKeyAppTests/DeviceCoordinatorPairingTests.swift b/Tests/AskKeyAppTests/DeviceCoordinatorPairingTests.swift new file mode 100644 index 00000000..ae8efd5e --- /dev/null +++ b/Tests/AskKeyAppTests/DeviceCoordinatorPairingTests.swift @@ -0,0 +1,394 @@ +import AskKeyDevicesTestSupport +import AskKeyTestSupport +import Foundation +import XCTest +@testable import AskKeyAppKit +@testable import AskKeyDevices +@testable import AskKeyVault + +/// The Mac as pairing initiator (ADR 0013 → Pairing), with software keys, +/// the in-memory transport and a simulated iPhone. +@MainActor +final class DeviceCoordinatorPairingTests: AskKeyAppTestCase { + private var harness: DeviceCoordinatorHarness! + + override func setUp() async throws { + harness = try DeviceCoordinatorHarness() + await harness.coordinator.launch() + } + + override func tearDown() async throws { + try harness.close() + harness = nil + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld, "no test leaves idle expiry held") + } + + func testFullPairingWhereTheMacConfirmsFirstAndTheIPhoneLater() async throws { + let coordinator = harness.coordinator + XCTAssertEqual(coordinator.availability, .ready) + XCTAssertFalse(coordinator.hasIdentity) + XCTAssertTrue(harness.library.keys.deviceItems.isEmpty, "the identity is created on the first pairing") + + let peer = try await harness.startAndScan() + XCTAssertTrue(coordinator.hasIdentity) + XCTAssertEqual(peer, PairingPeer(deviceID: harness.phone.deviceID, platform: .iOS, displayName: "Synthetic Phone")) + XCTAssertEqual(Set(harness.backend.preparedInboxes), [try XCTUnwrap(harness.macDeviceID)], + "the Mac prepares its own inbox and subscriptions before the QR code") + XCTAssertTrue(SessionPolicy.isIdleExpiryHeld, "the open sheet holds idle expiry") + let expiry = try XCTUnwrap(coordinator.pairingExpiresAt) + XCTAssertEqual(expiry.timeIntervalSince(harness.clock.now()), 600, accuracy: 1) + + try await harness.phone.poll() + let shown1 = await harness.phone.shownCode() + let code = try XCTUnwrap(shown1) + XCTAssertFalse(harness.script.loggedEvents.contains(.revealVerified)) + await coordinator.refresh() + XCTAssertEqual(coordinator.pairing, .enteringCode(peer: peer, attemptsRemaining: 3)) + XCTAssertTrue(harness.script.loggedEvents.contains(.revealVerified), "the verified reveal is logged without content") + + await coordinator.enterPairingCode(code) + XCTAssertEqual(coordinator.pairing, .codeAccepted(peer: peer)) + XCTAssertTrue(harness.script.authenticationReasons.isEmpty, "accepting the code asks for nothing") + XCTAssertTrue(harness.macConfirmations().isEmpty) + + await coordinator.confirmPairing() + XCTAssertEqual(coordinator.pairing, .waitingForIPhoneConfirmation(peer: peer)) + XCTAssertEqual(harness.script.authenticationReasons, [Vault.devicePairingReason]) + XCTAssertEqual(harness.macConfirmations().count, 1) + XCTAssertTrue(coordinator.devices.isEmpty, "nothing is added before the iPhone's confirmation") + + // The iPhone confirms later; the Mac adds it without asking again. + try await harness.phone.confirm() + try await harness.phone.poll() + await coordinator.refresh() + XCTAssertEqual(coordinator.pairing, .paired(peer: peer)) + XCTAssertEqual(harness.script.authenticationReasons.count, 1) + XCTAssertTrue(SessionPolicy.isIdleExpiryHeld, "the open sheet's session can still be undone until it expires") + let row = try XCTUnwrap(coordinator.devices.first) + XCTAssertEqual(row.id, harness.phone.deviceID) + XCTAssertEqual(row.displayName, "Synthetic Phone") + XCTAssertEqual(row.reachability, .notReachedYet) + let phoneEntries = try await harness.phone.pairedDevices() + XCTAssertEqual(phoneEntries.map(\.deviceID), [try XCTUnwrap(harness.macDeviceID)]) + XCTAssertEqual(harness.macSent(.deliveryCheck).count, 1, "a delivery check follows the pairing") + + for text in [code, code.replacingOccurrences(of: " ", with: "")] { + XCTAssertFalse(harness.publishedState().contains(text), "the coordinator never exposes the code") + } + await coordinator.cancelPairing() + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld, "closing the sheet ends the hold") + XCTAssertEqual(coordinator.devices.count, 1, "closing the sheet keeps the added device") + } + + func testAfterPairingTheOpenSheetKeepsPollingUntilTheSessionExpires() async throws { + let coordinator = harness.coordinator + let code = try await harness.reachCodeEntry() + await coordinator.enterPairingCode(code) + await coordinator.confirmPairing() + try await harness.phone.confirm() + await coordinator.refresh() + guard case .paired = coordinator.pairing else { return XCTFail("the Mac added the iPhone") } + + // The iPhone cancels two minutes later; the sheet's polling finds it. + harness.clock.advance(by: 120) + await harness.phone.cancel() + await coordinator.pairingTick() + XCTAssertEqual(coordinator.pairing, .ended(.undoneByPeer)) + XCTAssertTrue(coordinator.devices.isEmpty) + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld) + } + + func testAnExpiryDuringALoopTurnsPollEndsPollingAndTheHold() async throws { + try await harness.pairPhoneLeavingSheetOpen() + XCTAssertTrue(SessionPolicy.isIdleExpiryHeld) + let clock = harness.clock + harness.backend.beforeFetch = { clock.advance(by: 11 * 60) } + let goOn = await harness.coordinator.pairingLoopStep() + harness.backend.beforeFetch = nil + XCTAssertFalse(goOn, "the loop stops") + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld, "and does not leave the hold behind") + XCTAssertNil(harness.coordinator.pairingPollTask) + } + + func testLockingAfterTheSessionExpiredStillEndsTheHold() async throws { + try await harness.pairPhoneLeavingSheetOpen() + harness.clock.advance(by: 11 * 60) + await harness.coordinator.managementSessionEnded() + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld) + } + + func testAfterPairingPollingAndTheHoldEndWhenTheSessionExpires() async throws { + try await harness.pairPhoneLeavingSheetOpen() + XCTAssertTrue(SessionPolicy.isIdleExpiryHeld) + harness.clock.advance(by: 11 * 60) + await harness.coordinator.pairingTick() + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld) + guard case .paired = harness.coordinator.pairing else { return XCTFail("the sheet still shows the pairing") } + XCTAssertEqual(harness.coordinator.devices.count, 1) + } + + func testNoCodeEntryBeforeTheRevealAndAMismatchedRevealStoresNothing() async throws { + let coordinator = harness.coordinator + harness.transport.interceptor = { envelope in + envelope.kind == Int64(MessageKind.pairingReveal.rawValue) ? .drop : .deliver + } + let peer = try await harness.startAndScan() + try await harness.phone.poll() + let shown2 = await harness.phone.shownCode() + let code = try XCTUnwrap(shown2, "the iPhone shows a code; its reveal was lost") + await coordinator.refresh() + XCTAssertEqual(coordinator.pairing, .waitingForIPhone(peer: peer, since: harness.clock.now())) + + await coordinator.enterPairingCode(code) + XCTAssertNil(coordinator.pairingFeedback) + guard case .waitingForIPhone = coordinator.pairing else { return XCTFail("no code entry before the reveal") } + await XCTAssertThrowsErrorAsync(try await harness.coordinator.services!.messenger.enterPairingCode(code)) + + // A reveal bound to the challenge but with another secret. + harness.transport.interceptor = nil + let mac = try XCTUnwrap(harness.macDeviceID) + let challengeEnvelope = try XCTUnwrap(harness.transport.sentEnvelopes.last { + $0.kind == Int64(MessageKind.pairingChallenge.rawValue) && $0.sender == mac.text + }) + let challenge = try DeviceCoordinatorHarness.open(challengeEnvelope, by: harness.phone.identity, now: harness.clock.now()) + let challengePayload = try PairingChallengePayload(decoding: challenge.payload) + let card = challengePayload.card + let forged = PairingRevealPayload(sessionID: challengePayload.sessionID, challengeDigest: challenge.digest, + secret: RandomBytes.make(32)) + let sentAt = UnixTime.seconds(harness.clock.now()) + let sealed = try MessageCodec.seal(kind: .pairingReveal, payload: forged.encoded, identity: harness.phone.identity, + recipient: mac, recipientKey: card.agreementPublicKey, preSharedKey: nil, + sentAt: sentAt, expiresAt: sentAt + 300) + harness.transport.insert(sealed.envelope, intoInboxOf: mac) + await coordinator.refresh() + + XCTAssertEqual(coordinator.pairing, .ended(.commitmentMismatch)) + XCTAssertTrue(coordinator.devices.isEmpty) + XCTAssertNil(harness.library.keys.deviceItems[.pairedDevices], "nothing is stored") + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld) + XCTAssertTrue(harness.macConfirmations().isEmpty) + } + + func testCancelledAuthenticationSendsNothingAndARetryNeedsNoNewEntry() async throws { + let coordinator = harness.coordinator + let code = try await harness.reachCodeEntry() + await coordinator.enterPairingCode(code) + harness.script.authenticationResults = [.cancelled, .failed] + + await coordinator.confirmPairing() + XCTAssertEqual(coordinator.pairingFeedback, .authenticationCancelled) + guard case .codeAccepted = coordinator.pairing else { return XCTFail("the accepted code stays accepted") } + XCTAssertTrue(harness.macConfirmations().isEmpty, "a cancelled authentication sends nothing") + await coordinator.confirmPairing() + XCTAssertEqual(coordinator.pairingFeedback, .authenticationFailed) + XCTAssertTrue(harness.macConfirmations().isEmpty, "a failed authentication sends nothing") + guard case .codeAccepted = coordinator.pairing else { return XCTFail("neither prompt counted as an attempt") } + + await coordinator.confirmPairing() + XCTAssertNil(coordinator.pairingFeedback) + XCTAssertEqual(harness.macConfirmations().count, 1, "the retry sends the confirmation without a new entry") + XCTAssertEqual(harness.script.authenticationReasons.count, 3) + guard case .waitingForIPhoneConfirmation = coordinator.pairing else { return XCTFail("the Mac confirmed") } + } + + func testAuthenticationThatCompletesAfterTheSessionEndedSendsNothing() async throws { + let coordinator = harness.coordinator + let code = try await harness.reachCodeEntry() + await coordinator.enterPairingCode(code) + let clock = harness.clock + harness.script.duringAuthentication = { clock.advance(by: 11 * 60) } + + await coordinator.confirmPairing() + + XCTAssertTrue(harness.macConfirmations().isEmpty) + XCTAssertEqual(coordinator.pairing, .ended(.expired)) + try? await harness.phone.confirm() + await coordinator.refresh() + XCTAssertTrue(coordinator.devices.isEmpty) + XCTAssertNil(harness.library.keys.deviceItems[.pairedDevices]) + } + + func testAuthenticationThatCompletesAfterCancelSendsNothing() async throws { + let coordinator = harness.coordinator + let code = try await harness.reachCodeEntry() + await coordinator.enterPairingCode(code) + harness.script.duringAuthentication = { [coordinator] in await coordinator.cancelPairing() } + + await coordinator.confirmPairing() + + XCTAssertTrue(harness.macConfirmations().isEmpty) + XCTAssertNil(coordinator.pairing) + XCTAssertEqual(harness.macSent(.pairingCancellation).count, 1, "the iPhone is told") + } + + func testThreeWrongCodesEndTheSessionWithNothingStored() async throws { + let coordinator = harness.coordinator + let code = try await harness.reachCodeEntry() + let digits = code.replacingOccurrences(of: " ", with: "") + let wrong = String(format: "%06d", (Int(digits)! + 1) % 1_000_000) + + await coordinator.enterPairingCode("12 34") + XCTAssertEqual(coordinator.pairingFeedback, .invalidCodeFormat) + await coordinator.enterPairingCode(wrong) + XCTAssertEqual(coordinator.pairingFeedback, .wrongCode(attemptsRemaining: 2)) + guard case .enteringCode(_, 2) = coordinator.pairing else { return XCTFail("two attempts left") } + await coordinator.enterPairingCode(wrong) + await coordinator.enterPairingCode(wrong) + + XCTAssertEqual(coordinator.pairing, .ended(.codeMismatch)) + XCTAssertEqual(harness.macSent(.pairingCancellation).count, 1) + XCTAssertTrue(harness.script.authenticationReasons.isEmpty) + XCTAssertTrue(harness.macConfirmations().isEmpty) + XCTAssertNil(harness.library.keys.deviceItems[.pairedDevices]) + XCTAssertFalse(harness.publishedState().contains(digits)) + } + + func testCancelExpiryAndASecondRequestEndTheSessionWithNothingStored() async throws { + let coordinator = harness.coordinator + // Cancel. + _ = try await harness.startAndScan() + await coordinator.cancelPairing() + XCTAssertNil(coordinator.pairing) + XCTAssertEqual(harness.macSent(.pairingCancellation).count, 1) + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld) + + // Expiry. + await coordinator.startPairing() + harness.clock.advance(by: 11 * 60) + await coordinator.refresh() + XCTAssertEqual(coordinator.pairing, .ended(.expired)) + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld) + + // A second request for the same QR code. + await coordinator.startPairing() + guard case .showingQRCode(let text, _) = coordinator.pairing else { return XCTFail("a new QR code") } + let other = SimulatedPairingPhone(transport: harness.transport, clock: harness.clock) + try await harness.phone.scan(text) + try await other.scan(text, name: "Synthetic Other Phone") + await coordinator.refresh() + XCTAssertEqual(coordinator.pairing, .ended(.secondRequest)) + + XCTAssertTrue(coordinator.devices.isEmpty) + XCTAssertNil(harness.library.keys.deviceItems[.pairedDevices]) + XCTAssertTrue(harness.script.authenticationReasons.isEmpty) + } + + func testIPhoneWithdrawalWithoutChallengeEndsTheSession() async throws { + let coordinator = harness.coordinator + harness.transport.interceptor = { envelope in + envelope.kind == Int64(MessageKind.pairingRequest.rawValue) ? .delay : .deliver + } + await coordinator.startPairing() + guard case .showingQRCode(let text, _) = coordinator.pairing else { return XCTFail("a QR code") } + try await harness.phone.scan(text) + harness.clock.advance(by: 61) + try await harness.phone.poll() + harness.transport.interceptor = nil + await coordinator.refresh() + XCTAssertEqual(coordinator.pairing, .ended(.cancelledByPeer)) + XCTAssertNil(harness.library.keys.deviceItems[.pairedDevices]) + } + + func testCancellationAfterTheMacAddedTheDeviceRemovesIt() async throws { + let coordinator = harness.coordinator + let code = try await harness.reachCodeEntry() + try await harness.phone.confirm() + await coordinator.enterPairingCode(code) + await coordinator.confirmPairing() + await coordinator.refresh() + guard case .paired = coordinator.pairing else { return XCTFail("the Mac added the iPhone") } + XCTAssertEqual(coordinator.devices.count, 1) + + await harness.phone.cancel() + await coordinator.refresh() + + XCTAssertEqual(coordinator.pairing, .ended(.undoneByPeer)) + XCTAssertTrue(coordinator.devices.isEmpty) + let stored = try await harness.library.vault.pairedDeviceRegistry().list() + XCTAssertTrue(stored.devices.isEmpty) + } + + func testLockingManagementCancelsTheSessionAndStopsTheAdd() async throws { + let coordinator = harness.coordinator + let code = try await harness.reachCodeEntry() + await coordinator.enterPairingCode(code) + await coordinator.confirmPairing() + XCTAssertEqual(harness.macConfirmations().count, 1) + + harness.script.managementActive = false + harness.library.vault.endManagementSession() + await coordinator.managementSessionEnded() + XCTAssertNil(coordinator.pairing) + XCTAssertEqual(harness.macSent(.pairingCancellation).count, 1) + + try await harness.phone.confirm() + await coordinator.refresh() + XCTAssertTrue(coordinator.devices.isEmpty, "the authorization did not outlive the management session") + XCTAssertNil(harness.library.keys.deviceItems[.pairedDevices]) + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld) + } + + func testPairingTickCancelsWhenTheManagementSessionIsGone() async throws { + _ = try await harness.startAndScan() + harness.script.managementActive = false + await harness.coordinator.pairingTick() + XCTAssertNil(harness.coordinator.pairing) + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld) + } + + func testPairingNeedsTheManagementSessionAndAnAvailableAccountBeforeAnyKeyIsStored() async throws { + let coordinator = harness.coordinator + harness.script.managementActive = false + await coordinator.startPairing() + XCTAssertEqual(coordinator.pairing, .failed(.managementRequired)) + harness.script.managementActive = true + + harness.backend.accountState = .noAccount + await coordinator.startPairing() + XCTAssertEqual(coordinator.pairing, .failed(.accountUnavailable(.noAccount))) + XCTAssertTrue(harness.library.keys.deviceItems.isEmpty, "no key is stored without an account") + XCTAssertFalse(coordinator.hasIdentity) + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld) + + harness.backend.accountState = .available(userRecordName: "_synthetic-account-1") + harness.backend.isSecureEnclaveAvailable = false + await coordinator.startPairing() + XCTAssertEqual(coordinator.pairing, .failed(.secureEnclaveUnavailable)) + XCTAssertEqual(coordinator.availability, .secureEnclaveUnavailable) + XCTAssertTrue(harness.library.keys.deviceItems.isEmpty) + } + + func testOpenPairingSheetHoldsIdleExpiryAndClosingItRestoresTheNormalExpiry() async throws { + var timers: [(timeout: Double, timer: Timer)] = [] + let policy = SessionPolicy(scheduleTimer: { timeout, callback in + let timer = Timer(timeInterval: timeout, repeats: false, block: callback) + timers.append((timeout, timer)) + return timer + }, enqueueExpiration: { callback in + MainActor.assumeIsolated { callback() } + }) + defer { policy.cancel() } + var expired = 0 + policy.renew(timeout: 300) { expired += 1 } + + _ = try await harness.startAndScan() + timers[0].timer.fire() + XCTAssertEqual(expired, 0, "an open pairing sheet keeps the management session from expiring") + XCTAssertEqual(timers.count, 1) + + await harness.coordinator.cancelPairing() + XCTAssertEqual(timers.count, 2, "closing the sheet restarts the normal timeout") + XCTAssertEqual(timers[1].timeout, 300) + XCTAssertEqual(expired, 0) + timers[1].timer.fire() + XCTAssertEqual(expired, 1) + } +} + +func XCTAssertThrowsErrorAsync(_ expression: @autoclosure () async throws -> T, _ message: String = "", + file: StaticString = #filePath, line: UInt = #line) async { + do { + _ = try await expression() + XCTFail("expected an error \(message)", file: file, line: line) + } catch {} +} diff --git a/Tests/AskKeyAppTests/DeviceCoordinatorTestSupport.swift b/Tests/AskKeyAppTests/DeviceCoordinatorTestSupport.swift new file mode 100644 index 00000000..be49b03a --- /dev/null +++ b/Tests/AskKeyAppTests/DeviceCoordinatorTestSupport.swift @@ -0,0 +1,175 @@ +import AskKeyDevicesTestSupport +import AskKeyTestSupport +import Foundation +import XCTest +@testable import AskKeyAppKit +@testable import AskKeyDevices +@testable import AskKeyVault + +/// The Mac's coordinator on a real temporary library, with software keys, +/// the in-memory transport, a manual clock and a simulated iPhone. Polling +/// runs only when a test asks for it. +@MainActor +final class DeviceCoordinatorHarness { + let library: AgentAccessPolicyTestLibrary + let clock = ManualDeviceClock(Date()) + let transport = InMemoryDeviceTransport() + let backend: FakeDeviceServicesBackend + let script = DeviceTestScript() + let phone: SimulatedPairingPhone + private(set) var coordinator: DeviceCoordinator + + init() throws { + library = try AgentAccessPolicyTestLibrary() + try library.vault.beginManagementSession(using: .allow) + backend = FakeDeviceServicesBackend(transport: transport, clock: clock) + phone = SimulatedPairingPhone(transport: transport, clock: clock) + coordinator = Self.makeCoordinator(vault: library.vault, backend: backend, script: script) + } + + func close() throws { + coordinator.stop() + try library.close() + } + + /// The same keychain, library and inbox state in a new App process. + func relaunch() async { + coordinator.stop() + coordinator = Self.makeCoordinator(vault: library.vault, backend: backend, script: script) + await coordinator.launch() + } + + private static func makeCoordinator(vault: Vault, backend: FakeDeviceServicesBackend, + script: DeviceTestScript) -> DeviceCoordinator { + DeviceCoordinator(environment: DeviceCoordinatorEnvironment( + vault: vault, + backend: backend, + inboxState: VaultDeviceInboxStateStore(vault: vault), + authenticate: { reason in await script.authenticate(reason) }, + isManagementSessionActive: { script.managementActive }, + displayName: { "Synthetic Mac" }, + log: { script.record($0) }, + automaticPolling: false, + sleep: { _ in } + )) + } + + var macDeviceID: DeviceID? { coordinator.services?.deviceID } + + /// Starts pairing and has the iPhone scan the QR code; the Mac then + /// freezes the request and sends its challenge. + func startAndScan() async throws -> PairingPeer { + await coordinator.startPairing() + guard case .showingQRCode(let text, _) = coordinator.pairing else { + XCTFail("the Mac shows the QR code, not \(String(describing: coordinator.pairing))") + throw DeviceMessengerError.notAwaitingConfirmation + } + try await phone.scan(text) + await coordinator.refresh() + guard case .waitingForIPhone(let peer, _) = coordinator.pairing else { + XCTFail("the Mac waits for the iPhone, not \(String(describing: coordinator.pairing))") + throw DeviceMessengerError.notAwaitingConfirmation + } + return peer + } + + /// Runs the pairing up to the code entry and returns the iPhone's code. + func reachCodeEntry() async throws -> String { + _ = try await startAndScan() + try await phone.poll() + await coordinator.refresh() + guard case .enteringCode = coordinator.pairing, let code = await phone.shownCode() else { + XCTFail("the Mac asks for the code the iPhone shows, not \(String(describing: coordinator.pairing))") + throw DeviceMessengerError.notAwaitingConfirmation + } + return code + } + + /// A complete pairing with the simulated iPhone, its delivery check answered. + func pairPhone() async throws { + try await pairPhoneLeavingSheetOpen() + await coordinator.cancelPairing() + try await phone.poll() + await coordinator.refresh() + } + + /// A complete pairing; the sheet stays open on the paired state. + func pairPhoneLeavingSheetOpen() async throws { + let code = try await reachCodeEntry() + await coordinator.enterPairingCode(code) + await coordinator.confirmPairing() + try await phone.confirm() + try await phone.poll() + await coordinator.refresh() + guard case .paired = coordinator.pairing else { + XCTFail("the Mac paired, not \(String(describing: coordinator.pairing))") + throw DeviceMessengerError.notAwaitingConfirmation + } + } + + /// Every confirmation the Mac sent. + func macConfirmations() -> [DeviceEnvelope] { + guard let mac = macDeviceID else { return [] } + return transport.sentEnvelopes.filter { $0.kind == Int64(MessageKind.pairingConfirmation.rawValue) && $0.sender == mac.text } + } + + func macSent(_ kind: MessageKind) -> [DeviceEnvelope] { + guard let mac = macDeviceID else { return [] } + return transport.sentEnvelopes.filter { $0.kind == Int64(kind.rawValue) && $0.sender == mac.text } + } + + /// What the coordinator publishes, for checks that nothing shows the code. + func publishedState() -> String { + [String(reflecting: coordinator.pairing), String(reflecting: coordinator.pairingFeedback), + String(reflecting: coordinator.devices), String(reflecting: coordinator.availability)].joined(separator: "\n") + } + + /// Waits, for at most five seconds, until work on other executors made + /// `condition` true. + func waitUntil(_ condition: () -> Bool, file: StaticString = #filePath, line: UInt = #line) async throws { + let deadline = Date().addingTimeInterval(5) + while !condition() { + guard Date() < deadline else { + XCTFail("timed out", file: file, line: line) + return + } + try await Task.sleep(for: .milliseconds(10)) + } + } + + /// Opens a message addressed to `identity`, as that device would. + static func open(_ envelope: DeviceEnvelope, by identity: SoftwareDeviceIdentity, now: Date) throws -> InnerMessage { + let ownID = DeviceID(messagePublicKey: identity.messagePublicKey) + let routing = try MessageCodec.routing(of: envelope, ownID: ownID, now: now) + return try MessageCodec.open(envelope, routing: routing, ownID: ownID, identity: identity, preSharedKey: nil, now: now) + } +} + +/// What the person and the system do while a test runs. +@MainActor +final class DeviceTestScript { + var managementActive = true + /// Each prompt's answer, in order; afterwards every prompt succeeds. + var authenticationResults: [ManagementAuthenticationResult] = [] + var duringAuthentication: (@MainActor () async -> Void)? + private(set) var authenticationReasons: [String] = [] + private let events = DeviceEventLog() + + func authenticate(_ reason: String) async -> ManagementAuthenticationResult { + authenticationReasons.append(reason) + await duringAuthentication?() + return authenticationResults.isEmpty ? .authenticated(.allow) : authenticationResults.removeFirst() + } + + nonisolated func record(_ event: DeviceServicesEvent) { events.append(event) } + + var loggedEvents: [DeviceServicesEvent] { events.all } +} + +final class DeviceEventLog: @unchecked Sendable { + private let lock = NSLock() + private var events: [DeviceServicesEvent] = [] + + func append(_ event: DeviceServicesEvent) { lock.withLock { events.append(event) } } + var all: [DeviceServicesEvent] { lock.withLock { events } } +} diff --git a/Tests/AskKeyAppTests/SessionExpiryHoldTests.swift b/Tests/AskKeyAppTests/SessionExpiryHoldTests.swift new file mode 100644 index 00000000..c2d2212b --- /dev/null +++ b/Tests/AskKeyAppTests/SessionExpiryHoldTests.swift @@ -0,0 +1,74 @@ +import Foundation +import XCTest +@testable import AskKeyAppKit + +/// An open pairing sheet holds idle expiry (ADR 0013 → Pairing, step 6). +@MainActor +final class SessionExpiryHoldTests: AskKeyAppTestCase { + private var timers: [(timeout: Double, timer: Timer)] = [] + + override func tearDown() { + XCTAssertFalse(SessionPolicy.isIdleExpiryHeld) + super.tearDown() + } + + private func makePolicy() -> SessionPolicy { + SessionPolicy(scheduleTimer: { [unowned self] timeout, callback in + let timer = Timer(timeInterval: timeout, repeats: false, block: callback) + timers.append((timeout, timer)) + return timer + }, enqueueExpiration: { callback in + MainActor.assumeIsolated { callback() } + }) + } + + func testHeldExpiryWaitsAndReleasingRestartsTheFullTimeout() { + let policy = makePolicy() + defer { policy.cancel() } + var expired = 0 + policy.renew(timeout: 300) { expired += 1 } + let first = SessionPolicy.holdIdleExpiry() + let second = SessionPolicy.holdIdleExpiry() + + timers[0].timer.fire() + XCTAssertEqual(expired, 0) + first.release() + first.release() + XCTAssertEqual(timers.count, 1, "another hold is still open") + second.release() + XCTAssertEqual(timers.count, 2) + XCTAssertEqual(timers[1].timeout, 300) + timers[1].timer.fire() + XCTAssertEqual(expired, 1) + } + + func testWithoutAHoldExpiryIsUnchangedAndACancelledPolicyIsNotRestarted() { + let policy = makePolicy() + var expired = 0 + policy.renew(timeout: 60) { expired += 1 } + timers[0].timer.fire() + XCTAssertEqual(expired, 1) + + policy.renew(timeout: 60) { expired += 1 } + let hold = SessionPolicy.holdIdleExpiry() + timers[1].timer.fire() + policy.cancel() + hold.release() + XCTAssertEqual(timers.count, 2, "a cancelled session is not restarted") + XCTAssertEqual(expired, 1) + } + + func testRenewingDuringAHoldReplacesTheWaitingExpiry() { + let policy = makePolicy() + defer { policy.cancel() } + var expired: [String] = [] + policy.renew(timeout: 60) { expired.append("old") } + let hold = SessionPolicy.holdIdleExpiry() + timers[0].timer.fire() + policy.renew(timeout: 120) { expired.append("new") } + hold.release() + XCTAssertEqual(timers.count, 2, "the renewed timer is still running; nothing restarts") + timers[1].timer.fire() + XCTAssertEqual(expired, ["new"]) + } +} diff --git a/Tests/AskKeyTestSupport/E2EVaultFixture.swift b/Tests/AskKeyTestSupport/E2EVaultFixture.swift index b280c27f..d77c8bb2 100644 --- a/Tests/AskKeyTestSupport/E2EVaultFixture.swift +++ b/Tests/AskKeyTestSupport/E2EVaultFixture.swift @@ -90,4 +90,22 @@ private final class E2EPolicyStore: AppKeyStore { func deleteLegacyKey() throws { } func deletePendingKey() throws { } func deleteAppKey() throws { } + // Device items: synthetic files beside the policy fixture, one per account. + private func deviceURL(_ account: DeviceKeychainAccount) -> URL { + url.deletingLastPathComponent().appendingPathComponent("\(account.rawValue).fixture") + } + func loadDeviceItem(_ account: DeviceKeychainAccount) throws -> Data? { + let file = deviceURL(account) + guard FileManager.default.fileExists(atPath: file.path) else { return nil } + return try Data(contentsOf: file) + } + func saveDeviceItem(_ data: Data, account: DeviceKeychainAccount) throws { + let file = deviceURL(account) + try data.write(to: file, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) + } + func deleteDeviceItem(_ account: DeviceKeychainAccount) throws { + let file = deviceURL(account) + if FileManager.default.fileExists(atPath: file.path) { try FileManager.default.removeItem(at: file) } + } } diff --git a/Tests/AskKeyTestSupport/FakeDeviceServices.swift b/Tests/AskKeyTestSupport/FakeDeviceServices.swift new file mode 100644 index 00000000..988899d0 --- /dev/null +++ b/Tests/AskKeyTestSupport/FakeDeviceServices.swift @@ -0,0 +1,238 @@ +import AskKeyAppKit +import AskKeyDevices +import AskKeyDevicesTestSupport +import CryptoKit +import Foundation + +/// Device services for the E2E App (#288) and App tests: software keys, an +/// in-memory transport standing in for the account's private database, and +/// an account the test sets. Nothing here creates a CloudKit object. +package final class FakeDeviceServicesBackend: DeviceServicesBackend, @unchecked Sendable { + package let transport: InMemoryDeviceTransport + package let clock: any DeviceClock + private let lock = NSLock() + private var secureEnclave = true + private var account: CloudKitAccountState + private var failRestore = false + private var restored: [SoftwareDeviceIdentity] = [] + private var prepared: [DeviceID] = [] + private var removed: [DeviceID] = [] + private var checks = 0 + private var answerGate: (@Sendable () async -> Void)? + private var fetchHook: (@Sendable () async -> Void)? + private var preparationError: DeviceTransportError? + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + package init(transport: InMemoryDeviceTransport = InMemoryDeviceTransport(), clock: any DeviceClock = SystemDeviceClock(), + account: CloudKitAccountState = .available(userRecordName: "_synthetic-account-1")) { + self.transport = transport + self.clock = clock + self.account = account + } + + package var isSecureEnclaveAvailable: Bool { + get { lock.withLock { secureEnclave } } + set { lock.withLock { secureEnclave = newValue } } + } + + /// What the next account check answers; `accountChanged` is derived from + /// the stored account, as CloudKit does. + package var accountState: CloudKitAccountState { + get { lock.withLock { account } } + set { lock.withLock { account = newValue } } + } + + /// Makes the next restores fail as a key copied to another Mac does. + package var restoreFails: Bool { + get { lock.withLock { failRestore } } + set { lock.withLock { failRestore = newValue } } + } + + /// Makes inbox preparations throw this error. + package var inboxPreparationError: DeviceTransportError? { + get { lock.withLock { preparationError } } + set { lock.withLock { preparationError = newValue } } + } + + /// Every identity restored from stored blobs, most recent last. + package var restoredIdentities: [SoftwareDeviceIdentity] { lock.withLock { restored } } + /// Each inbox preparation, by device. + package var preparedInboxes: [DeviceID] { lock.withLock { prepared } } + /// Each inbox removal, by device. + package var removedInboxes: [DeviceID] { lock.withLock { removed } } + /// How many account checks were made. + package var accountChecks: Int { lock.withLock { checks } } + + /// Runs at the start of every inbox fetch, for example to let time pass + /// while a poll is out. + package var beforeFetch: (@Sendable () async -> Void)? { + get { lock.withLock { fetchHook } } + set { lock.withLock { fetchHook = newValue } } + } + + /// Runs after an account check read its answer and before it returns it, + /// so a test can let a later check overtake an earlier one. + package var accountAnswerGate: (@Sendable () async -> Void)? { + get { lock.withLock { answerGate } } + set { lock.withLock { answerGate = newValue } } + } + + package var registersForRemoteNotifications: Bool { false } + + package func createIdentity() throws -> (identity: any DeviceIdentity, blobs: DeviceIdentityKeyBlobs) { + let message = P256.Signing.PrivateKey() + let agreement = P256.KeyAgreement.PrivateKey() + let approval = P256.Signing.PrivateKey() + let identity = SoftwareDeviceIdentity(messageKey: message, agreementKey: agreement, approvalKey: approval) + let blobs = DeviceIdentityKeyBlobs(message: message.rawRepresentation, agreement: agreement.rawRepresentation, + approval: approval.rawRepresentation) + return (identity, blobs) + } + + package func restoreIdentity(from blobs: DeviceIdentityKeyBlobs) throws -> any DeviceIdentity { + guard !restoreFails, + let message = try? P256.Signing.PrivateKey(rawRepresentation: blobs.message), + let agreement = try? P256.KeyAgreement.PrivateKey(rawRepresentation: blobs.agreement), + let approval = try? P256.Signing.PrivateKey(rawRepresentation: blobs.approval) else { + throw DeviceIdentityError.unusable + } + let identity = SoftwareDeviceIdentity(messageKey: message, agreementKey: agreement, approvalKey: approval) + lock.withLock { restored.append(identity) } + return identity + } + + package func makeInbox(for device: DeviceID) -> any DeviceInbox { + FakeDeviceInbox(backend: self, device: device) + } + + package func accountChanges() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + lock.withLock { continuations[id] = continuation } + continuation.onTermination = { [weak self] _ in + self?.lock.withLock { _ = self?.continuations.removeValue(forKey: id) } + } + } + } + + /// Plays the system's account-change notification. + package func signalAccountChange() { + for continuation in lock.withLock({ Array(continuations.values) }) { continuation.yield() } + } + + /// The push a test sends for `device`'s inbox. + package static func pushHint(for device: DeviceID) -> [AnyHashable: Any] { + ["askkey-synthetic-inbox": device.text] + } + + package func isPushHint(_ userInfo: [AnyHashable: Any], for device: DeviceID) -> Bool { + userInfo["askkey-synthetic-inbox"] as? String == device.text + } + + fileprivate func recordPreparation(_ device: DeviceID) throws { + try lock.withLock { + if let preparationError { throw preparationError } + prepared.append(device) + } + } + + fileprivate func recordAccountCheck() -> CloudKitAccountState { + lock.withLock { + checks += 1 + return account + } + } + + fileprivate func recordRemoval(_ device: DeviceID) { + lock.withLock { removed.append(device) } + } +} + +/// One device's inbox over the shared in-memory transport. +final class FakeDeviceInbox: DeviceInbox, @unchecked Sendable { + private let backend: FakeDeviceServicesBackend + private let device: DeviceID + + init(backend: FakeDeviceServicesBackend, device: DeviceID) { + self.backend = backend + self.device = device + } + + func send(_ envelope: DeviceEnvelope) async throws { + try await backend.transport.send(envelope) + } + + func fetchInboxChanges(of device: DeviceID, since token: InboxChangeToken?) async throws -> InboxChanges { + await backend.beforeFetch?() + return try await backend.transport.fetchInboxChanges(of: device, since: token) + } + + func deleteMessages(_ recordNames: [String], from device: DeviceID) async throws { + try await backend.transport.deleteMessages(recordNames, from: device) + } + + func prepareInbox() async throws -> InboxPreparation { + try backend.recordPreparation(device) + return InboxPreparation() + } + + func accountState(storedUserRecordName: String?) async -> CloudKitAccountState { + let state = backend.recordAccountCheck() + await backend.accountAnswerGate?() + if case .available(let current) = state, let storedUserRecordName, storedUserRecordName != current { + return .accountChanged(userRecordName: current) + } + return state + } + + func removeInbox() async throws { + backend.recordRemoval(device) + } +} + +/// A pairing-only iPhone in the same process: software keys, its own list in +/// memory, and the backend's transport and clock. The person's steps on the +/// phone are its methods. +package final class SimulatedPairingPhone: @unchecked Sendable { + package let identity = SoftwareDeviceIdentity() + package let store = InMemoryPairedDeviceListStore() + package let messenger: DeviceMessenger + + package init(transport: any DeviceTransport, clock: any DeviceClock) { + messenger = DeviceMessenger(identity: identity, platform: .iOS, registry: PairedDeviceRegistry(store: store), + transport: transport, clock: clock) + } + + package var deviceID: DeviceID { messenger.deviceID } + + package func scan(_ qrText: String, name: String = "Synthetic Phone") async throws { + try await messenger.joinPairing(scannedText: qrText, displayName: name) + } + + @discardableResult + package func poll() async throws -> InboxReport { + try await messenger.poll() + } + + /// The code the phone's pairing screen shows, once its reveal was sent. + package func shownCode() async -> String? { + if case .showingCode(let code, _, _) = await messenger.pairingPhase { return code.formatted } + return nil + } + + package func confirm() async throws { + try await messenger.confirmPairing(approvalReason: "Synthetic phone confirmation") + } + + package func cancel() async { + await messenger.cancelPairing() + } + + package func pairedDevices() async throws -> [PairedDevice] { + try await messenger.pairedDevices() + } + + package var phase: PairingPhase? { + get async { await messenger.pairingPhase } + } +} diff --git a/Tests/AskKeyVaultTests/DeviceKeychainNativeTests.swift b/Tests/AskKeyVaultTests/DeviceKeychainNativeTests.swift new file mode 100644 index 00000000..d3ff5365 --- /dev/null +++ b/Tests/AskKeyVaultTests/DeviceKeychainNativeTests.swift @@ -0,0 +1,62 @@ +import Foundation +import XCTest +@testable import AskKeyVault + +/// The device items in a real file keychain (CI only): App-only access, no +/// in-place update, their own conflict kinds, and no effect on the vault key. +final class DeviceKeychainNativeTests: XCTestCase { + func testNativeDeviceItemsRoundTripWithAppOnlyAccessAndLeaveTheVaultKeyAlone() throws { + let fixture = try KeychainNativeTestSupport(test: self) + let keys = try fixture.makeStore() + let vaultKey = Data(repeating: 0x42, count: 32) + try keys.savePendingKey(vaultKey) + try keys.promotePendingKey(expected: vaultKey) + + for account in DeviceKeychainAccount.allCases { + let value = Data("synthetic-\(account.rawValue)".utf8) + XCTAssertNil(try keys.loadDeviceItem(account)) + try keys.saveDeviceItem(value, account: account) + XCTAssertEqual(try keys.loadDeviceItem(account), value) + try fixture.assertAppOnlyItem(account: account.rawValue) + try keys.saveDeviceItem(value + Data([1]), account: account) + XCTAssertEqual(try keys.loadDeviceItem(account), value + Data([1])) + } + for account in DeviceKeychainAccount.allCases { + try keys.deleteDeviceItem(account) + XCTAssertNil(try keys.loadDeviceItem(account)) + } + XCTAssertEqual(try keys.loadAppKey(), vaultKey, "no device operation touched the vault key") + XCTAssertEqual(try keys.loadPendingKey(), vaultKey) + } + + func testNativePlantedPermissiveDeviceItemIsReplacedNotUpdated() throws { + let fixture = try KeychainNativeTestSupport(test: self) + let planted = try fixture.addItem(Data("synthetic-planted".utf8), account: "paired-devices", permissive: true) + let keys = try fixture.makeStore() + try keys.saveDeviceItem(Data("synthetic-list".utf8), account: .pairedDevices) + try fixture.assertOldItemReplaced(planted, account: "paired-devices") + try fixture.assertAppOnlyItem(account: "paired-devices") + XCTAssertEqual(try keys.loadDeviceItem(.pairedDevices), Data("synthetic-list".utf8)) + } + + func testNativeDuplicateDeviceItemsRefuseEveryOperationWithTheirOwnKind() throws { + let fixture = try KeychainNativeTestSupport(test: self) + for account in DeviceKeychainAccount.allCases { + _ = try fixture.addItem(Data("synthetic-pinned".utf8), account: account.rawValue) + _ = try fixture.addItem(Data("synthetic-shadow".utf8), account: account.rawValue, outside: true) + } + let keys = try fixture.makeStore() + for account in DeviceKeychainAccount.allCases { + let operations: [() throws -> Void] = [ + { _ = try keys.loadDeviceItem(account) }, + { try keys.saveDeviceItem(Data("synthetic-new".utf8), account: account) }, + { try keys.deleteDeviceItem(account) }, + ] + for operation in operations { + XCTAssertThrowsError(try operation()) { + XCTAssertEqual($0 as? AppKeyStoreError, .conflictingItem(account.itemKind)) + } + } + } + } +} diff --git a/Tests/AskKeyVaultTests/DeviceStorageTests.swift b/Tests/AskKeyVaultTests/DeviceStorageTests.swift new file mode 100644 index 00000000..8d541431 --- /dev/null +++ b/Tests/AskKeyVaultTests/DeviceStorageTests.swift @@ -0,0 +1,417 @@ +import AskKeyDevices +import AskKeyDevicesTestSupport +import CryptoKit +import Foundation +import XCTest +@testable import AskKeyVault + +/// The Mac's device identity and paired-device list in the Vault (ADR 0013), +/// on a real temporary library with an in-memory key store. +final class DeviceStorageTests: XCTestCase { + private var libraries: [AgentAccessPolicyLibrary] = [] + + override func tearDownWithError() throws { + for library in libraries { try library.close() } + libraries = [] + } + + private func makeLibrary() throws -> AgentAccessPolicyLibrary { + let library = try AgentAccessPolicyLibrary() + libraries.append(library) + try library.vault.beginManagementSession(using: .allow) + return library + } + + private func syntheticBlobs() -> DeviceIdentityKeyBlobs { + DeviceIdentityKeyBlobs(message: Data("synthetic-message-blob".utf8), agreement: Data("synthetic-agreement-blob".utf8), + approval: Data("synthetic-approval-blob".utf8)) + } + + private func syntheticDevice(name: String = "Synthetic Phone") throws -> PairedDevice { + let card = try DeviceCard.make(identity: SoftwareDeviceIdentity(), platform: .iOS, + createdAt: Date(timeIntervalSince1970: 1_800_000_000)) + let pairingID = try XCTUnwrap(PairingID(rawValue: Data((0..<16).map { _ in UInt8.random(in: .min ... .max) }))) + return PairedDevice(card: card, pairingID: pairingID, displayName: name, + pairedAt: Date(timeIntervalSince1970: 1_800_000_100)) + } + + /// Adds a device the way pairing does: the person's authorization, then one list change. + private func add(_ device: PairedDevice, to library: AgentAccessPolicyLibrary) async throws { + try library.vault.authorizeDevicePairing(with: device.deviceID, using: .allow) + try await library.vault.pairedDeviceRegistry().apply(.add(device)) + } + + func testIdentityAndListRoundTripAcrossReopening() async throws { + let library = try makeLibrary() + XCTAssertNil(try library.vault.storedDeviceIdentityKeyBlobs(), "a Mac that never paired has no identity") + XCTAssertTrue(library.keys.deviceItems.isEmpty) + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + let device = try syntheticDevice() + try await add(device, to: library) + + XCTAssertEqual(Set(library.keys.deviceItems.keys), + [.messageKey, .agreementKey, .approvalKey, .pairedDevices]) + library.vault.endManagementSession() + try library.reopen() + // Reading needs no management session: messages are verified in the background. + XCTAssertEqual(try library.vault.storedDeviceIdentityKeyBlobs(), syntheticBlobs()) + let list = try await library.vault.pairedDeviceRegistry().list() + XCTAssertEqual(list.devices, [device]) + } + + func testListItemIsTheSerializationFollowedByItsTagOverTheExactBytes() async throws { + let library = try makeLibrary() + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + try await add(try syntheticDevice(), to: library) + let item = try XCTUnwrap(library.keys.deviceItems[.pairedDevices]) + let bytes = item.prefix(item.count - 32) + // Labels written out byte by byte, independently of the implementation. + let key = HKDF.deriveKey(inputKeyMaterial: try library.key, salt: Data(), + info: Data("AskKey paired devices key v1".utf8) + Data([0]), + outputByteCount: 32) + let expected = HMAC.authenticationCode( + for: Data("AskKey paired devices v1".utf8) + Data([0]) + bytes, using: key) + XCTAssertEqual(Data(item.suffix(32)), Data(expected)) + let stored = try await library.vault.pairedDeviceRegistry().list() + XCTAssertEqual(try PairedDeviceList(serialized: Data(bytes)), stored) + } + + func testTamperedOrReencodedListIsRefusedAndNotRepaired() async throws { + let library = try makeLibrary() + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + try await add(try syntheticDevice(), to: library) + let registry = try library.vault.pairedDeviceRegistry() + let original = try XCTUnwrap(library.keys.deviceItems[.pairedDevices]) + + var flipped = original + flipped[flipped.startIndex + 10] ^= 0x01 + let bytes = original.prefix(original.count - 32) + let object = try JSONSerialization.jsonObject(with: bytes) + // The same list, encoded with other whitespace, keeps the old tag. + let reencoded = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]) + original.suffix(32) + let otherKeyTag = Data(HMAC.authenticationCode(for: bytes, using: SymmetricKey(size: .bits256))) + for stored in [flipped, reencoded, Data(bytes) + otherKeyTag, Data(original.prefix(31))] { + library.keys.deviceItems[.pairedDevices] = stored + do { + _ = try await registry.list() + XCTFail("a list that fails authentication is refused") + } catch { + XCTAssertEqual(error as? DeviceStorageError, .listRefused) + } + do { + try await registry.apply(.add(try syntheticDevice())) + XCTFail("a refused list is not overwritten") + } catch { + XCTAssertEqual(error as? DeviceStorageError, .listRefused) + } + XCTAssertEqual(library.keys.deviceItems[.pairedDevices], stored, "nothing repairs the item") + } + } + + func testAddingNeedsTheManagementSessionAndThisSessionsAuthorization() async throws { + let library = try makeLibrary() + let vault = library.vault + try vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + let registry = try vault.pairedDeviceRegistry() + let device = try syntheticDevice() + + await assertRefused(try await registry.apply(.add(device)), .pairingNotAuthorized, "no authorization") + XCTAssertThrowsError(try vault.authorizeDevicePairing(with: device.deviceID, using: .deny), + "a cancelled authentication authorizes nothing") + await assertRefused(try await registry.apply(.add(device)), .pairingNotAuthorized, "after a cancelled authentication") + try vault.authorizeDevicePairing(with: try syntheticDevice().deviceID, using: .allow) + await assertRefused(try await registry.apply(.add(device)), .pairingNotAuthorized, "authorized for another device") + XCTAssertNil(library.keys.deviceItems[.pairedDevices], "nothing was stored") + + try vault.authorizeDevicePairing(with: device.deviceID, using: .allow) + vault.endManagementSession() + do { + try await registry.apply(.add(device)) + XCTFail("an authorization does not outlive the management session") + } catch VaultError.managementAuthenticationRequired { + } catch { + XCTFail("unexpected \(error)") + } + XCTAssertThrowsError(try vault.authorizeDevicePairing(with: device.deviceID, using: .allow), + "authorizing needs the management session") + XCTAssertNil(library.keys.deviceItems[.pairedDevices]) + try vault.beginManagementSession(using: .allow) + await assertRefused(try await registry.apply(.add(device)), .pairingNotAuthorized, + "an authorization from an ended management session is gone") + + try vault.authorizeDevicePairing(with: device.deviceID, using: .allow) + try await registry.apply(.add(device)) + let second = try syntheticDevice(name: "Synthetic Second Phone") + await assertRefused(try await registry.apply(.add(second)), .pairingNotAuthorized, "the authorization is used up") + // Pairing again replaces the entry and its pairing ID; that is an add too. + let repaired = PairedDevice(card: device.card, pairingID: second.pairingID, displayName: "Synthetic Phone", + pairedAt: device.pairedAt) + await assertRefused(try await registry.apply(.add(repaired)), .pairingNotAuthorized, "re-pairing") + + // Renaming, delivery records and removal change no trust upward. + vault.endManagementSession() + try await registry.apply(.rename(device.deviceID, pairingID: device.pairingID, displayName: "Renamed Phone")) + try await registry.apply(.recordReached(device.deviceID, pairingID: device.pairingID, at: Date())) + let renamed = try await registry.list().devices.first?.displayName + XCTAssertEqual(renamed, "Renamed Phone") + let removal = try await registry.apply(.remove(device.deviceID, pairingID: device.pairingID)) + XCTAssertEqual(removal, .applied) + let remaining = try await registry.list().devices + XCTAssertTrue(remaining.isEmpty) + } + + func testAnAuthorizationEndsWithItsManagementSessionEvenWithoutACheckInBetween() async throws { + let library = try makeLibrary() + let vault = library.vault + try vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + let registry = try vault.pairedDeviceRegistry() + let device = try syntheticDevice() + try vault.authorizeDevicePairing(with: device.deviceID, using: .allow) + vault.endManagementSession() + try vault.beginManagementSession(using: .allow) + await assertRefused(try await registry.apply(.add(device)), .pairingNotAuthorized, + "a new management session does not inherit the old authorization") + XCTAssertNil(library.keys.deviceItems[.pairedDevices]) + + try vault.authorizeDevicePairing(with: device.deviceID, using: .allow) + vault.lock() + try library.reopen() + try vault.beginManagementSession(using: .allow) + await assertRefused(try await vault.pairedDeviceRegistry().apply(.add(device)), .pairingNotAuthorized, + "locking ends the authorization too") + } + + func testListChangePreparedBeforeARemovalDoesNotRestoreTheDevice() async throws { + let library = try makeLibrary() + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + let device = try syntheticDevice() + try await add(device, to: library) + let registry = try library.vault.pairedDeviceRegistry() + try await registry.apply(.remove(device.deviceID, pairingID: device.pairingID)) + let rename = try await registry.apply(.rename(device.deviceID, pairingID: device.pairingID, displayName: "Late")) + XCTAssertEqual(rename, .dropped) + let reached = try await registry.apply(.recordReached(device.deviceID, pairingID: device.pairingID, at: Date())) + XCTAssertEqual(reached, .dropped) + let remaining = try await registry.list().devices + XCTAssertTrue(remaining.isEmpty) + } + + func testKeyItemsCarryATagOverTheAccountAndBlobAndForeignItemsAreRefused() throws { + let library = try makeLibrary() + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + let item = try XCTUnwrap(library.keys.deviceItems[.agreementKey]) + let blob = Data("synthetic-agreement-blob".utf8) + XCTAssertEqual(item.prefix(item.count - 32), blob) + let key = HKDF.deriveKey(inputKeyMaterial: try library.key, salt: Data(), + info: Data("AskKey device key blob key v1".utf8) + Data([0]), + outputByteCount: 32) + let expected = HMAC.authenticationCode( + for: Data("AskKey device key blob v1".utf8) + Data([0]) + Data("device-agreement-key".utf8) + Data([0]) + blob, + using: key) + XCTAssertEqual(Data(item.suffix(32)), Data(expected)) + + let stored = library.keys.deviceItems + let foreignKey = HKDF.deriveKey(inputKeyMaterial: SymmetricKey(size: .bits256), salt: Data(), + info: Data("AskKey device key blob key v1".utf8) + Data([0]), + outputByteCount: 32) + let foreignTag = Data(HMAC.authenticationCode( + for: Data("AskKey device key blob v1".utf8) + Data([0]) + Data("device-agreement-key".utf8) + Data([0]) + blob, + using: foreignKey)) + // A planted raw blob, a blob tagged under another key, and items moved between accounts. + for (account, planted) in [(DeviceKeychainAccount.messageKey, Data("synthetic-planted-blob".utf8)), + (.agreementKey, blob + foreignTag), + (.approvalKey, try XCTUnwrap(stored[.messageKey]))] { + library.keys.deviceItems = stored + library.keys.deviceItems[account] = planted + XCTAssertThrowsError(try library.vault.storedDeviceIdentityKeyBlobs(), "\(account)") { + XCTAssertEqual($0 as? DeviceStorageError, .identityRefused) + } + } + library.keys.deviceItems = stored + XCTAssertEqual(try library.vault.storedDeviceIdentityKeyBlobs(), syntheticBlobs()) + } + + func testAnOpenPairingSheetsCheckRenewsTheManagementSession() async throws { + let clock = DeviceTestClock() + let library = try AgentAccessPolicyLibrary(now: { clock.now }) + libraries.append(library) + try library.vault.beginManagementSession(using: .allow) + for _ in 0..<3 { + clock.now = clock.now.addingTimeInterval(Vault.managementSessionIdleLimit - 30) + XCTAssertNoThrow(try library.vault.requireDeviceManagementSession()) + } + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + let device = try syntheticDevice() + try library.vault.authorizeDevicePairing(with: device.deviceID, using: .allow) + clock.now = clock.now.addingTimeInterval(Vault.managementSessionIdleLimit + 1) + XCTAssertThrowsError(try library.vault.requireDeviceManagementSession(), "idle expiry still applies without checks") + try library.vault.beginManagementSession(using: .allow) + await assertRefused(try await library.vault.pairedDeviceRegistry().apply(.add(device)), .pairingNotAuthorized, + "the failed check dropped the authorization") + } + + func testIdentityIsNeverReplacedAndAPartialIdentityIsUnusable() throws { + let library = try makeLibrary() + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + XCTAssertThrowsError(try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs())) { + XCTAssertEqual($0 as? DeviceStorageError, .identityExists) + } + library.keys.deviceItems[.approvalKey] = nil + XCTAssertThrowsError(try library.vault.storedDeviceIdentityKeyBlobs()) { + XCTAssertEqual($0 as? DeviceStorageError, .incompleteIdentity) + } + library.vault.endManagementSession() + XCTAssertThrowsError(try library.vault.resetDeviceIdentity(), "resetting needs the management session") + } + + func testCreatingAnIdentityNeedsTheManagementSessionAndDropsAListWithoutIdentity() throws { + let library = try makeLibrary() + library.keys.deviceItems[.pairedDevices] = Data("synthetic-stale-list".utf8) + library.vault.endManagementSession() + XCTAssertThrowsError(try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs())) + XCTAssertNil(try library.vault.storedDeviceIdentityKeyBlobs()) + try library.vault.beginManagementSession(using: .allow) + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + XCTAssertNil(library.keys.deviceItems[.pairedDevices]) + } + + func testResetDeletesKeysListAndInboxStateAndClosesTheOldRegistry() async throws { + let library = try makeLibrary() + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + let device = try syntheticDevice() + try await add(device, to: library) + try library.vault.saveDeviceInboxState(DeviceInboxState(listAccount: "_synthetic-account", inboxToken: Data([1]))) + let old = try library.vault.pairedDeviceRegistry() + + try library.vault.resetDeviceIdentity() + + XCTAssertTrue(library.keys.deviceItems.isEmpty) + XCTAssertEqual(try library.vault.loadDeviceInboxState(), DeviceInboxState()) + do { + _ = try await old.apply(.rename(device.deviceID, pairingID: device.pairingID, displayName: "Late")) + XCTFail("a registry from before the reset writes nothing") + } catch { + XCTAssertEqual(error as? DeviceStorageError, .servicesStopped) + } + XCTAssertTrue(library.keys.deviceItems.isEmpty) + XCTAssertFalse(old === (try library.vault.pairedDeviceRegistry())) + } + + func testNoDeviceOperationReadsWritesOrDeletesTheVaultKeyItem() async throws { + let library = try makeLibrary() + let mutations = library.keys.mutations + let policyWrites = library.keys.policyWrites + library.keys.appLoadFailure = SyntheticPolicyFailure() + + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + _ = try library.vault.storedDeviceIdentityKeyBlobs() + let device = try syntheticDevice() + try await add(device, to: library) + let registry = try library.vault.pairedDeviceRegistry() + _ = try await registry.list() + try await registry.apply(.remove(device.deviceID, pairingID: device.pairingID)) + try library.vault.saveDeviceInboxState(DeviceInboxState(listAccount: "_synthetic-account")) + _ = try library.vault.loadDeviceInboxState() + try library.vault.resetDeviceIdentity() + + XCTAssertEqual(library.keys.mutations, mutations, "the vault-key items were not written or deleted") + XCTAssertEqual(library.keys.policyWrites, policyWrites) + XCTAssertNotNil(library.keys.appKey) + } + + func testLocalEraseStopsDeviceServicesFirstThenRemovesAllFourItems() async throws { + let library = try makeLibrary() + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + let device = try syntheticDevice() + try await add(device, to: library) + try library.vault.saveDeviceInboxState(DeviceInboxState(listAccount: "_synthetic-account")) + let registry = try library.vault.pairedDeviceRegistry() + let keys = library.keys + let itemsWhenStopped = LockedCount() + library.vault.setDeviceServicesStopHandler { + itemsWhenStopped.set(keys.deviceItems.count) + } + + try library.vault.eraseLocalLibrary(confirmation: LocalVaultEraseLanguage.english.confirmationText, + language: .english, using: .allow) + + XCTAssertEqual(itemsWhenStopped.value, 4, "the services stop before anything is deleted") + XCTAssertTrue(library.keys.deviceItems.isEmpty, "the keys and the list are gone") + XCTAssertFalse(FileManager.default.fileExists(atPath: library.paths.directory.path)) + do { + try await registry.apply(.rename(device.deviceID, pairingID: device.pairingID, displayName: "Late")) + XCTFail("a list change that started before the erase writes nothing afterwards") + } catch {} + XCTAssertTrue(library.keys.deviceItems.isEmpty) + } + + func testAnInterruptedEraseKeepsDeviceStorageClosedUntilItFinishes() async throws { + let library = try makeLibrary() + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + let journal = FileLocalVaultEraseJournalStore(url: library.vault.localEraseJournalURL) + try journal.save(.operationsQuiesced) + XCTAssertThrowsError(try library.vault.storedDeviceIdentityKeyBlobs()) { + XCTAssertEqual($0 as? DeviceStorageError, .eraseInProgress) + } + XCTAssertThrowsError(try library.vault.pairedDeviceRegistry()) + try journal.clear() + XCTAssertEqual(try library.vault.storedDeviceIdentityKeyBlobs(), syntheticBlobs()) + } + + func testBackupExportContainsNoDeviceItem() async throws { + let library = try makeLibrary() + try SyntheticBackupLibrary.populate(library.vault) + try library.vault.saveDeviceIdentityKeyBlobs(syntheticBlobs()) + let device = try syntheticDevice(name: "Synthetic Backup Phone") + try await add(device, to: library) + try library.vault.saveDeviceInboxState(DeviceInboxState(listAccount: "_synthetic-account-name")) + + let export = try library.vault.exportBackup(using: .allow) + let document = try BackupFileForge.document(of: export) + XCTAssertEqual(Set(document.keys), ["createdAt", "appVersion", "groups", "credentials"]) + let plaintext = String(decoding: try JSONSerialization.data(withJSONObject: document), as: UTF8.self) + for marker in ["Synthetic Backup Phone", "synthetic-message-blob", "_synthetic-account-name", + device.card.bytes.base64EncodedString(), device.pairingID.rawValue.base64EncodedString()] { + XCTAssertFalse(plaintext.contains(marker), "the backup carries no device data: \(marker)") + } + } + + func testDeviceItemConflictsNameTheirItemAndTheManualRecovery() { + XCTAssertEqual(Set(DeviceKeychainAccount.allCases.map(\.rawValue)), + ["device-message-key", "device-agreement-key", "device-approval-key", "paired-devices"]) + for account in DeviceKeychainAccount.allCases { + let message = account.itemKind.conflictMessage + XCTAssertEqual(AppKeyStoreError.conflictingItem(account.itemKind).errorDescription, message) + for fragment in [account.rawValue, "login.keychain-db", "Keychain Access", "Do not remove vault-key items"] { + XCTAssertTrue(message.contains(fragment), "\(account): \(fragment)") + } + } + } + + private func assertRefused(_ expression: @autoclosure () async throws -> PairedDeviceChangeOutcome, + _ expected: DeviceStorageError, _ message: String, + file: StaticString = #filePath, line: UInt = #line) async { + do { + _ = try await expression() + XCTFail("expected \(expected): \(message)", file: file, line: line) + } catch { + XCTAssertEqual(error as? DeviceStorageError, expected, message, file: file, line: line) + } + } +} + +private final class DeviceTestClock: @unchecked Sendable { + private let lock = NSLock() + private var current = Date(timeIntervalSince1970: 1_800_000_000) + var now: Date { + get { lock.withLock { current } } + set { lock.withLock { current = newValue } } + } +} + +private final class LockedCount: @unchecked Sendable { + private let lock = NSLock() + private var stored: Int? + var value: Int? { lock.withLock { stored } } + func set(_ value: Int) { lock.withLock { stored = value } } +} diff --git a/Tests/AskKeyVaultTests/IsolatedDeviceKeyStoreTests.swift b/Tests/AskKeyVaultTests/IsolatedDeviceKeyStoreTests.swift new file mode 100644 index 00000000..3b3b710b --- /dev/null +++ b/Tests/AskKeyVaultTests/IsolatedDeviceKeyStoreTests.swift @@ -0,0 +1,47 @@ +import Foundation +import XCTest +@testable import AskKeyUnitTestSupport +@testable import AskKeyVault + +/// Isolated Debug runs keep the device items as owner-only files beside the +/// other synthetic key material. +final class IsolatedDeviceKeyStoreTests: AskKeyCoreTestCase { +#if DEBUG + func testIsolatedRunStoresEachDeviceItemSeparatelyFromThePolicy() throws { + let candidate = FileManager.default.temporaryDirectory + .appendingPathComponent("AskKeyIsolatedDevices-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: candidate) } + let root = try physicalTestDirectory(candidate) + let environment = AskKeyTestEnvironment(overrides: ["ASKKEY_DEBUG_RUN_DIRECTORY": root.path]) + defer { environment.restore() } + let keys = try IsolatedAppKeyStore(directory: root, legacyService: "synthetic.legacy", + pendingService: "synthetic.pending", appService: "synthetic.app") + + try keys.saveAgentAccessPolicy(Data("synthetic-policy".utf8)) + for account in DeviceKeychainAccount.allCases { + XCTAssertNil(try keys.loadDeviceItem(account)) + try keys.saveDeviceItem(Data("synthetic-\(account.rawValue)".utf8), account: account) + } + for account in DeviceKeychainAccount.allCases { + XCTAssertEqual(try keys.loadDeviceItem(account), Data("synthetic-\(account.rawValue)".utf8)) + } + XCTAssertEqual(try keys.loadAgentAccessPolicy(), Data("synthetic-policy".utf8)) + let files = try FileManager.default.contentsOfDirectory(at: root.appendingPathComponent("key-material"), + includingPropertiesForKeys: nil) + XCTAssertEqual(files.count, 5) + for file in files { + let mode = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? Int) + XCTAssertEqual(mode, 0o600) + } + XCTAssertThrowsError(try keys.saveDeviceItem(Data(count: 65_537), account: .pairedDevices)) + + try keys.saveDeviceItem(Data("synthetic-replacement".utf8), account: .pairedDevices) + XCTAssertEqual(try keys.loadDeviceItem(.pairedDevices), Data("synthetic-replacement".utf8)) + for account in DeviceKeychainAccount.allCases { try keys.deleteDeviceItem(account) } + for account in DeviceKeychainAccount.allCases { XCTAssertNil(try keys.loadDeviceItem(account)) } + XCTAssertEqual(try keys.loadAgentAccessPolicy(), Data("synthetic-policy".utf8)) + } +#endif +} diff --git a/Tests/AskKeyVaultTests/KeychainNativeTestSupport.swift b/Tests/AskKeyVaultTests/KeychainNativeTestSupport.swift index d9a9762f..5238194b 100644 --- a/Tests/AskKeyVaultTests/KeychainNativeTestSupport.swift +++ b/Tests/AskKeyVaultTests/KeychainNativeTestSupport.swift @@ -57,6 +57,10 @@ final class KeychainNativeTestSupport { permissive: permissive) } + func addItem(_ data: Data, account: String, outside: Bool = false, permissive: Bool = false) throws -> NativeItem { + try add(data, service: service, account: account, outside: outside, permissive: permissive) + } + func addVaultKey(_ data: Data, suffix: String = "", outside: Bool = false) throws { _ = try add(data, service: service + suffix, account: "vault-key", outside: outside) } @@ -71,10 +75,11 @@ final class KeychainNativeTestSupport { return try XCTUnwrap(result as? Data) } - func assertOldItemReplaced(_ item: NativeItem, file: StaticString = #filePath, line: UInt = #line) throws { + func assertOldItemReplaced(_ item: NativeItem, account: String = "agent-access-policy", + file: StaticString = #filePath, line: UInt = #line) throws { // File-keychain references may resolve the new item by its primary key. // A new item loses this unrelated attribute; a data/ACL update keeps it. - var query = query(service: service, account: "agent-access-policy", outside: false) + var query = query(service: service, account: account, outside: false) query[kSecReturnAttributes as String] = true var result: AnyObject? try check(SecItemCopyMatching(query as CFDictionary, &result)) @@ -95,7 +100,11 @@ final class KeychainNativeTestSupport { } func assertAppOnlyPolicy(file: StaticString = #filePath, line: UInt = #line) throws { - var query = query(service: service, account: "agent-access-policy", outside: false) + try assertAppOnlyItem(account: "agent-access-policy", file: file, line: line) + } + + func assertAppOnlyItem(account: String, file: StaticString = #filePath, line: UInt = #line) throws { + var query = query(service: service, account: account, outside: false) query[kSecReturnRef as String] = true var result: AnyObject? try check(SecItemCopyMatching(query as CFDictionary, &result)) diff --git a/Tests/AskKeyVaultTests/VaultBootstrapTests.swift b/Tests/AskKeyVaultTests/VaultBootstrapTests.swift index 36427270..253083f6 100644 --- a/Tests/AskKeyVaultTests/VaultBootstrapTests.swift +++ b/Tests/AskKeyVaultTests/VaultBootstrapTests.swift @@ -480,6 +480,17 @@ final class MemoryAppKeyStore: AppKeyStore { policy = data } func deleteAgentAccessPolicy() throws { policyDeletes += 1; policy = nil } + var deviceItems: [DeviceKeychainAccount: Data] = [:] + var deviceItemFailure: Error? + func loadDeviceItem(_ account: DeviceKeychainAccount) throws -> Data? { + if let deviceItemFailure { throw deviceItemFailure } + return deviceItems[account] + } + func saveDeviceItem(_ data: Data, account: DeviceKeychainAccount) throws { + if let deviceItemFailure { throw deviceItemFailure } + deviceItems[account] = data + } + func deleteDeviceItem(_ account: DeviceKeychainAccount) throws { deviceItems[account] = nil } } func directoryBytes(_ directory: URL) throws -> [String: Data] {