diff --git a/ios/MullvadREST/RESTAuthorization.swift b/ios/MullvadREST/RESTAuthorization.swift index 7e81b1f8cb90..340bd63f5c63 100644 --- a/ios/MullvadREST/RESTAuthorization.swift +++ b/ios/MullvadREST/RESTAuthorization.swift @@ -19,10 +19,10 @@ extension REST { typealias Authorization = String struct AccessTokenProvider: RESTAuthorizationProvider { - private let accessTokenManager: AccessTokenManager + private let accessTokenManager: RESTAccessTokenManagement private let accountNumber: String - init(accessTokenManager: AccessTokenManager, accountNumber: String) { + init(accessTokenManager: RESTAccessTokenManagement, accountNumber: String) { self.accessTokenManager = accessTokenManager self.accountNumber = accountNumber } diff --git a/ios/MullvadREST/RESTProxy.swift b/ios/MullvadREST/RESTProxy.swift index 6ca64d85ee46..3ca5c9630eef 100644 --- a/ios/MullvadREST/RESTProxy.swift +++ b/ios/MullvadREST/RESTProxy.swift @@ -160,11 +160,11 @@ extension REST { } public class AuthProxyConfiguration: ProxyConfiguration { - public let accessTokenManager: AccessTokenManager + public let accessTokenManager: RESTAccessTokenManagement public init( proxyConfiguration: ProxyConfiguration, - accessTokenManager: AccessTokenManager + accessTokenManager: RESTAccessTokenManagement ) { self.accessTokenManager = accessTokenManager diff --git a/ios/MullvadSettings/MigrationManager.swift b/ios/MullvadSettings/MigrationManager.swift index 6348cf94718a..8667885823c5 100644 --- a/ios/MullvadSettings/MigrationManager.swift +++ b/ios/MullvadSettings/MigrationManager.swift @@ -29,14 +29,18 @@ public struct MigrationManager { /// Migrate settings store if needed. /// - /// The following types of error are expected to be returned by this method: - /// `SettingsMigrationError`, `UnsupportedSettingsVersionError`, `ReadSettingsVersionError`. + /// Reads the current settings, upgrades them to the latest version if needed + /// and writes back to `store` when settings are updated. + /// - Parameters: + /// - store: The store to from which settings are read and written to. + /// - proxyFactory: Factory used for migrations that involve API calls. + /// - migrationCompleted: Completion handler called with a migration result. public func migrateSettings( store: SettingsStore, proxyFactory: REST.ProxyFactory, migrationCompleted: @escaping (SettingsMigrationResult) -> Void ) { - let handleCompletion = { (result: SettingsMigrationResult) in + let resetStoreHandler = { (result: SettingsMigrationResult) in // Reset store upon failure to migrate settings. if case .failure = result { SettingsManager.resetStore() @@ -45,56 +49,52 @@ public struct MigrationManager { } do { - try checkLatestSettingsVersion(in: store) - handleCompletion(.nothing) + try upgradeSettingsToLatestVersion( + store: store, + proxyFactory: proxyFactory, + migrationCompleted: migrationCompleted + ) + } catch .itemNotFound as KeychainError { + migrationCompleted(.nothing) } catch { - handleCompletion(.failure(error)) + resetStoreHandler(.failure(error)) } } - private func checkLatestSettingsVersion(in store: SettingsStore) throws { - let settingsVersion: Int - do { - let parser = SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder()) - let settingsData = try store.read(key: SettingsKey.settings) - settingsVersion = try parser.parseVersion(data: settingsData) - } catch .itemNotFound as KeychainError { - return - } catch { - throw ReadSettingsVersionError(underlyingError: error) - } + private func upgradeSettingsToLatestVersion( + store: SettingsStore, + proxyFactory: REST.ProxyFactory, + migrationCompleted: @escaping (SettingsMigrationResult) -> Void + ) throws { + let parser = SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder()) + let settingsData = try store.read(key: SettingsKey.settings) + let settingsVersion = try parser.parseVersion(data: settingsData) guard settingsVersion != SchemaVersion.current.rawValue else { + migrationCompleted(.nothing) return } - let error = UnsupportedSettingsVersionError( - storedVersion: settingsVersion, - currentVersion: SchemaVersion.current - ) - - logger.error(error: error, message: "Encountered an unknown version.") - - throw error - } -} - -/// A wrapper type for errors returned by concrete migrations. -public struct SettingsMigrationError: LocalizedError, WrappingError { - private let inner: Error - public let sourceVersion, targetVersion: SchemaVersion + // Corrupted settings version (i.e. negative values, or downgrade from a future version) should fail + guard var savedSchema = SchemaVersion(rawValue: settingsVersion) else { + migrationCompleted(.failure(UnsupportedSettingsVersionError( + storedVersion: settingsVersion, + currentVersion: SchemaVersion.current + ))) + return + } - public var underlyingError: Error? { - inner - } + var savedSettings = try parser.parsePayload(as: savedSchema.settingsType, from: settingsData) - public var errorDescription: String? { - "Failed to migrate settings from \(sourceVersion) to \(targetVersion)." - } + repeat { + let upgradedVersion = savedSettings.upgradeToNextVersion() + savedSchema = savedSchema.nextVersion + savedSettings = upgradedVersion + } while savedSchema.rawValue < SchemaVersion.current.rawValue - public init(sourceVersion: SchemaVersion, targetVersion: SchemaVersion, underlyingError: Error) { - self.sourceVersion = sourceVersion - self.targetVersion = targetVersion - inner = underlyingError + // Write the latest settings back to the store + let latestVersionPayload = try parser.producePayload(savedSettings, version: SchemaVersion.current.rawValue) + try store.write(latestVersionPayload, for: .settings) + migrationCompleted(.success) } } diff --git a/ios/MullvadSettings/SettingsManager.swift b/ios/MullvadSettings/SettingsManager.swift index 953c052beb0a..8de7cc1a9db3 100644 --- a/ios/MullvadSettings/SettingsManager.swift +++ b/ios/MullvadSettings/SettingsManager.swift @@ -18,11 +18,28 @@ private let accountExpiryKey = "accountExpiry" public enum SettingsManager { private static let logger = Logger(label: "SettingsManager") + #if DEBUG + private static var _store = KeychainSettingsStore( + serviceName: keychainServiceName, + accessGroup: ApplicationConfiguration.securityGroupIdentifier + ) + + /// Alternative store used for tests. + internal static var unitTestStore: SettingsStore? + + public static var store: SettingsStore { + if let unitTestStore { return unitTestStore } + return _store + } + + #else public static let store: SettingsStore = KeychainSettingsStore( serviceName: keychainServiceName, accessGroup: ApplicationConfiguration.securityGroupIdentifier ) + #endif + private static func makeParser() -> SettingsParser { SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder()) } diff --git a/ios/MullvadSettings/StoredWgKeyData.swift b/ios/MullvadSettings/StoredWgKeyData.swift new file mode 100644 index 000000000000..df07e0e8c94e --- /dev/null +++ b/ios/MullvadSettings/StoredWgKeyData.swift @@ -0,0 +1,37 @@ +// +// StoredWgKeyData.swift +// MullvadSettings +// +// Created by Marco Nikic on 2023-10-23. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import WireGuardKitTypes + +public struct StoredWgKeyData: Codable, Equatable { + /// Private key creation date. + public var creationDate: Date + + /// Last date a rotation was attempted. Nil if last attempt was successful. + public var lastRotationAttemptDate: Date? + + /// Private key. + public var privateKey: PrivateKey + + /// Next private key we're trying to rotate to. + /// Added in 2023.3 + public var nextPrivateKey: PrivateKey? + + public init( + creationDate: Date, + lastRotationAttemptDate: Date? = nil, + privateKey: PrivateKey, + nextPrivateKey: PrivateKey? = nil + ) { + self.creationDate = creationDate + self.lastRotationAttemptDate = lastRotationAttemptDate + self.privateKey = privateKey + self.nextPrivateKey = nextPrivateKey + } +} diff --git a/ios/MullvadSettings/TunnelSettings.swift b/ios/MullvadSettings/TunnelSettings.swift index f58c88c47c2a..9d27b0a3757e 100644 --- a/ios/MullvadSettings/TunnelSettings.swift +++ b/ios/MullvadSettings/TunnelSettings.swift @@ -7,9 +7,15 @@ // import Foundation +import MullvadREST /// Alias to the latest version of the `TunnelSettings`. -public typealias LatestTunnelSettings = TunnelSettingsV2 +public typealias LatestTunnelSettings = TunnelSettingsV3 + +/// Protocol all TunnelSettings must adhere to, for upgrade purposes. +public protocol TunnelSettings: Codable { + func upgradeToNextVersion() -> any TunnelSettings +} /// Settings and device state schema versions. public enum SchemaVersion: Int, Equatable { @@ -19,6 +25,25 @@ public enum SchemaVersion: Int, Equatable { /// New settings format, stored as `TunnelSettingsV2`. case v2 = 2 + /// V2 format with WireGuard obfuscation options, stored as `TunnelSettingsV3`. + case v3 = 3 + + var settingsType: any TunnelSettings.Type { + switch self { + case .v1: return TunnelSettingsV1.self + case .v2: return TunnelSettingsV2.self + case .v3: return TunnelSettingsV3.self + } + } + + var nextVersion: Self { + switch self { + case .v1: return .v2 + case .v2: return .v3 + case .v3: return .v3 + } + } + /// Current schema version. - public static let current = SchemaVersion.v2 + public static let current = SchemaVersion.v3 } diff --git a/ios/MullvadSettings/TunnelSettingsV1.swift b/ios/MullvadSettings/TunnelSettingsV1.swift index 5148266e21aa..1d7301a9108d 100644 --- a/ios/MullvadSettings/TunnelSettingsV1.swift +++ b/ios/MullvadSettings/TunnelSettingsV1.swift @@ -7,6 +7,7 @@ // import Foundation +import MullvadREST import MullvadTypes import struct Network.IPv4Address import struct WireGuardKitTypes.IPAddressRange @@ -14,9 +15,13 @@ import class WireGuardKitTypes.PrivateKey import class WireGuardKitTypes.PublicKey /// A struct that holds the configuration passed via `NETunnelProviderProtocol`. -public struct TunnelSettingsV1: Codable, Equatable { +public struct TunnelSettingsV1: Codable, Equatable, TunnelSettings { public var relayConstraints = RelayConstraints() public var interface = InterfaceSettings() + + public func upgradeToNextVersion() -> any TunnelSettings { + TunnelSettingsV2(relayConstraints: relayConstraints, dnsSettings: interface.dnsSettings) + } } /// A struct that holds a tun interface configuration. diff --git a/ios/MullvadSettings/TunnelSettingsV2.swift b/ios/MullvadSettings/TunnelSettingsV2.swift index 001cc3adb6b8..c5d39ca0e606 100644 --- a/ios/MullvadSettings/TunnelSettingsV2.swift +++ b/ios/MullvadSettings/TunnelSettingsV2.swift @@ -7,13 +7,10 @@ // import Foundation +import MullvadREST import MullvadTypes -import struct Network.IPv4Address -import struct WireGuardKitTypes.IPAddressRange -import class WireGuardKitTypes.PrivateKey -import class WireGuardKitTypes.PublicKey -public struct TunnelSettingsV2: Codable, Equatable { +public struct TunnelSettingsV2: Codable, Equatable, TunnelSettings { /// Relay constraints. public var relayConstraints: RelayConstraints @@ -27,31 +24,12 @@ public struct TunnelSettingsV2: Codable, Equatable { self.relayConstraints = relayConstraints self.dnsSettings = dnsSettings } -} - -public struct StoredWgKeyData: Codable, Equatable { - /// Private key creation date. - public var creationDate: Date - - /// Last date a rotation was attempted. Nil if last attempt was successful. - public var lastRotationAttemptDate: Date? - - /// Private key. - public var privateKey: PrivateKey - /// Next private key we're trying to rotate to. - /// Added in 2023.3 - public var nextPrivateKey: PrivateKey? - - public init( - creationDate: Date, - lastRotationAttemptDate: Date? = nil, - privateKey: PrivateKey, - nextPrivateKey: PrivateKey? = nil - ) { - self.creationDate = creationDate - self.lastRotationAttemptDate = lastRotationAttemptDate - self.privateKey = privateKey - self.nextPrivateKey = nextPrivateKey + public func upgradeToNextVersion() -> any TunnelSettings { + TunnelSettingsV3( + relayConstraints: relayConstraints, + dnsSettings: dnsSettings, + wireGuardObfuscation: WireGuardObfuscationSettings() + ) } } diff --git a/ios/MullvadSettings/TunnelSettingsV3.swift b/ios/MullvadSettings/TunnelSettingsV3.swift new file mode 100644 index 000000000000..98d9f4bc7131 --- /dev/null +++ b/ios/MullvadSettings/TunnelSettingsV3.swift @@ -0,0 +1,36 @@ +// +// TunnelSettingsV3.swift +// MullvadVPN +// +// Created by Marco Nikic on 2023-10-17. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadREST +import MullvadTypes + +public struct TunnelSettingsV3: Codable, Equatable, TunnelSettings { + /// Relay constraints. + public var relayConstraints: RelayConstraints + + /// DNS settings. + public var dnsSettings: DNSSettings + + /// WireGuard obfuscation settings + public var wireGuardObfuscation: WireGuardObfuscationSettings + + public init( + relayConstraints: RelayConstraints = RelayConstraints(), + dnsSettings: DNSSettings = DNSSettings(), + wireGuardObfuscation: WireGuardObfuscationSettings = WireGuardObfuscationSettings() + ) { + self.relayConstraints = relayConstraints + self.dnsSettings = dnsSettings + self.wireGuardObfuscation = wireGuardObfuscation + } + + public func upgradeToNextVersion() -> any TunnelSettings { + self + } +} diff --git a/ios/MullvadSettings/WireGuardObfuscationSettings.swift b/ios/MullvadSettings/WireGuardObfuscationSettings.swift new file mode 100644 index 000000000000..1d5404b94b01 --- /dev/null +++ b/ios/MullvadSettings/WireGuardObfuscationSettings.swift @@ -0,0 +1,31 @@ +// +// WireGuardObfuscationSettings.swift +// MullvadVPN +// +// Created by Marco Nikic on 2023-10-17. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +public enum WireGuardObfuscationState: Codable { + case automatic + case on + case off +} + +public enum WireGuardObfuscationPort: Codable { + case automatic + case port80 + case port5001 +} + +public struct WireGuardObfuscationSettings: Codable, Equatable { + let state: WireGuardObfuscationState + let port: WireGuardObfuscationPort + + public init(state: WireGuardObfuscationState = .automatic, port: WireGuardObfuscationPort = .automatic) { + self.state = state + self.port = port + } +} diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 34a2bb66ad39..61a08e84e5b6 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -503,12 +503,16 @@ A94D691B2ABAD66700413DD4 /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 58FE25E72AA7399D003D1918 /* WireGuardKitTypes */; }; A95F86B72A1F53BA00245DAC /* URLSessionTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FAE67C28F83CA50033DD93 /* URLSessionTransport.swift */; }; A95F86B82A1F547000245DAC /* ShadowsocksProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F1FF1B29F06124007083C3 /* ShadowsocksProxy.swift */; }; + A97D30172AE6B5E90045C0E4 /* StoredWgKeyData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97D30162AE6B5E90045C0E4 /* StoredWgKeyData.swift */; }; A97F1F442A1F4E1A00ECEFDE /* MullvadTransport.h in Headers */ = {isa = PBXBuildFile; fileRef = A97F1F432A1F4E1A00ECEFDE /* MullvadTransport.h */; settings = {ATTRIBUTES = (Public, ); }; }; A97F1F472A1F4E1A00ECEFDE /* MullvadTransport.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A97F1F412A1F4E1A00ECEFDE /* MullvadTransport.framework */; }; A97F1F482A1F4E1A00ECEFDE /* MullvadTransport.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A97F1F412A1F4E1A00ECEFDE /* MullvadTransport.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; A97FF5502A0D2FFC00900996 /* NSFileCoordinator+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97FF54F2A0D2FFC00900996 /* NSFileCoordinator+Extensions.swift */; }; A988DF212ADD293D00D807EF /* RESTTransportStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A1DE782AD5708E0073F689 /* RESTTransportStrategy.swift */; }; A988DF242ADD307200D807EF /* libRelaySelector.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5898D29829017DAC00EB5EBA /* libRelaySelector.a */; }; + A988DF262ADE86ED00D807EF /* WireGuardObfuscationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A988DF252ADE86ED00D807EF /* WireGuardObfuscationSettings.swift */; }; + A988DF272ADE86ED00D807EF /* WireGuardObfuscationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A988DF252ADE86ED00D807EF /* WireGuardObfuscationSettings.swift */; }; + A988DF2A2ADE880300D807EF /* TunnelSettingsV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = A988DF282ADE880300D807EF /* TunnelSettingsV3.swift */; }; A9A1DE792AD5708E0073F689 /* RESTTransportStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A1DE782AD5708E0073F689 /* RESTTransportStrategy.swift */; }; A9A5F9E12ACB05160083449F /* AddressCacheTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06AC114028F841390037AF9A /* AddressCacheTracker.swift */; }; A9A5F9E22ACB05160083449F /* BackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C76A0A2A338E4300100D75 /* BackgroundTask.swift */; }; @@ -611,6 +615,9 @@ A9A5FA432ACB05F20083449F /* UIColor+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */; }; A9A8A8EB2A262AB30086D569 /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A8A8EA2A262AB30086D569 /* FileCache.swift */; }; A9B2CF722A1F64CD0013CC6C /* MullvadREST.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06799ABC28F98E1D00ACD94E /* MullvadREST.framework */; }; + A9B6AC182ADE8F4300F7802A /* MigrationManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */; }; + A9B6AC1A2ADE8FBB00F7802A /* InMemorySettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6AC192ADE8FBB00F7802A /* InMemorySettingsStore.swift */; }; + A9B6AC1B2ADEA3AD00F7802A /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB9C2A98F69E00F578F2 /* MemoryCache.swift */; }; A9C342C12ACC37E30045F00E /* TunnelStatusBlockObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E0317D2ACC32920095D843 /* TunnelStatusBlockObserver.swift */; }; A9C342C32ACC3EE90045F00E /* RelayCacheTracker+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C342C22ACC3EE90045F00E /* RelayCacheTracker+Stubs.swift */; }; A9C342C52ACC42130045F00E /* ServerRelaysResponse+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C342C42ACC42130045F00E /* ServerRelaysResponse+Stubs.swift */; }; @@ -1585,12 +1592,17 @@ A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCacheTests.swift; sourceTree = ""; }; A9467E872A2DCD57000DC21F /* ShadowsocksConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksConfiguration.swift; sourceTree = ""; }; A9467E8A2A2E0317000DC21F /* ShadowsocksConfigurationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksConfigurationCache.swift; sourceTree = ""; }; + A97D30162AE6B5E90045C0E4 /* StoredWgKeyData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredWgKeyData.swift; sourceTree = ""; }; A97F1F412A1F4E1A00ECEFDE /* MullvadTransport.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MullvadTransport.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A97F1F432A1F4E1A00ECEFDE /* MullvadTransport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MullvadTransport.h; sourceTree = ""; }; A97FF54F2A0D2FFC00900996 /* NSFileCoordinator+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSFileCoordinator+Extensions.swift"; sourceTree = ""; }; + A988DF252ADE86ED00D807EF /* WireGuardObfuscationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuardObfuscationSettings.swift; sourceTree = ""; }; + A988DF282ADE880300D807EF /* TunnelSettingsV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV3.swift; sourceTree = ""; }; A9A1DE782AD5708E0073F689 /* RESTTransportStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RESTTransportStrategy.swift; sourceTree = ""; }; A9A5F9A12ACB003D0083449F /* TunnelManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManagerTests.swift; sourceTree = ""; }; A9A8A8EA2A262AB30086D569 /* FileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = ""; }; + A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationManagerTests.swift; sourceTree = ""; }; + A9B6AC192ADE8FBB00F7802A /* InMemorySettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemorySettingsStore.swift; sourceTree = ""; }; A9C342C22ACC3EE90045F00E /* RelayCacheTracker+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelayCacheTracker+Stubs.swift"; sourceTree = ""; }; A9C342C42ACC42130045F00E /* ServerRelaysResponse+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ServerRelaysResponse+Stubs.swift"; sourceTree = ""; }; A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCacheTests.swift; sourceTree = ""; }; @@ -2471,7 +2483,9 @@ 58C3FA672A385C89006A450A /* FileCacheTests.swift */, 582A8A3928BCE19B00D0F9FB /* FixedWidthIntegerArithmeticsTests.swift */, 58B0A2A4238EE67E00BC001D /* Info.plist */, + A9B6AC192ADE8FBB00F7802A /* InMemorySettingsStore.swift */, F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */, + A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */, 58C3FA652A38549D006A450A /* MockFileCache.swift */, A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */, A9C342C22ACC3EE90045F00E /* RelayCacheTracker+Stubs.swift */, @@ -2518,20 +2532,23 @@ 58B2FDD42AA71D2A003EB5C6 /* MullvadSettings */ = { isa = PBXGroup; children = ( - 58B2FDD52AA71D2A003EB5C6 /* MullvadSettings.h */, + A92ECC2B2A7803A50052F1B1 /* DeviceState.swift */, 580F8B8528197958002E0998 /* DNSSettings.swift */, 06410DFD292CE18F00AFC18C /* KeychainSettingsStore.swift */, 068CE5732927B7A400A068BB /* Migration.swift */, + A9D96B192A8247C100A5C673 /* MigrationManager.swift */, + 58B2FDD52AA71D2A003EB5C6 /* MullvadSettings.h */, 58FF2C02281BDE02009EF542 /* SettingsManager.swift */, 06410E03292D0F7100AFC18C /* SettingsParser.swift */, 06410E06292D108E00AFC18C /* SettingsStore.swift */, A92ECC232A7802520052F1B1 /* StoredAccountData.swift */, A92ECC272A7802AB0052F1B1 /* StoredDeviceData.swift */, + A97D30162AE6B5E90045C0E4 /* StoredWgKeyData.swift */, + A92ECC202A77FFAF0052F1B1 /* TunnelSettings.swift */, 587AD7C523421D7000E93A53 /* TunnelSettingsV1.swift */, 580F8B8228197881002E0998 /* TunnelSettingsV2.swift */, - A92ECC202A77FFAF0052F1B1 /* TunnelSettings.swift */, - A92ECC2B2A7803A50052F1B1 /* DeviceState.swift */, - A9D96B192A8247C100A5C673 /* MigrationManager.swift */, + A988DF282ADE880300D807EF /* TunnelSettingsV3.swift */, + A988DF252ADE86ED00D807EF /* WireGuardObfuscationSettings.swift */, ); path = MullvadSettings; sourceTree = ""; @@ -4069,6 +4086,7 @@ A9A5F9E72ACB05160083449F /* FirstTimeLaunch.swift in Sources */, A9A5F9E82ACB05160083449F /* MarkdownStylingOptions.swift in Sources */, A9A5F9E92ACB05160083449F /* ObserverList.swift in Sources */, + A9B6AC1B2ADEA3AD00F7802A /* MemoryCache.swift in Sources */, A9A5F9EA2ACB05160083449F /* Bundle+ProductVersion.swift in Sources */, A9A5F9EB2ACB05160083449F /* CharacterSet+IPAddress.swift in Sources */, A9A5F9EC2ACB05160083449F /* CodingErrors+CustomErrorDescription.swift in Sources */, @@ -4115,6 +4133,7 @@ A9A5FA102ACB05160083449F /* PacketTunnelTransport.swift in Sources */, A9A5FA112ACB05160083449F /* TransportMonitor.swift in Sources */, A9A5FA122ACB05160083449F /* DeleteAccountOperation.swift in Sources */, + A9B6AC1A2ADE8FBB00F7802A /* InMemorySettingsStore.swift in Sources */, A9A5FA132ACB05160083449F /* LoadTunnelConfigurationOperation.swift in Sources */, A9A5FA142ACB05160083449F /* MapConnectionStatusOperation.swift in Sources */, A9A5FA152ACB05160083449F /* RedeemVoucherOperation.swift in Sources */, @@ -4140,6 +4159,7 @@ A9A5FA272ACB05160083449F /* VPNConnectionProtocol.swift in Sources */, A9A5FA282ACB05160083449F /* WgKeyRotation.swift in Sources */, A9A5FA292ACB05160083449F /* AddressCacheTests.swift in Sources */, + A9B6AC182ADE8F4300F7802A /* MigrationManagerTests.swift in Sources */, A9A5FA2A2ACB05160083449F /* CoordinatesTests.swift in Sources */, A9A5FA2B2ACB05160083449F /* CustomDateComponentsFormattingTests.swift in Sources */, A9A5FA2C2ACB05160083449F /* DeviceCheckOperationTests.swift in Sources */, @@ -4162,13 +4182,16 @@ files = ( 58B2FDEE2AA72098003EB5C6 /* ApplicationConfiguration.swift in Sources */, 58B2FDE52AA71D5C003EB5C6 /* TunnelSettingsV2.swift in Sources */, + A97D30172AE6B5E90045C0E4 /* StoredWgKeyData.swift in Sources */, 58B2FDE32AA71D5C003EB5C6 /* StoredDeviceData.swift in Sources */, 58B2FDDF2AA71D5C003EB5C6 /* DNSSettings.swift in Sources */, 58B2FDE02AA71D5C003EB5C6 /* TunnelSettings.swift in Sources */, + A988DF2A2ADE880300D807EF /* TunnelSettingsV3.swift in Sources */, 58B2FDE42AA71D5C003EB5C6 /* SettingsManager.swift in Sources */, 58B2FDE62AA71D5C003EB5C6 /* DeviceState.swift in Sources */, 58FE25BF2AA72311003D1918 /* MigrationManager.swift in Sources */, 58B2FDEF2AA720C4003EB5C6 /* ApplicationTarget.swift in Sources */, + A988DF272ADE86ED00D807EF /* WireGuardObfuscationSettings.swift in Sources */, 58B2FDDE2AA71D5C003EB5C6 /* Migration.swift in Sources */, 58B2FDE12AA71D5C003EB5C6 /* TunnelSettingsV1.swift in Sources */, 58B2FDE72AA71D5C003EB5C6 /* SettingsStore.swift in Sources */, @@ -4273,6 +4296,7 @@ 5896CEF226972DEB00B0FAE8 /* AccountContentView.swift in Sources */, 7A3353932AAA089000F0A71C /* SimulatorTunnelInfo.swift in Sources */, 5867771429097BCD006F721F /* PaymentState.swift in Sources */, + A988DF262ADE86ED00D807EF /* WireGuardObfuscationSettings.swift in Sources */, F0EF50D32A8FA47E0031E8DF /* ChangeLogInteractor.swift in Sources */, 7AC8A3AF2ABC71D600DC4939 /* TermsOfServiceCoordinator.swift in Sources */, F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */, diff --git a/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift b/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift index 8e4fcf289706..300de34ad734 100644 --- a/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift +++ b/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift @@ -226,7 +226,7 @@ final class TunnelStatusNotificationProvider: NotificationProvider, InAppNotific switch error { case .outdatedSchema: - errorString = "Unable to start tunnel connection after update. Please send a problem report." + errorString = "Unable to start tunnel connection after update. Please disconnect and reconnect." case .noRelaysSatisfyingConstraints: errorString = "No servers match your settings, try changing server or other settings." case .invalidAccount: diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift index 7a7f9f84da40..1547ab0f50f1 100644 --- a/ios/MullvadVPN/SceneDelegate.swift +++ b/ios/MullvadVPN/SceneDelegate.swift @@ -223,18 +223,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, SettingsMigrationUIHand "NEWER_STORED_SETTINGS_ERROR", tableName: "SettingsMigrationUI", value: """ - The version of settings stored on device is from a newer app than is currently \ - running. Settings will be reset to defaults and device logged out. - """, - comment: "" - ) - } else if let error = error as? SettingsMigrationError, - error.underlyingError is REST.Error { - return NSLocalizedString( - "NETWORK_ERROR", - tableName: "SettingsMigrationUI", - value: """ - Network error occurred. Settings will be reset to defaults and device logged out. + The version of settings stored on device is unrecognized.\ + Settings will be reset to defaults and the device will be logged out. """, comment: "" ) diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift index 0f32bf0512e3..415f7be2eaa1 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift @@ -1117,7 +1117,7 @@ final class TunnelManager: StorePaymentObserver { } private func unsetTunnelConfiguration(completion: @escaping () -> Void) { - setSettings(TunnelSettingsV2(), persist: true) + setSettings(LatestTunnelSettings(), persist: true) // Tell the caller to unsubscribe from VPN status notifications. prepareForVPNConfigurationDeletion() diff --git a/ios/MullvadVPNTests/InMemorySettingsStore.swift b/ios/MullvadVPNTests/InMemorySettingsStore.swift new file mode 100644 index 000000000000..1f19b6429c21 --- /dev/null +++ b/ios/MullvadVPNTests/InMemorySettingsStore.swift @@ -0,0 +1,31 @@ +// +// InMemorySettingsStore.swift +// MullvadVPNTests +// +// Created by Marco Nikic on 2023-10-17. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings + +protocol Instantiable { + init() +} + +class InMemorySettingsStore: SettingsStore where ThrownError: Instantiable { + private var settings = [SettingsKey: Data]() + + func read(key: SettingsKey) throws -> Data { + guard settings.keys.contains(key), let value = settings[key] else { throw ThrownError() } + return value + } + + func write(_ data: Data, for key: SettingsKey) throws { + settings[key] = data + } + + func delete(key: SettingsKey) throws { + settings.removeValue(forKey: key) + } +} diff --git a/ios/MullvadVPNTests/MigrationManagerTests.swift b/ios/MullvadVPNTests/MigrationManagerTests.swift new file mode 100644 index 000000000000..1b0bc35bad4c --- /dev/null +++ b/ios/MullvadVPNTests/MigrationManagerTests.swift @@ -0,0 +1,189 @@ +// +// MigrationManagerTests.swift +// MullvadVPNTests +// +// Created by Marco Nikic on 2023-10-17. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +@testable import MullvadREST +@testable import MullvadSettings +@testable import MullvadTypes +import XCTest + +final class MigrationManagerTests: XCTestCase { + static let store = InMemorySettingsStore() + + var manager: MigrationManager! + var proxyFactory: REST.ProxyFactory! + override class func setUp() { + SettingsManager.unitTestStore = store + } + + override class func tearDown() { + SettingsManager.unitTestStore = nil + } + + override func setUpWithError() throws { + let transportProvider = REST.AnyTransportProvider { nil } + let addressCache = REST.AddressCache(canWriteToCache: false, fileCache: MemoryCache()) + let proxyConfiguration = REST.ProxyConfiguration( + transportProvider: transportProvider, + addressCacheStore: addressCache + ) + let authProxy = REST.AuthProxyConfiguration( + proxyConfiguration: proxyConfiguration, + accessTokenManager: AccessTokenManagerStub() + ) + + proxyFactory = REST.ProxyFactory(configuration: authProxy) + manager = MigrationManager() + } + + func testNothingToMigrate() throws { + let store = Self.store + let settings = LatestTunnelSettings() + try SettingsManager.writeSettings(settings) + + let nothingToMigrateExpectation = expectation(description: "No migration") + manager.migrateSettings(store: store, proxyFactory: proxyFactory) { result in + if case .nothing = result { + nothingToMigrateExpectation.fulfill() + } + } + wait(for: [nothingToMigrateExpectation], timeout: 1) + } + + func testNothingToMigrateWhenSettingsAreNotFound() throws { + let store = InMemorySettingsStore() + SettingsManager.unitTestStore = store + + let nothingToMigrateExpectation = expectation(description: "No migration") + manager.migrateSettings(store: store, proxyFactory: proxyFactory) { result in + if case .nothing = result { + nothingToMigrateExpectation.fulfill() + } + } + wait(for: [nothingToMigrateExpectation], timeout: 1) + + // Reset the `SettingsManager` unit test store to avoid affecting other tests + // since it's a globally shared instance + SettingsManager.unitTestStore = Self.store + } + + func testFailedMigration() throws { + let store = Self.store + let failedMigrationExpectation = expectation(description: "Failed migration") + manager.migrateSettings(store: store, proxyFactory: proxyFactory) { result in + if case .failure = result { + failedMigrationExpectation.fulfill() + } + } + wait(for: [failedMigrationExpectation], timeout: 1) + } + + func testFailedMigrationResetsSettings() throws { + let store = Self.store + let data = try XCTUnwrap("Migration test".data(using: .utf8)) + try store.write(data, for: .settings) + try store.write(data, for: .deviceState) + + // Failed migration should reset settings and device state keys + manager.migrateSettings(store: store, proxyFactory: proxyFactory) { _ in } + + let assertDeletionFor: (SettingsKey) throws -> Void = { key in + try XCTAssertThrowsError(store.read(key: key)) { thrownError in + XCTAssertTrue(thrownError is SettingNotFound) + } + } + + try assertDeletionFor(.deviceState) + try assertDeletionFor(.lastUsedAccount) + } + + func testFailedMigrationIfRecordedSettingsVersionHigherThanLatestSettings() throws { + let store = Self.store + let settings = FutureVersionSettings() + try write(settings: settings, version: Int.max - 1, in: store) + + manager.migrateSettings(store: store, proxyFactory: proxyFactory) { _ in } + + let assertDeletionFor: (SettingsKey) throws -> Void = { key in + try XCTAssertThrowsError(store.read(key: key)) { thrownError in + XCTAssertTrue(thrownError is SettingNotFound) + } + } + + try assertDeletionFor(.deviceState) + try assertDeletionFor(.lastUsedAccount) + } + + func testFailedMigrationCorruptedSchemaResetsSettings() throws { + let store = Self.store + let settings = FutureVersionSettings() + try write(settings: settings, version: -42, in: store) + + let failedMigrationExpectation = expectation(description: "Failed migration") + manager.migrateSettings(store: store, proxyFactory: proxyFactory) { result in + if case .failure = result { + failedMigrationExpectation.fulfill() + } + } + wait(for: [failedMigrationExpectation], timeout: 1) + } + + func testSuccessfulMigrationFromV2ToLatest() throws { + var settingsV2 = TunnelSettingsV2() + let osakaRelayConstraints: RelayConstraints = .init(location: .only(.city("jp", "osa"))) + settingsV2.relayConstraints = osakaRelayConstraints + + try migrateToLatest(settingsV2, version: .v2) + + let latestSettings = try SettingsManager.readSettings() + XCTAssertEqual(osakaRelayConstraints, latestSettings.relayConstraints) + } + + func testSuccessfulMigrationFromV1ToLatest() throws { + var settingsV1 = TunnelSettingsV1() + let osakaRelayConstraints: RelayConstraints = .init(location: .only(.city("jp", "osa"))) + settingsV1.relayConstraints = osakaRelayConstraints + + try migrateToLatest(settingsV1, version: .v1) + + // Once the migration is done, settings should have been updated to the latest available version + // Verify that the old settings are still valid + let latestSettings = try SettingsManager.readSettings() + XCTAssertEqual(osakaRelayConstraints, latestSettings.relayConstraints) + } + + private func migrateToLatest(_ settings: any TunnelSettings, version: SchemaVersion) throws { + let store = Self.store + try write(settings: settings, version: version.rawValue, in: store) + + let successfulMigrationExpectation = expectation(description: "Successful migration") + manager.migrateSettings(store: store, proxyFactory: proxyFactory) { result in + if case .success = result { + successfulMigrationExpectation.fulfill() + } + } + wait(for: [successfulMigrationExpectation], timeout: 1) + } + + private func write(settings: any TunnelSettings, version: Int, in store: SettingsStore) throws { + let parser = SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder()) + let payload = try parser.producePayload(settings, version: version) + try store.write(payload, for: .settings) + } +} + +private struct FutureVersionSettings: TunnelSettings { + func upgradeToNextVersion() -> TunnelSettings { self } +} + +struct SettingNotFound: Error, Instantiable {} + +extension KeychainError: Instantiable { + init() { + self = KeychainError.itemNotFound + } +}