diff options
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | ios/MullvadVPN/AutomaticKeyRotationManager.swift | 221 | ||||
| -rw-r--r-- | ios/MullvadVPN/KeychainItemRevision.swift | 70 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelConfigurationCoder.swift | 30 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelConfigurationManager.swift | 344 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelManager.swift | 237 | ||||
| -rw-r--r-- | ios/MullvadVPN/WireguardKeysViewController.swift | 17 | ||||
| -rw-r--r-- | ios/MullvadVPN/WireguardPrivateKey.swift | 2 | ||||
| -rw-r--r-- | ios/PacketTunnel/PacketTunnelProvider.swift | 102 |
9 files changed, 691 insertions, 348 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 6a58a1afdd..81e872570b 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ 587AD7CA2342283900E93A53 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587AD7C92342283900E93A53 /* Account.swift */; }; 587B08E0229433EB000E6F17 /* LoginState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B08DF229433EB000E6F17 /* LoginState.swift */; }; 587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */; }; + 588534BF246193D90018B744 /* AutomaticKeyRotationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588534BD246193C00018B744 /* AutomaticKeyRotationManager.swift */; }; 5888AD7F2279B6BF0051EB06 /* RelayStatusIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD7E2279B6BF0051EB06 /* RelayStatusIndicatorView.swift */; }; 5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD82227B11080051EB06 /* SelectLocationCell.swift */; }; 5888AD87227B17950051EB06 /* SelectLocationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD86227B17950051EB06 /* SelectLocationController.swift */; }; @@ -85,8 +86,6 @@ 58ADDB3E227B1CD900FAFEA7 /* MullvadRpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58ADDB3D227B1CD900FAFEA7 /* MullvadRpc.swift */; }; 58AEEF652344A36000C9BBD5 /* KeychainError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58AEEF642344A36000C9BBD5 /* KeychainError.swift */; }; 58AEEF662344A37400C9BBD5 /* KeychainError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58AEEF642344A36000C9BBD5 /* KeychainError.swift */; }; - 58AEEF682344A40800C9BBD5 /* TunnelConfigurationCoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58AEEF672344A40800C9BBD5 /* TunnelConfigurationCoder.swift */; }; - 58AEEF692344A43A00C9BBD5 /* TunnelConfigurationCoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58AEEF672344A40800C9BBD5 /* TunnelConfigurationCoder.swift */; }; 58AEEF6B2344A46200C9BBD5 /* TunnelConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58AEEF6A2344A46200C9BBD5 /* TunnelConfigurationManager.swift */; }; 58AEEF6C2344A49D00C9BBD5 /* TunnelConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58AEEF6A2344A46200C9BBD5 /* TunnelConfigurationManager.swift */; }; 58B0A2A8238EE68200BC001D /* RelaySelectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584B26F3237434D00073B10E /* RelaySelectorTests.swift */; }; @@ -135,6 +134,8 @@ 58DF28A52417CB4B00E836B0 /* AppStorePaymentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DF28A42417CB4B00E836B0 /* AppStorePaymentManager.swift */; }; 58EC4E6C23915325003F5C5B /* Bundle+MullvadVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EC4E6B23915325003F5C5B /* Bundle+MullvadVersion.swift */; }; 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */; }; + 58F840AF2464382C0044E708 /* KeychainItemRevision.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F840AE2464382C0044E708 /* KeychainItemRevision.swift */; }; + 58F840B02464382C0044E708 /* KeychainItemRevision.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F840AE2464382C0044E708 /* KeychainItemRevision.swift */; }; 58FAEDEF245069C700CB0F5B /* KeychainAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */; }; 58FAEDF1245069CA00CB0F5B /* KeychainAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */; }; 58FAEDF4245088B300CB0F5B /* KeychainError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58AEEF642344A36000C9BBD5 /* KeychainError.swift */; }; @@ -237,6 +238,7 @@ 587AD7C92342283900E93A53 /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = "<group>"; }; 587B08DF229433EB000E6F17 /* LoginState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginState.swift; sourceTree = "<group>"; }; 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Helpers.swift"; sourceTree = "<group>"; }; + 588534BD246193C00018B744 /* AutomaticKeyRotationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticKeyRotationManager.swift; sourceTree = "<group>"; }; 5888AD7E2279B6BF0051EB06 /* RelayStatusIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayStatusIndicatorView.swift; sourceTree = "<group>"; }; 5888AD82227B11080051EB06 /* SelectLocationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationCell.swift; sourceTree = "<group>"; }; 5888AD86227B17950051EB06 /* SelectLocationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationController.swift; sourceTree = "<group>"; }; @@ -252,7 +254,6 @@ 58ADDB3B227B1BD200FAFEA7 /* JsonRpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonRpc.swift; sourceTree = "<group>"; }; 58ADDB3D227B1CD900FAFEA7 /* MullvadRpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadRpc.swift; sourceTree = "<group>"; }; 58AEEF642344A36000C9BBD5 /* KeychainError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainError.swift; sourceTree = "<group>"; }; - 58AEEF672344A40800C9BBD5 /* TunnelConfigurationCoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelConfigurationCoder.swift; sourceTree = "<group>"; }; 58AEEF6A2344A46200C9BBD5 /* TunnelConfigurationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelConfigurationManager.swift; sourceTree = "<group>"; }; 58B0A2A0238EE67E00BC001D /* MullvadVPNTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MullvadVPNTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 58B0A2A4238EE67E00BC001D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; @@ -293,6 +294,7 @@ 58EC4E6B23915325003F5C5B /* Bundle+MullvadVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+MullvadVersion.swift"; sourceTree = "<group>"; }; 58ECD29123F178FD004298B6 /* Screenshots.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Screenshots.xcconfig; sourceTree = "<group>"; }; 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerActivityIndicatorView.swift; sourceTree = "<group>"; }; + 58F840AE2464382C0044E708 /* KeychainItemRevision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainItemRevision.swift; sourceTree = "<group>"; }; 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainAttributes.swift; sourceTree = "<group>"; }; 58FAEDF6245088E100CB0F5B /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; }; 58FAEDFC24533A5500CB0F5B /* KeychainMatchLimit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainMatchLimit.swift; sourceTree = "<group>"; }; @@ -406,6 +408,7 @@ 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */, 58CE5E6A224146210008646E /* Assets.xcassets */, 5845F839236C6A7200B2D93C /* AutoDisposableSink.swift */, + 588534BD246193C00018B744 /* AutomaticKeyRotationManager.swift */, 589AB4F6227B64450039131E /* BasicTableViewCell.swift */, 58EC4E6B23915325003F5C5B /* Bundle+MullvadVersion.swift */, 584E96B9240D791E00D3334F /* CancellableDelayPublisher.swift */, @@ -425,6 +428,7 @@ 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */, 58FAEE0024533A9C00CB0F5B /* KeychainClass.swift */, 58AEEF642344A36000C9BBD5 /* KeychainError.swift */, + 58F840AE2464382C0044E708 /* KeychainItemRevision.swift */, 58FAEDFC24533A5500CB0F5B /* KeychainMatchLimit.swift */, 58FAEDFE24533A7000CB0F5B /* KeychainReturn.swift */, 58CE5E6C224146210008646E /* LaunchScreen.storyboard */, @@ -467,7 +471,6 @@ 5807E2BF2432038B00F5FF30 /* String+Split.swift */, 5862805322428EF100F5A6E1 /* TranslucentButtonBlurView.swift */, 587AD7C523421D7000E93A53 /* TunnelConfiguration.swift */, - 58AEEF672344A40800C9BBD5 /* TunnelConfigurationCoder.swift */, 58AEEF6A2344A46200C9BBD5 /* TunnelConfigurationManager.swift */, 5845F837236C466400B2D93C /* TunnelControlViewController.swift */, 5835B7CB233B76CB0096D79F /* TunnelManager.swift */, @@ -849,6 +852,7 @@ 58FD5BF624291F1A00112C88 /* AppStorePaymentPublisher.swift in Sources */, 587AD7CA2342283900E93A53 /* Account.swift in Sources */, 58A8BE8323A0F362006B74AC /* UIAlertController+Error.swift in Sources */, + 58F840AF2464382C0044E708 /* KeychainItemRevision.swift in Sources */, 587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */, 588AE72F2362001F009F9F2E /* MutuallyExclusive.swift in Sources */, 5888AD89227B18C40051EB06 /* RelayList.swift in Sources */, @@ -857,7 +861,6 @@ 58561C99239A5D1500BD6B5E /* IPEndpoint.swift in Sources */, 58FD5BF22424F7D700112C88 /* UserInterfaceInteractionRestriction.swift in Sources */, 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */, - 58AEEF682344A40800C9BBD5 /* TunnelConfigurationCoder.swift in Sources */, 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */, 58FAEDFF24533A7000CB0F5B /* KeychainReturn.swift in Sources */, ); @@ -874,6 +877,7 @@ 58BFA5CD22A7CE1F00A6173D /* ApplicationConfiguration.swift in Sources */, 58BFA5C222A7C92900A6173D /* JsonRpc.swift in Sources */, 588AE730236200E2009F9F2E /* MutuallyExclusive.swift in Sources */, + 58F840B02464382C0044E708 /* KeychainItemRevision.swift in Sources */, 58C6B35122BB7CFD003C19AD /* IPAddressRange.swift in Sources */, 587AD7C723421D8600E93A53 /* TunnelConfiguration.swift in Sources */, 58BFA5C322A7C93400A6173D /* RelayList.swift in Sources */, @@ -898,12 +902,12 @@ 58BFA5C722A7C97F00A6173D /* RelayCache.swift in Sources */, 58BFA5C022A7C8A900A6173D /* MullvadRpc.swift in Sources */, 58906DE02445C7A5002F0673 /* NEProviderStopReason+Debug.swift in Sources */, - 58AEEF692344A43A00C9BBD5 /* TunnelConfigurationCoder.swift in Sources */, 584E96BD240FD4DA00D3334F /* Location.swift in Sources */, 58FAEDF8245088E100CB0F5B /* Keychain.swift in Sources */, 58C6B36122C0EC82003C19AD /* AnyIPEndpoint+DNS64.swift in Sources */, 58C6B36722C106FC003C19AD /* WireguardCommand.swift in Sources */, 58561C9A239A5D1500BD6B5E /* IPEndpoint.swift in Sources */, + 588534BF246193D90018B744 /* AutomaticKeyRotationManager.swift in Sources */, 584B26FF237435A90073B10E /* RelaySelector+RelayCache.swift in Sources */, 58781CCE22AE8918009B9D8E /* RelayConstraints.swift in Sources */, 58781CD522AFBA39009B9D8E /* RelaySelector.swift in Sources */, diff --git a/ios/MullvadVPN/AutomaticKeyRotationManager.swift b/ios/MullvadVPN/AutomaticKeyRotationManager.swift new file mode 100644 index 0000000000..ee7cc6e4c7 --- /dev/null +++ b/ios/MullvadVPN/AutomaticKeyRotationManager.swift @@ -0,0 +1,221 @@ +// +// AutomaticKeyRotationManager.swift +// MullvadVPN +// +// Created by pronebird on 05/05/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation +import os + +/// A private key rotation retry interval on failure (in seconds) +private let retryIntervalOnFailure = 300 + +/// A private key rotation interval (in days) +private let rotationInterval = 1 + +class AutomaticKeyRotationManager { + + enum Error: Swift.Error { + /// An RPC failure + case rpc(MullvadRpc.Error) + + /// A failure to read the tunnel configuration + case readTunnelConfiguration(TunnelConfigurationManager.Error) + + /// A failure to update tunnel configuration + case updateTunnelConfiguration(TunnelConfigurationManager.Error) + + var localizedDescription: String { + switch self { + case .rpc(let error): + return "Rpc error: \(error.localizedDescription)" + case .readTunnelConfiguration(let error): + return "Read configuration error: \(error.localizedDescription)" + case .updateTunnelConfiguration(let error): + return "Update configuration error: \(error.localizedDescription)" + } + } + } + + struct KeyRotationEvent { + var isNew: Bool + var creationDate: Date + var publicKey: WireguardPublicKey + } + + private let rpc = MullvadRpc() + private let persistentKeychainReference: Data + private var rotateKeySubscriber: AnyCancellable? + + private let dispatchQueue = DispatchQueue(label: "net.mullvad.vpn.key-manager", qos: .background) + private var retryWorkItem: DispatchWorkItem? + + private var isAutomaticRotationEnabled = false + + private let lock = NSLock() + private var _keyRotationEventHandler: ((KeyRotationEvent) -> Void)? + var keyRotationEventHandler: ((KeyRotationEvent) -> Void)? { + get { + lock.withCriticalBlock { + self._keyRotationEventHandler + } + } + set { + lock.withCriticalBlock { + self._keyRotationEventHandler = newValue + } + } + } + + init(persistentKeychainReference: Data) { + self.persistentKeychainReference = persistentKeychainReference + } + + func startAutomaticRotation() { + dispatchQueue.async { + guard !self.isAutomaticRotationEnabled else { return } + + os_log(.default, log: tunnelProviderLog, "Start automatic key rotation") + + self.isAutomaticRotationEnabled = true + self.performKeyRotation() + } + } + + func stopAutomaticRotation() { + dispatchQueue.async { + guard self.isAutomaticRotationEnabled else { return } + + os_log(.default, log: tunnelProviderLog, "Stop automatic key rotation") + + self.isAutomaticRotationEnabled = false + self.rotateKeySubscriber?.cancel() + self.retryWorkItem?.cancel() + } + } + + private func performKeyRotation() { + rotateKeySubscriber = tryRotatingPrivateKey() + .receive(on: dispatchQueue) + .sink(receiveCompletion: { [weak self] (completion) in + guard let self = self else { return } + + switch completion { + case .finished: + break + + case .failure(let error): + os_log(.error, log: tunnelProviderLog, + "Failed to rotate the private key: %{public}s. Retry in %d seconds.", + error.localizedDescription, + retryIntervalOnFailure) + + self.scheduleRetry(deadline: .now() + .seconds(retryIntervalOnFailure)) + } + }) { [weak self] (keyRotationEvent) in + guard let self = self else { return } + + if keyRotationEvent.isNew { + os_log(.default, log: tunnelProviderLog, "Finished private key rotation") + + self.keyRotationEventHandler?(keyRotationEvent) + } + + if let rotationDate = Self.nextRotation(creationDate: keyRotationEvent.creationDate) { + let interval = rotationDate.timeIntervalSinceNow + + os_log(.default, log: tunnelProviderLog, + "Next private key rotation on %{public}s", "\(rotationDate)") + + self.scheduleRetry(deadline: .now() + .seconds(Int(interval))) + } else { + os_log(.error, log: tunnelProviderLog, + "Failed to compute the next private rotation date. Retry in %d seconds.") + + self.scheduleRetry(deadline: .now() + .seconds(retryIntervalOnFailure)) + } + } + } + + private func scheduleRetry(deadline: DispatchTime) { + // cancel any pending work + retryWorkItem?.cancel() + + // schedule new work + let workItem = DispatchWorkItem { [weak self] in + self?.performKeyRotation() + } + retryWorkItem = workItem + dispatchQueue.asyncAfter(deadline: deadline, execute: workItem) + } + + private func tryRotatingPrivateKey() -> AnyPublisher<KeyRotationEvent, Error> { + return TunnelConfigurationManager + .load(searchTerm: .persistentReference(persistentKeychainReference)) + .mapError { .readTunnelConfiguration($0) } + .publisher + .flatMap { (keychainEntry) -> AnyPublisher<KeyRotationEvent, Error> in + let currentPrivateKey = keychainEntry.tunnelConfiguration.interface.privateKey + + if Self.shouldRotateKey(creationDate: currentPrivateKey.creationDate) { + return self.replaceWireguardKey( + accountToken: keychainEntry.accountToken, + oldPublicKey: currentPrivateKey.publicKey + ).map({ (newTunnelConfiguration) -> KeyRotationEvent in + let newPrivateKey = newTunnelConfiguration.interface.privateKey + + return KeyRotationEvent( + isNew: true, + creationDate: newPrivateKey.creationDate, + publicKey: newPrivateKey.publicKey + ) + }).eraseToAnyPublisher() + } else { + let result = KeyRotationEvent( + isNew: false, + creationDate: currentPrivateKey.creationDate, + publicKey: currentPrivateKey.publicKey + ) + + return Result.Publisher(result).eraseToAnyPublisher() + } + }.eraseToAnyPublisher() + } + + private func replaceWireguardKey(accountToken: String, oldPublicKey: WireguardPublicKey) + -> AnyPublisher<TunnelConfiguration, Error> + { + let newPrivateKey = WireguardPrivateKey() + + return rpc.replaceWireguardKey( + accountToken: accountToken, + oldPublicKey: oldPublicKey.rawRepresentation, + newPublicKey: newPrivateKey.publicKey.rawRepresentation) + .mapError { .rpc($0) } + .flatMap { (addresses) in + TunnelConfigurationManager + .update(searchTerm: .persistentReference(self.persistentKeychainReference)) + { (tunnelConfiguration) in + tunnelConfiguration.interface.privateKey = newPrivateKey + tunnelConfiguration.interface.addresses = [ + addresses.ipv4Address, + addresses.ipv6Address + ] + } + .mapError { .updateTunnelConfiguration($0) } + .publisher + }.eraseToAnyPublisher() + } + + class func nextRotation(creationDate: Date) -> Date? { + return Calendar.current.date(byAdding: .day, value: rotationInterval, to: creationDate) + } + + class func shouldRotateKey(creationDate: Date) -> Bool { + return nextRotation(creationDate: creationDate) + .map { $0 <= Date() } ?? false + } +} diff --git a/ios/MullvadVPN/KeychainItemRevision.swift b/ios/MullvadVPN/KeychainItemRevision.swift new file mode 100644 index 0000000000..6e73d1a6a8 --- /dev/null +++ b/ios/MullvadVPN/KeychainItemRevision.swift @@ -0,0 +1,70 @@ +// +// KeychainItemRevision.swift +// MullvadVPN +// +// Created by pronebird on 07/05/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// A struct that helps to organize revisions for Keychain items +/// Uses `Keychain.Attributes.generic` field for storing `Int64` revision number. +struct KeychainItemRevision { + let revision: Int64 + + /// Returns the `KeychainItemRevision` initialized with the next in sequence revision + var nextRevision: KeychainItemRevision { + return .init(revision: revision + 1) + } + + /// Initialize a struct with the given revision number + init(revision: Int64) { + self.revision = revision + } + + /// Initialize a struct from the given `Keychain.Attributes` + /// + /// Returns `nil` when `Keychain.Attributes.generic` is `nil` or when it's set to data that + /// cannot be interpreted as `Int64` + init?(attributes: Keychain.Attributes) { + let revision = attributes.generic.flatMap { Self.parseRevision(from: $0) } + if let revision = revision { + self.revision = revision + } else { + return nil + } + } + + /// Store the revision number in the given `Keychain.Attributes` + func store(in attributes: inout Keychain.Attributes) { + attributes.generic = asData() + } + + /// A convenience method to initialize the first revision in sequence + static func firstRevision() -> Self { + return .init(revision: 1) + } + + /// Serialize the revision number as `Data` + private func asData() -> Data { + return withUnsafeBytes(of: revision) { Data($0) } + } + + /// Private helper to parse `Data` into `Int64` + /// Returns `nil` if the given raw data does not match the size of `Int64` + private static func parseRevision(from rawData: Data) -> Int64? { + var value: Int64 = 0 + + // Ensure that the buffer has as many bytes as needed to fill the `Int64` + guard rawData.count == MemoryLayout.size(ofValue: value) else { + return nil + } + + _ = withUnsafeMutableBytes(of: &value) { (valuePointer) in + rawData.copyBytes(to: valuePointer) + } + + return value + } +} diff --git a/ios/MullvadVPN/TunnelConfigurationCoder.swift b/ios/MullvadVPN/TunnelConfigurationCoder.swift deleted file mode 100644 index 97500eccbb..0000000000 --- a/ios/MullvadVPN/TunnelConfigurationCoder.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// TunnelConfigurationCoder.swift -// MullvadVPN -// -// Created by pronebird on 02/10/2019. -// Copyright © 2019 Mullvad VPN AB. All rights reserved. -// - -import Foundation - -/// Tunnel configuration encoding and decoding helper -enum TunnelConfigurationCoder {} - -extension TunnelConfigurationCoder { - - enum Error: Swift.Error { - case encode(Swift.Error) - case decode(Swift.Error) - } - - static func decode(data: Data) -> Result<TunnelConfiguration, Error> { - return Result { try JSONDecoder().decode(TunnelConfiguration.self, from: data) } - .mapError { Error.decode($0) } - } - - static func encode(tunnelConfig: TunnelConfiguration) -> Result<Data, Error> { - return Result { try JSONEncoder().encode(tunnelConfig) } - .mapError { Error.encode($0) } - } -} diff --git a/ios/MullvadVPN/TunnelConfigurationManager.swift b/ios/MullvadVPN/TunnelConfigurationManager.swift index 563216a202..29b30befa2 100644 --- a/ios/MullvadVPN/TunnelConfigurationManager.swift +++ b/ios/MullvadVPN/TunnelConfigurationManager.swift @@ -12,203 +12,247 @@ import Security /// Service name used for keychain items private let kServiceName = "Mullvad VPN" -enum TunnelConfigurationManagerError: Error { - case encode(TunnelConfigurationCoder.Error) - case decode(TunnelConfigurationCoder.Error) - case addToKeychain(KeychainError) - case updateKeychain(KeychainError) - case removeKeychainItem(KeychainError) - case getFromKeychain(KeychainError) - case getPersistentKeychainRef(KeychainError) -} - enum TunnelConfigurationManager {} extension TunnelConfigurationManager { - static func save(configuration: TunnelConfiguration, account: String) -> Result<(), TunnelConfigurationManagerError> { - TunnelConfigurationCoder.encode(tunnelConfig: configuration) - .mapError { .encode($0) } - .flatMap { (data) -> Result<(), TunnelConfigurationManagerError> in - Keychain.updateItem(account: account, data: data) - .flatMapError { (keychainError) -> Result<(), TunnelConfigurationManagerError> in - if case .itemNotFound = keychainError { - return Keychain.addItem(account: account, data: data) - .mapError { .addToKeychain($0) } - } else { - return .failure(.updateKeychain(keychainError)) - } - } - } - } + enum Error: Swift.Error { + case encode(Swift.Error) + case decode(Swift.Error) + case addToKeychain(Keychain.Error) + case updateKeychain(Keychain.Error) + case removeKeychainItem(Keychain.Error) + case getFromKeychain(Keychain.Error) + case getPersistentKeychainRef(Keychain.Error) - static func load(account: String) -> Result<TunnelConfiguration, TunnelConfigurationManagerError> { - Keychain.getItemData(account: account) - .mapError { .getFromKeychain($0) } - .flatMap { (data) in - TunnelConfigurationCoder.decode(data: data) - .mapError { .decode($0) } + var localizedDescription: String { + switch self { + case .encode(let error): + return error.localizedDescription + case .decode(let error): + return error.localizedDescription + case .addToKeychain(let error): + return error.localizedDescription + case .updateKeychain(let error): + return error.localizedDescription + case .removeKeychainItem(let error): + return error.localizedDescription + case .getFromKeychain(let error): + return error.localizedDescription + case .getPersistentKeychainRef(let error): + return error.localizedDescription + } } } - static func load(persistentKeychainRef: Data) -> Result<TunnelConfiguration, TunnelConfigurationManagerError> { - Keychain.getItemData(persistentKeychainRef: persistentKeychainRef) - .mapError { .getFromKeychain($0) } - .flatMap { (data) in - TunnelConfigurationCoder.decode(data: data) - .mapError { .decode($0) } + typealias Result<T> = Swift.Result<T, Error> + + /// Keychain access level that should be used for all items containing tunnel configuration + private static let keychainAccessibleLevel = Keychain.Accessible.afterFirstUnlock + + enum KeychainSearchTerm { + case accountToken(String) + case persistentReference(Data) + + /// Returns `Keychain.Attributes` appropriate for adding or querying the item + fileprivate func makeKeychainAttributes() -> Keychain.Attributes { + var attributes = Keychain.Attributes() + attributes.class = .genericPassword + + switch self { + case .accountToken(let accountToken): + attributes.account = accountToken + attributes.service = kServiceName + + case .persistentReference(let persistentReference): + attributes.valuePersistentReference = persistentReference + } + + return attributes } } - static func remove(account: String) -> Result<(), TunnelConfigurationManagerError> { - Keychain.removeItem(account: account) - .mapError { .removeKeychainItem($0) } + struct KeychainEntry { + let accountToken: String + let tunnelConfiguration: TunnelConfiguration } - static func getPersistentKeychainRef(account: String) -> Result<Data, TunnelConfigurationManagerError> { - Keychain.getPersistentRef(account: account) - .mapError { .getPersistentKeychainRef($0) } - } + static func load(searchTerm: KeychainSearchTerm) -> Result<KeychainEntry> { + var query = searchTerm.makeKeychainAttributes() + query.return = [.data, .attributes] -} + return Keychain.findFirst(query: query) + .mapError { .getFromKeychain($0) } + .flatMap { (attributes) in + let attributes = attributes! + let account = attributes.account! + let data = attributes.valueData! -private enum Keychain {} + return Self.decode(data: data) + .map { KeychainEntry(accountToken: account, tunnelConfiguration: $0) } + } + } -private extension Keychain { + static func add(configuration: TunnelConfiguration, account: String) -> Result<()> { + Self.encode(tunnelConfig: configuration) + .flatMap { (data) -> Result<()> in + var attributes = KeychainSearchTerm.accountToken(account) + .makeKeychainAttributes() - /// A Keychain Result type - typealias Result<T> = Swift.Result<T, KeychainError> + // Share the item with the application group + attributes.accessGroup = ApplicationConfiguration.securityGroupIdentifier - /// List all of the account tokens in Keychain - static func listAccounts() -> Result<[String]> { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: kServiceName, - kSecReturnAttributes: true, - kSecMatchLimit: kSecMatchLimitAll, - ] + // Make sure the keychain item is available after the first unlock to enable + // automatic key rotation in background (from the packet tunnel process) + attributes.accessible = Self.keychainAccessibleLevel - return executeSecCopyMatching(query: query) - .map { (result) in - let attrs = result as! [[CFString: Any]] - let accountTokens = attrs.compactMap { (dict) in - dict[kSecAttrAccount] as? String - } + // Store value + attributes.valueData = data - return accountTokens + // Add revision + KeychainItemRevision.firstRevision().store(in: &attributes) + + return Keychain.add(attributes) + .mapError { .addToKeychain($0) } + .map { _ in () } } } - /// Get a persistent reference to the Keychain item for the given account token - static func getPersistentRef(account: String) -> Result<Data> { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: account, - kSecAttrService: kServiceName, - kSecReturnPersistentRef: true - ] + /// This is a migration path for the existing Keychain entries created by 2020.2 or before. + /// + /// - Set the appropriate `accessible` so that the Packet Tunnel can access the tunnel + /// configuration when the device is locked. + /// - Add revision field + /// + /// - Returns: A boolean that indicates whether the entry was up to date prior to the + /// migration request. - return executeSecCopyMatching(query: query) - .map { $0 as! Data } - } + static func migrateKeychainEntry(searchTerm: KeychainSearchTerm) -> Result<Bool> { + var queryAttributes = searchTerm.makeKeychainAttributes() + queryAttributes.return = [.attributes] - /// Get data associated with the given persistent Keychain reference - static func getItemData(persistentKeychainRef: Data) -> Result<Data> { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecValuePersistentRef: persistentKeychainRef, - kSecReturnData: true - ] + return Keychain.findFirst(query: queryAttributes) + .mapError { .getFromKeychain($0) } + .flatMap { (itemAttributes) -> Result<Bool> in + let itemAttributes = itemAttributes! - return executeSecCopyMatching(query: query) - .map { $0 as! Data } - } + let searchAttributes = searchTerm.makeKeychainAttributes() + var updateAttributes = Keychain.Attributes() + + // Add revision if it's missing + if KeychainItemRevision(attributes: itemAttributes) == nil { + KeychainItemRevision.firstRevision().store(in: &updateAttributes) + } - /// Get data associated with the given account token - static func getItemData(account: String) -> Result<Data> { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: account, - kSecAttrService: kServiceName, - kSecReturnData: true - ] + // Fix the accessibility permission for the Keychain entry + if itemAttributes.accessible != Self.keychainAccessibleLevel { + updateAttributes.accessible = Self.keychainAccessibleLevel + } - return executeSecCopyMatching(query: query) - .map { $0 as! Data } + // Return immediately if nothing to update (i.e the keychain query is empty) + if updateAttributes.keychainRepresentation().isEmpty { + return .success(false) + } else { + return Keychain.update(query: searchAttributes, update: updateAttributes) + .mapError { .updateKeychain($0) } + .map { true } + } + } } - /// Store data in the Keychain and associate it with the given account token - static func addItem(account: String, data: Data) -> Result<()> { - let attributes: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: account, - kSecAttrService: kServiceName, - kSecValueData: data, - kSecReturnData: false, + /// Reads the tunnel configuration from Keychain, then passes it to the given closure for + /// modifications, saves the result back to Keychain. + /// + /// The given block may run multiple times if Keychain entry was changed between read and write + /// operations. + static func update(searchTerm: KeychainSearchTerm, + using changeConfiguration: (inout TunnelConfiguration) -> Void) + -> Result<TunnelConfiguration> + { + while true { + var searchQuery = searchTerm.makeKeychainAttributes() + searchQuery.return = [.attributes, .data] - // Share the item with the application group - kSecAttrAccessGroup: ApplicationConfiguration.securityGroupIdentifier, - ] + let result = Keychain.findFirst(query: searchQuery) + .mapError { TunnelConfigurationManager.Error.getFromKeychain($0) } + .flatMap { (itemAttributes) -> Result<TunnelConfiguration> in + let itemAttributes = itemAttributes! + let serializedData = itemAttributes.valueData! + let account = itemAttributes.account! - let status = SecItemAdd(attributes as CFDictionary, nil) + // Parse the current revision from Keychain attributes + let currentRevision = KeychainItemRevision(attributes: itemAttributes) - return mapSecResult(status: status) { - () - } - } + // Pick the next revision in sequence + let nextRevision = currentRevision?.nextRevision + ?? KeychainItemRevision.firstRevision() - /// Replace the data associated with the given account token. - static func updateItem(account: String, data: Data) -> Result<()> { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: account, - kSecAttrService: kServiceName, - ] + return Self.decode(data: serializedData) + .flatMap { (tunnelConfig) -> Result<TunnelConfiguration> in + var tunnelConfig = tunnelConfig + changeConfiguration(&tunnelConfig) - let update: [CFString: Any] = [ - kSecValueData: data - ] + return Self.encode(tunnelConfig: tunnelConfig) + .flatMap { (newData) -> Result<TunnelConfiguration> in + // `SecItemUpdate` does not accept query parameters when using + // persistent reference, so constraint the query to account + // token instead now when we know it + var updateQuery = KeychainSearchTerm + .accountToken(account) + .makeKeychainAttributes() - let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + // Provide the last known revision via generic field to prevent + // overwriting the item if it was modified in the meanwhile. + // This field can be missing for the existing apps on AppStore + currentRevision?.store(in: &updateQuery) - return mapSecResult(status: status) { - () - } - } + var updateAttributes = Keychain.Attributes() + updateAttributes.valueData = newData - /// Remove the data associated with the given account token - static func removeItem(account: String) -> Result<()> { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: account, - kSecAttrService: kServiceName, - ] + // Add the next revision number + nextRevision.store(in: &updateAttributes) - let status = SecItemDelete(query as CFDictionary) + return Keychain.update(query: updateQuery, update: updateAttributes) + .mapError { TunnelConfigurationManager.Error.updateKeychain($0) } + .map { tunnelConfig } + } + } + } - return mapSecResult(status: status) { - () + // Retry if Keychain reported that the item was not found when updating + if case .failure(.updateKeychain(.itemNotFound)) = result { + continue + } else { + return result + } } } - /// A private helper that verifies the given `status` and executes `body` on success - static private func mapSecResult<T>(status: OSStatus, body: () -> T) -> Result<T> { - if status == errSecSuccess { - return .success(body()) - } else { - return .failure(KeychainError(code: status)) - } + static func remove(searchTerm: KeychainSearchTerm) -> Result<()> { + return Keychain.delete(query: searchTerm.makeKeychainAttributes()) + .mapError { .removeKeychainItem($0) } } - /// A private helper to execute the given query using `SecCopyMatching` and map the result to - /// the `Result<CFTypeRef?>` type. - static private func executeSecCopyMatching(query: [CFString: Any]) -> Result<CFTypeRef?> { - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) + /// Get a persistent reference to the Keychain item for the given account token + static func getPersistentKeychainReference(account: String) -> Result<Data> { + var query = KeychainSearchTerm.accountToken(account) + .makeKeychainAttributes() + query.return = [.persistentReference] - return mapSecResult(status: status) { - result + return Keychain.findFirst(query: query) + .mapError { .getPersistentKeychainRef($0) } + .map { (attributes) -> Data in + return attributes!.valuePersistentReference! } } + private static func encode(tunnelConfig: TunnelConfiguration) -> Result<Data> { + return Swift.Result { try JSONEncoder().encode(tunnelConfig) } + .mapError { .encode($0) } + } + + private static func decode(data: Data) -> Result<TunnelConfiguration> { + return Swift.Result { try JSONDecoder().decode(TunnelConfiguration.self, from: data) } + .mapError { .decode($0) } + } } diff --git a/ios/MullvadVPN/TunnelManager.swift b/ios/MullvadVPN/TunnelManager.swift index f90140491e..e418061cd8 100644 --- a/ios/MullvadVPN/TunnelManager.swift +++ b/ios/MullvadVPN/TunnelManager.swift @@ -32,13 +32,13 @@ enum TunnelManagerError: Error { case unsetAccount(UnsetAccountError) /// A failure to set the relay constraints - case setRelayConstraints(UpdateTunnelConfigurationError) + case setRelayConstraints(TunnelConfigurationManager.Error) /// A failure to get the relay constraints - case getRelayConstraints(TunnelConfigurationManagerError) + case getRelayConstraints(TunnelConfigurationManager.Error) /// A failure to get a public key used for Wireguard - case getWireguardPublicKey(TunnelConfigurationManagerError) + case getWireguardPublicKey(TunnelConfigurationManager.Error) /// A failure to re-generate a private key used for Wireguard case regenerateWireguardPrivateKey(RegenerateWireguardPrivateKeyError) @@ -74,7 +74,7 @@ extension TunnelManagerError: LocalizedError { case .setAccount(.pushWireguardKey(let pushError)), .regenerateWireguardPrivateKey(.replaceWireguardKey(let pushError)): switch pushError { - case .transport(.network(let urlError)): + case .network(let urlError): return urlError.localizedDescription case .server(let serverError): @@ -127,13 +127,13 @@ enum TunnelIpcRequestError: Error { enum SetAccountError: Error { /// A failure to make the tunnel configuration - case makeTunnelConfiguration(TunnelConfigurationManagerError) + case makeTunnelConfiguration(TunnelConfigurationManager.Error) /// A failure to update the tunnel configuration - case updateTunnelConfiguration(UpdateTunnelConfigurationError) + case updateTunnelConfiguration(TunnelConfigurationManager.Error) /// A failure to push the wireguard key - case pushWireguardKey(PushWireguardKeyError) + case pushWireguardKey(MullvadRpc.Error) /// A failure to set up a tunnel case setup(SetupTunnelError) @@ -144,34 +144,18 @@ enum UnsetAccountError: Error { case removeTunnel(Error) /// A failure to remove a tunnel configuration from Keychain - case removeTunnelConfiguration(TunnelConfigurationManagerError) + case removeTunnelConfiguration(TunnelConfigurationManager.Error) } enum RegenerateWireguardPrivateKeyError: Error { /// A failure to read the public Wireguard key from Keychain - case readPublicWireguardKey(TunnelConfigurationManagerError) + case readPublicWireguardKey(TunnelConfigurationManager.Error) /// A failure to replace the public Wireguard key - case replaceWireguardKey(PushWireguardKeyError) + case replaceWireguardKey(MullvadRpc.Error) /// A failure to update tunnel configuration - case updateTunnelConfiguration(UpdateTunnelConfigurationError) - - /// A failure to set up a tunnel - case setupTunnel(SetupTunnelError) -} - -enum PushWireguardKeyError: Error { - case transport(MullvadAPI.Error) - case server(MullvadAPI.ResponseError) -} - -enum UpdateTunnelConfigurationError: Error { - /// Unable to load the existing configuration - case loadTunnelConfiguration(TunnelConfigurationManagerError) - - /// Unable to save the configuration - case saveTunnelConfiguration(TunnelConfigurationManagerError) + case updateTunnelConfiguration(TunnelConfigurationManager.Error) } enum StartTunnelError: Error { @@ -193,7 +177,7 @@ enum SetupTunnelError: Error { case reloadTunnel(Error) /// Unable to obtain the keychain reference for the configuration - case obtainKeychainRef(TunnelConfigurationManagerError) + case obtainKeychainRef(TunnelConfigurationManager.Error) } enum LoadTunnelError: Error { @@ -306,7 +290,7 @@ class TunnelManager { /// A queue used for access synchronization to the TunnelManager members private let executionQueue = DispatchQueue(label: "net.mullvad.vpn.tunnel-manager.execution-queue") - private let apiClient = MullvadAPI() + private let rpc = MullvadRpc() private var tunnelProvider: TunnelProviderManagerType? private var tunnelIpc: PacketTunnelIpc? @@ -336,6 +320,11 @@ class TunnelManager { .receive(on: self.executionQueue) .flatMap { (tunnels) -> AnyPublisher<(), LoadTunnelError> in + // Migrate tunnel configuration if needed + if let accountToken = accountToken { + self.migrateTunnelConfiguration(accountToken: accountToken) + } + // No tunnels found. Save the account token. guard let tunnelProvider = tunnels?.first else { self.accountToken = accountToken @@ -427,21 +416,16 @@ class TunnelManager { // Send wireguard key to the server let publicKey = tunnelConfig.interface.privateKey.publicKey.rawRepresentation - return self.apiClient.pushWireguardKey(accountToken: accountToken, publicKey: publicKey) - .mapError { (networkError) -> SetAccountError in - return .pushWireguardKey(.transport(networkError)) - }.flatMap { (response: MullvadAPI.Response<WireguardAssociatedAddresses>) in - return response.result.publisher - .mapError { (serverError) -> SetAccountError in - return .pushWireguardKey(.server(serverError)) - } - }.flatMap { (addresses) in - return self.updateAssociatedAddresses( - accountToken: accountToken, - addresses: addresses - ).mapError { SetAccountError.updateTunnelConfiguration($0) } - .publisher - }.flatMap { setupTunnelPublisher }.eraseToAnyPublisher() + return self.rpc.pushWireguardKey(accountToken: accountToken, publicKey: publicKey) + .mapError { SetAccountError.pushWireguardKey($0) } + .flatMap { (addresses) in + self.updateAssociatedAddresses( + accountToken: accountToken, + addresses: addresses + ).mapError { SetAccountError.updateTunnelConfiguration($0) } + .publisher + .flatMap { _ in setupTunnelPublisher } + }.eraseToAnyPublisher() }.mapError { TunnelManagerError.setAccount($0) } }.eraseToAnyPublisher() } @@ -458,37 +442,30 @@ class TunnelManager { let removeKeychainConfigPublisher = Deferred { () -> AnyPublisher<(), UnsetAccountError> in // Load existing configuration - switch TunnelConfigurationManager.load(account: accountToken) { - case .success(let tunnelConfig): - let publicKey = tunnelConfig.interface + switch TunnelConfigurationManager.load(searchTerm: .accountToken(accountToken)) { + case .success(let keychainEntry): + let publicKey = keychainEntry.tunnelConfiguration + .interface .privateKey .publicKey .rawRepresentation // Remove configuration from Keychain - return TunnelConfigurationManager.remove(account: accountToken) + return TunnelConfigurationManager.remove(searchTerm: .accountToken(accountToken)) .mapError { UnsetAccountError.removeTunnelConfiguration($0) } .publisher .flatMap { // Remove WireGuard key from master - self.apiClient.removeWireguardKey( + self.rpc.removeWireguardKey( accountToken: accountToken, publicKey: publicKey ) .retry(1) - .map({ (response) -> () in - switch response.result { - case .success(let isRemoved): - os_log(.debug, "Removed the WireGuard key from server: %{public}s", "\(isRemoved)") - - case .failure(let error): - os_log(.error, "Failed to remove the WireGuard key from server. Server error: %{public}s", error.localizedDescription) - } - - // Suppress server errors + .map({ (isRemoved) -> () in + os_log(.debug, "Removed the WireGuard key from server: %{public}s", "\(isRemoved)") return () }).catch({ (error) -> Result<(), UnsetAccountError>.Publisher in - os_log(.error, "Failed to remove the Wireguard key from server. Network error: %{public}s", error.localizedDescription) + os_log(.error, "Failed to remove the Wireguard key from server: %{public}s", error.localizedDescription) // Suppress network errors return Result.Publisher(()) @@ -558,51 +535,40 @@ class TunnelManager { .flatMap { (accountToken) -> AnyPublisher<(), TunnelManagerError> in let newPrivateKey = WireguardPrivateKey() - return TunnelConfigurationManager.load(account: accountToken) - .map { $0.interface.privateKey.publicKey } + return TunnelConfigurationManager.load(searchTerm: .accountToken(accountToken)) + .map { $0.tunnelConfiguration.interface.privateKey.publicKey } .mapError { RegenerateWireguardPrivateKeyError.readPublicWireguardKey($0) } .publisher .flatMap { (oldPublicKey) in - self.apiClient.replaceWireguardKey( + self.rpc.replaceWireguardKey( accountToken: accountToken, oldPublicKey: oldPublicKey.rawRepresentation, newPublicKey: newPrivateKey.publicKey.rawRepresentation) - .mapError { (networkError) -> RegenerateWireguardPrivateKeyError in - return .replaceWireguardKey(.transport(networkError)) - }.receive(on: self.executionQueue) - .flatMap { (response: MullvadAPI.Response<WireguardAssociatedAddresses>) in - return response.result.publisher - .mapError { (serverError) -> RegenerateWireguardPrivateKeyError in - return .replaceWireguardKey(.server(serverError)) + .mapError { RegenerateWireguardPrivateKeyError.replaceWireguardKey($0) } + .receive(on: self.executionQueue) + .flatMap { (addresses) in + TunnelConfigurationManager.update(searchTerm: .accountToken(accountToken)) { + (tunnelConfiguration) in + tunnelConfiguration.interface.privateKey = newPrivateKey + tunnelConfiguration.interface.addresses = [ + addresses.ipv4Address, + addresses.ipv6Address + ] } - } - .flatMap { (addresses) in - self.updateTunnelConfiguration(accountToken: accountToken) { - (tunnelConfiguration) in - tunnelConfiguration.interface.privateKey = newPrivateKey - tunnelConfiguration.interface.addresses = [ - addresses.ipv4Address, - addresses.ipv6Address - ] - } - .mapError { .updateTunnelConfiguration($0) } - .publisher - } - .flatMap { - self.setupTunnel(accountToken: accountToken) + .mapError { .updateTunnelConfiguration($0) } .map { _ in () } - .mapError { RegenerateWireguardPrivateKeyError.setupTunnel($0) } + .publisher }.receive(on: self.executionQueue) .flatMap { _ in // Ignore Packet Tunnel IPC errors but log them self.reloadPacketTunnelConfiguration() - .replaceError(with: ()) - .setFailureType(to: RegenerateWireguardPrivateKeyError.self) .handleEvents(receiveCompletion: { (completion) in if case .failure(let error) = completion { os_log(.error, "Failed to tell the tunnel to reload configuration: %{public}s", error.localizedDescription) } }) + .replaceError(with: ()) + .setFailureType(to: RegenerateWireguardPrivateKeyError.self) } } .mapError { TunnelManagerError.regenerateWireguardPrivateKey($0) } @@ -617,11 +583,12 @@ class TunnelManager { .setFailureType(to: TunnelManagerError.self) .replaceNil(with: .missingAccount) .flatMap { (accountToken) in - self.updateTunnelConfiguration(accountToken: accountToken) { (tunnelConfig) in - tunnelConfig.relayConstraints = constraints + TunnelConfigurationManager + .update(searchTerm: .accountToken(accountToken)) { (tunnelConfig) in + tunnelConfig.relayConstraints = constraints }.mapError { TunnelManagerError.setRelayConstraints($0) } .publisher - .flatMap { + .flatMap { _ in // Ignore Packet Tunnel IPC errors but log them self.reloadPacketTunnelConfiguration() .replaceError(with: ()) @@ -642,16 +609,17 @@ class TunnelManager { .setFailureType(to: TunnelManagerError.self) .replaceNil(with: .missingAccount) .flatMap { (accountToken) in - TunnelConfigurationManager.load(account: accountToken) - .map { $0.relayConstraints } - .flatMapError { (error) -> Result<RelayConstraints, TunnelConfigurationManagerError> in + TunnelConfigurationManager.load(searchTerm: .accountToken(accountToken)) + .map { $0.tunnelConfiguration.relayConstraints } + .flatMapError { (error) -> Result<RelayConstraints, TunnelConfigurationManager.Error> in // Return default constraints if the config is not found in Keychain if case .getFromKeychain(.itemNotFound) = error { return .success(TunnelConfiguration().relayConstraints) } else { return .failure(error) } - }.mapError { TunnelManagerError.getRelayConstraints($0) }.publisher + }.mapError { .getRelayConstraints($0) } + .publisher } }.eraseToAnyPublisher() } @@ -662,9 +630,10 @@ class TunnelManager { .setFailureType(to: TunnelManagerError.self) .replaceNil(with: .missingAccount) .flatMap { (accountToken) in - TunnelConfigurationManager.load(account: accountToken) - .map { $0.interface.privateKey.publicKey } - .mapError { TunnelManagerError.getWireguardPublicKey($0) }.publisher + TunnelConfigurationManager.load(searchTerm: .accountToken(accountToken)) + .map { $0.tunnelConfiguration.interface.privateKey.publicKey } + .mapError { .getWireguardPublicKey($0) } + .publisher } }.eraseToAnyPublisher() } @@ -673,21 +642,25 @@ class TunnelManager { /// Tell Packet Tunnel process to reload the tunnel configuration private func reloadPacketTunnelConfiguration() -> AnyPublisher<(), TunnelIpcRequestError> { - Just(tunnelIpc) - .setFailureType(to: TunnelIpcRequestError.self) - .replaceNil(with: .missingIpc) - .flatMap { (tunnelIpc) in - tunnelIpc.reloadConfiguration() - .mapError { .send($0) } - }.eraseToAnyPublisher() + return executeIpcRequest { $0.reloadConfiguration() } } + /// Ask Packet Tunnel process to return the current tunnel connection info private func getTunnelConnectionInfo() -> AnyPublisher<TunnelConnectionInfo, TunnelIpcRequestError> { + return executeIpcRequest { $0.getTunnelInformation() } + } + + /// IPC interactor helper that automatically maps the `PacketTunnelIpcError` to + /// `TunnelIpcRequestError` + private func executeIpcRequest<T>( + _ body: @escaping (PacketTunnelIpc) -> AnyPublisher<T, PacketTunnelIpcError> + ) -> AnyPublisher<T, TunnelIpcRequestError> + { Just(tunnelIpc) .setFailureType(to: TunnelIpcRequestError.self) .replaceNil(with: .missingIpc) .flatMap { (tunnelIpc) in - tunnelIpc.getTunnelInformation() + body(tunnelIpc) .mapError { .send($0) } }.eraseToAnyPublisher() } @@ -782,13 +755,16 @@ class TunnelManager { } /// Retrieve the existing TunnelConfiguration or create a new one - private func makeTunnelConfiguration(accountToken: String) -> Result<TunnelConfiguration, TunnelConfigurationManagerError> { - TunnelConfigurationManager.load(account: accountToken) - .flatMapError { (error) -> Result<TunnelConfiguration, TunnelConfigurationManagerError> in + private func makeTunnelConfiguration(accountToken: String) -> Result<TunnelConfiguration, TunnelConfigurationManager.Error> { + TunnelConfigurationManager.load(searchTerm: .accountToken(accountToken)) + .map { $0.tunnelConfiguration } + .flatMapError { (error) -> Result<TunnelConfiguration, TunnelConfigurationManager.Error> in // Return default tunnel configuration if the config is not found in Keychain if case .getFromKeychain(.itemNotFound) = error { let defaultConfiguration = TunnelConfiguration() - return TunnelConfigurationManager.save(configuration: defaultConfiguration, account: accountToken) + + return TunnelConfigurationManager + .add(configuration: defaultConfiguration, account: accountToken) .map { defaultConfiguration } } else { return .failure(error) @@ -805,7 +781,7 @@ class TunnelManager { return tunnels?.first ?? TunnelProviderManagerType() } .flatMap { (tunnelProvider) in - TunnelConfigurationManager.getPersistentKeychainRef(account: accountToken) + TunnelConfigurationManager.getPersistentKeychainReference(account: accountToken) .mapError { SetupTunnelError.obtainKeychainRef($0) } .map { (tunnelProvider, $0) } .publisher @@ -855,21 +831,12 @@ class TunnelManager { return protocolConfig } - private func updateTunnelConfiguration(accountToken: String, using block: (inout TunnelConfiguration) -> Void) -> Result<(), UpdateTunnelConfigurationError> { - TunnelConfigurationManager.load(account: accountToken) - .mapError { UpdateTunnelConfigurationError.loadTunnelConfiguration($0) } - .flatMap { (tunnelConfig) -> Result<(), UpdateTunnelConfigurationError> in - var tunnelConfig = tunnelConfig - - block(&tunnelConfig) - - return TunnelConfigurationManager.save(configuration: tunnelConfig, account: accountToken) - .mapError { .saveTunnelConfiguration($0) } - } - } - - private func updateAssociatedAddresses(accountToken: String, addresses: WireguardAssociatedAddresses) -> Result<(), UpdateTunnelConfigurationError> { - updateTunnelConfiguration(accountToken: accountToken) { (tunnelConfig) in + private func updateAssociatedAddresses( + accountToken: String, + addresses: WireguardAssociatedAddresses + ) -> Result<TunnelConfiguration, TunnelConfigurationManager.Error> + { + TunnelConfigurationManager.update(searchTerm: .accountToken(accountToken)) { (tunnelConfig) in tunnelConfig.interface.addresses = [ addresses.ipv4Address, addresses.ipv6Address @@ -877,6 +844,24 @@ class TunnelManager { } } + private func migrateTunnelConfiguration(accountToken: String) { + let result = TunnelConfigurationManager + .migrateKeychainEntry(searchTerm: .accountToken(accountToken)) + + switch result { + case .success(let migrated): + if migrated { + os_log("Migrated Keychain tunnel configuration") + } else { + os_log("Tunnel configuration is up to date. No migration needed.") + } + + case .failure(let error): + os_log("Failed to migrate tunnel configuration: %{public}s", + error.localizedDescription) + } + } + } /// Convenience methods to provide `Future` based alternatives for working with diff --git a/ios/MullvadVPN/WireguardKeysViewController.swift b/ios/MullvadVPN/WireguardKeysViewController.swift index 1a50dc9c7e..513e6097db 100644 --- a/ios/MullvadVPN/WireguardKeysViewController.swift +++ b/ios/MullvadVPN/WireguardKeysViewController.swift @@ -59,7 +59,8 @@ class WireguardKeysViewController: UIViewController { @IBOutlet var verifyKeyButton: UIButton! @IBOutlet var wireguardKeyStatusView: WireguardKeyStatusView! - private var fetchKeySubscriber: AnyCancellable? + private var tunnelStateSubscriber: AnyCancellable? + private var loadKeySubscriber: AnyCancellable? private var verifyKeySubscriber: AnyCancellable? private var regenerateKeySubscriber: AnyCancellable? private var creationDateTimerSubscriber: AnyCancellable? @@ -100,6 +101,18 @@ class WireguardKeysViewController: UIViewController { } } + tunnelStateSubscriber = TunnelManager.shared.$tunnelState + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] (tunnelState) in + guard let self = self else { return } + + // Reload the public key when the tunnel is reconnecting + // Normally this may happen in response to private key change + if case .reconnecting = tunnelState { + self.loadPublicKey(animated: true) + } + }) + loadPublicKey(animated: false) } @@ -160,7 +173,7 @@ class WireguardKeysViewController: UIViewController { } private func loadPublicKey(animated: Bool) { - fetchKeySubscriber = TunnelManager.shared.getWireguardPublicKey() + loadKeySubscriber = TunnelManager.shared.getWireguardPublicKey() .receive(on: DispatchQueue.main) .sink(receiveCompletion: { (completion) in switch completion { diff --git a/ios/MullvadVPN/WireguardPrivateKey.swift b/ios/MullvadVPN/WireguardPrivateKey.swift index 293750f893..07defa7cf8 100644 --- a/ios/MullvadVPN/WireguardPrivateKey.swift +++ b/ios/MullvadVPN/WireguardPrivateKey.swift @@ -52,7 +52,7 @@ extension WireguardPrivateKey: Equatable { } /// A struct holding a public key used for Wireguard with associated metadata -struct WireguardPublicKey { +struct WireguardPublicKey: Codable, Equatable { /// Refers to private key creation date let creationDate: Date diff --git a/ios/PacketTunnel/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider.swift index 8333c6a814..e58e2f3c1b 100644 --- a/ios/PacketTunnel/PacketTunnelProvider.swift +++ b/ios/PacketTunnel/PacketTunnelProvider.swift @@ -23,7 +23,7 @@ enum PacketTunnelProviderError: Error { case missingKeychainConfigurationReference /// Failure to read the tunnel configuration from Keychain - case cannotReadTunnelConfiguration(TunnelConfigurationManagerError) + case cannotReadTunnelConfiguration(TunnelConfigurationManager.Error) /// Failure to set network settings case setNetworkSettings(Error) @@ -67,6 +67,7 @@ enum PacketTunnelProviderError: Error { } struct PacketTunnelConfiguration { + var persistentKeychainReference: Data var tunnelConfig: TunnelConfiguration var selectorResult: RelaySelectorResult } @@ -108,6 +109,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private let exclusivityQueue = DispatchQueue(label: "net.mullvad.vpn.packet-tunnel.exclusivity-queue") private let executionQueue = DispatchQueue(label: "net.mullvad.vpn.packet-tunnel.execution-queue") + private var keyRotationManager: AutomaticKeyRotationManager? + override init() { super.init() @@ -115,18 +118,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { - os_log(.default, log: tunnelProviderLog, "Start tunnel received.") - startStopTunnelSubscriber = self.startTunnel() .sink(receiveCompletion: { (completion) in switch completion { case .finished: - os_log(.default, log: tunnelProviderLog, "Started the tunnel") - completionHandler(nil) case .failure(let error): - os_log(.error, log: tunnelProviderLog, "Failed to start the tunnel: %{public}s", error.localizedDescription) + os_log(.error, log: tunnelProviderLog, + "Failed to start the tunnel: %{public}s", error.localizedDescription) completionHandler(error) } @@ -134,13 +134,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { - os_log(.default, log: tunnelProviderLog, "Stop tunnel received. Reason: %{public}s", "\(String(reflecting: reason))") - - startStopTunnelSubscriber = stopTunnel().sink(receiveCompletion: { (completion) in - os_log(.default, log: tunnelProviderLog, "Stopped the tunnel") - - completionHandler() - }) + startStopTunnelSubscriber = stopTunnel(reason: reason) + .sink(receiveCompletion: { (completion) in + completionHandler() + }) } override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { @@ -148,8 +145,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { .mapError { PacketTunnelProviderError.ipcHandler($0) } .receive(on: executionQueue) .flatMap { (request) -> AnyPublisher<AnyEncodable, PacketTunnelProviderError> in - os_log(.default, log: tunnelProviderLog, - "Received IPC request: %{public}s", "\(request)") + os_log(.default, log: tunnelProviderLog, "IPC request: %{public}s", "\(request)") switch request { @@ -168,7 +164,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { .mapError { PacketTunnelProviderError.ipcHandler($0) } }).autoDisposableSink(cancellableSet: cancellableSet, receiveCompletion: { (completion) in if case .failure(let error) = completion { - os_log(.error, log: tunnelProviderLog, "Failed to handle the app message: %{public}s", error.localizedDescription) + os_log(.error, log: tunnelProviderLog, + "Failed to handle the app message: %{public}s", error.localizedDescription) completionHandler?(nil) } }, receiveValue: { (responseData) in @@ -196,29 +193,36 @@ class PacketTunnelProvider: NEPacketTunnelProvider { exclusivityQueue: exclusivityQueue, executionQueue: executionQueue ) { () -> AnyPublisher<(), PacketTunnelProviderError> in - os_log(.default, log: tunnelProviderLog, "Starting the tunnel") + os_log(.default, log: tunnelProviderLog, "Start the tunnel") self.startedTunnel = true return self.makePacketTunnelConfigAndApplyNetworkSettings() - .flatMap { + .flatMap { (packetTunnelConfiguration) in Self.startWireguard( packetFlow: self.packetFlow, - configuration: $0.wireguardConfig + configuration: packetTunnelConfiguration.wireguardConfig ) .receive(on: self.executionQueue) .handleEvents(receiveOutput: { (wireguardDevice) in self.wireguardDevice = wireguardDevice + + self.startKeyRotation( + persistentKeychainReference: packetTunnelConfiguration + .persistentKeychainReference + ) }).map { _ in () } }.eraseToAnyPublisher() }.eraseToAnyPublisher() } - private func stopTunnel() -> AnyPublisher<(), Never> { + private func stopTunnel(reason: NEProviderStopReason) -> AnyPublisher<(), Never> { MutuallyExclusive(exclusivityQueue: exclusivityQueue, executionQueue: executionQueue) { () -> AnyPublisher<(), Never> in - os_log(.default, log: tunnelProviderLog, "Stopping the tunnel") + os_log(.default, log: tunnelProviderLog, + "Stop the tunnel. Reason: %{public}s", "\(String(reflecting: reason))") self.startedTunnel = false + self.stopKeyRotation() if let device = self.wireguardDevice { self.wireguardDevice = nil @@ -267,11 +271,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { }, receiveCompletion: { (completion) in switch completion { case .finished: - os_log(.default, log: tunnelProviderLog, "Set new tunnel settings") + os_log(.default, log: tunnelProviderLog, "Reloaded the tunnel with new settings") case .failure(let error): os_log(.default, log: tunnelProviderLog, - "Failed to set the new tunnel settings: %{public}s", + "Failed to reload the tunnel with new settings: %{public}s", error.localizedDescription) } @@ -293,7 +297,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { location: selectorResult.location ) - os_log(.default, log: tunnelProviderLog, "Selected relay: %{public}s", + os_log(.default, log: tunnelProviderLog, "Select relay: %{public}s", selectorResult.relay.hostname) } @@ -314,17 +318,16 @@ class PacketTunnelProvider: NEPacketTunnelProvider { /// Returns a `PacketTunnelConfig` that contains the tunnel configuration and selected relay private func makePacketTunnelConfig() -> AnyPublisher<PacketTunnelConfiguration, PacketTunnelProviderError> { - let keychainRef = (protocolConfiguration as? NETunnelProviderProtocol)?.passwordReference - - return Just(keychainRef) - .setFailureType(to: PacketTunnelProviderError.self) - .replaceNil(with: PacketTunnelProviderError.missingKeychainConfigurationReference) - .flatMap { (keychainRef) in - Self.readTunnelConfiguration(keychainReference: keychainRef).publisher + return getConfigurationPersistentKeychainReference() + .publisher + .flatMap { (persistentKeychainReference) in + Self.readTunnelConfiguration(keychainReference: persistentKeychainReference) + .publisher .flatMap { (tunnelConfiguration) in Self.selectRelayEndpoint(relayConstraints: tunnelConfiguration.relayConstraints) - .map { (selectorResult) in + .map { (selectorResult) -> PacketTunnelConfiguration in PacketTunnelConfiguration( + persistentKeychainReference: persistentKeychainReference, tunnelConfig: tunnelConfiguration, selectorResult: selectorResult) } @@ -351,10 +354,43 @@ class PacketTunnelProvider: NEPacketTunnelProvider { .eraseToAnyPublisher() } + /// Returns the persistent keychain reference for the VPN configuration or an error if it's + /// missing + private func getConfigurationPersistentKeychainReference() -> Result<Data, PacketTunnelProviderError> { + return protocolConfiguration.passwordReference.map { .success($0) } + ?? .failure(.missingKeychainConfigurationReference) + } + + private func startKeyRotation(persistentKeychainReference: Data) { + let keyRotationManager = AutomaticKeyRotationManager( + persistentKeychainReference: persistentKeychainReference + ) + + keyRotationManager.keyRotationEventHandler = { (keyRotationEvent) in + self.reloadTunnel().autoDisposableSink( + cancellableSet: self.cancellableSet, + receiveCompletion: { (completion) in + // no-op + }) + } + + stopKeyRotation() + self.keyRotationManager = keyRotationManager + + keyRotationManager.startAutomaticRotation() + } + + + private func stopKeyRotation() { + keyRotationManager?.stopAutomaticRotation() + keyRotationManager = nil + } + /// Read tunnel configuration from Keychain private class func readTunnelConfiguration(keychainReference: Data) -> Result<TunnelConfiguration, PacketTunnelProviderError> { - return TunnelConfigurationManager.load(persistentKeychainRef: keychainReference) + TunnelConfigurationManager.load(searchTerm: .persistentReference(keychainReference)) .mapError { PacketTunnelProviderError.cannotReadTunnelConfiguration($0) } + .map { $0.tunnelConfiguration } } /// Load relay cache with potential networking to refresh the cache and pick the relay for the |
