summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadSettings
diff options
context:
space:
mode:
authorBug Magnet <marco.nikic@mullvad.net>2023-09-20 14:17:42 +0200
committerBug Magnet <marco.nikic@mullvad.net>2023-09-20 15:29:59 +0200
commitda4e628b79fda9a2fa2426c1aa0e1f3cfcbc7ce8 (patch)
tree54db6508c9d716d1c7be533d39ff4c847ddffd5a /ios/MullvadSettings
parent1e17c07490ea3c66430559794778b343e21d83eb (diff)
downloadmullvadvpn-da4e628b79fda9a2fa2426c1aa0e1f3cfcbc7ce8.tar.xz
mullvadvpn-da4e628b79fda9a2fa2426c1aa0e1f3cfcbc7ce8.zip
Disable Module verifier, rename Settings to MullvadSettings to avoid clash with Apple private Framework of the same name
Diffstat (limited to 'ios/MullvadSettings')
-rw-r--r--ios/MullvadSettings/DNSSettings.swift127
-rw-r--r--ios/MullvadSettings/DeviceState.swift47
-rw-r--r--ios/MullvadSettings/KeychainSettingsStore.swift105
-rw-r--r--ios/MullvadSettings/Migration.swift17
-rw-r--r--ios/MullvadSettings/MigrationManager.swift100
-rw-r--r--ios/MullvadSettings/MullvadSettings.h19
-rw-r--r--ios/MullvadSettings/SettingsManager.swift241
-rw-r--r--ios/MullvadSettings/SettingsParser.swift68
-rw-r--r--ios/MullvadSettings/SettingsStore.swift22
-rw-r--r--ios/MullvadSettings/StoredAccountData.swift40
-rw-r--r--ios/MullvadSettings/StoredDeviceData.swift67
-rw-r--r--ios/MullvadSettings/TunnelSettings.swift24
-rw-r--r--ios/MullvadSettings/TunnelSettingsV1.swift101
-rw-r--r--ios/MullvadSettings/TunnelSettingsV2.swift57
14 files changed, 1035 insertions, 0 deletions
diff --git a/ios/MullvadSettings/DNSSettings.swift b/ios/MullvadSettings/DNSSettings.swift
new file mode 100644
index 0000000000..cae9de2928
--- /dev/null
+++ b/ios/MullvadSettings/DNSSettings.swift
@@ -0,0 +1,127 @@
+//
+// DNSSettings.swift
+// MullvadVPN
+//
+// Created by pronebird on 27/04/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadTypes
+import struct Network.IPv4Address
+
+/// A struct describing Mullvad DNS blocking options.
+public struct DNSBlockingOptions: OptionSet, Codable {
+ public let rawValue: UInt32
+
+ public static let blockAdvertising = DNSBlockingOptions(rawValue: 1 << 0)
+ public static let blockTracking = DNSBlockingOptions(rawValue: 1 << 1)
+ public static let blockMalware = DNSBlockingOptions(rawValue: 1 << 2)
+ public static let blockAdultContent = DNSBlockingOptions(rawValue: 1 << 3)
+ public static let blockGambling = DNSBlockingOptions(rawValue: 1 << 4)
+
+ public var serverAddress: IPv4Address? {
+ if isEmpty {
+ return nil
+ } else {
+ return IPv4Address("100.64.0.\(rawValue)")
+ }
+ }
+
+ public init(rawValue: UInt32) {
+ self.rawValue = rawValue
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let rawValue = try container.decode(RawValue.self)
+
+ self.init(rawValue: rawValue)
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+
+ try container.encode(rawValue)
+ }
+}
+
+/// A struct that holds DNS settings.
+public struct DNSSettings: Codable, Equatable {
+ /// Maximum number of allowed DNS domains.
+ public static let maxAllowedCustomDNSDomains = 3
+
+ /// DNS blocking options.
+ public var blockingOptions: DNSBlockingOptions = []
+
+ /// Enable custom DNS.
+ public var enableCustomDNS = false
+
+ /// Custom DNS domains.
+ public var customDNSDomains: [AnyIPAddress] = []
+
+ /// Effective state of the custom DNS setting.
+ public var effectiveEnableCustomDNS: Bool {
+ blockingOptions.isEmpty && enableCustomDNS && !customDNSDomains.isEmpty
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ // Removed in 2022.1 in favor of `blockingOptions`
+ case blockAdvertising, blockTracking
+
+ // Added in 2022.1
+ case blockingOptions
+
+ case enableCustomDNS, customDNSDomains
+ }
+
+ public init() {}
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ // Added in 2022.1
+ if let storedBlockingOptions = try container.decodeIfPresent(
+ DNSBlockingOptions.self,
+ forKey: .blockingOptions
+ ) {
+ blockingOptions = storedBlockingOptions
+ }
+
+ if let storedBlockAdvertising = try container.decodeIfPresent(
+ Bool.self,
+ forKey: .blockAdvertising
+ ), storedBlockAdvertising {
+ blockingOptions.insert(.blockAdvertising)
+ }
+
+ if let storedBlockTracking = try container.decodeIfPresent(
+ Bool.self,
+ forKey: .blockTracking
+ ), storedBlockTracking {
+ blockingOptions.insert(.blockTracking)
+ }
+
+ if let storedEnableCustomDNS = try container.decodeIfPresent(
+ Bool.self,
+ forKey: .enableCustomDNS
+ ) {
+ enableCustomDNS = storedEnableCustomDNS
+ }
+
+ if let storedCustomDNSDomains = try container.decodeIfPresent(
+ [AnyIPAddress].self,
+ forKey: .customDNSDomains
+ ) {
+ customDNSDomains = storedCustomDNSDomains
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ try container.encode(blockingOptions, forKey: .blockingOptions)
+ try container.encode(enableCustomDNS, forKey: .enableCustomDNS)
+ try container.encode(customDNSDomains, forKey: .customDNSDomains)
+ }
+}
diff --git a/ios/MullvadSettings/DeviceState.swift b/ios/MullvadSettings/DeviceState.swift
new file mode 100644
index 0000000000..052511d328
--- /dev/null
+++ b/ios/MullvadSettings/DeviceState.swift
@@ -0,0 +1,47 @@
+//
+// DeviceState.swift
+// MullvadVPN
+//
+// Created by Marco Nikic on 2023-07-31.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public enum DeviceState: Codable, Equatable {
+ case loggedIn(StoredAccountData, StoredDeviceData)
+ case loggedOut
+ case revoked
+
+ private enum LoggedInCodableKeys: String, CodingKey {
+ case _0 = "account"
+ case _1 = "device"
+ }
+
+ public var isLoggedIn: Bool {
+ switch self {
+ case .loggedIn:
+ return true
+ case .loggedOut, .revoked:
+ return false
+ }
+ }
+
+ public var accountData: StoredAccountData? {
+ switch self {
+ case let .loggedIn(accountData, _):
+ return accountData
+ case .loggedOut, .revoked:
+ return nil
+ }
+ }
+
+ public var deviceData: StoredDeviceData? {
+ switch self {
+ case let .loggedIn(_, deviceData):
+ return deviceData
+ case .loggedOut, .revoked:
+ return nil
+ }
+ }
+}
diff --git a/ios/MullvadSettings/KeychainSettingsStore.swift b/ios/MullvadSettings/KeychainSettingsStore.swift
new file mode 100644
index 0000000000..ba0612de0d
--- /dev/null
+++ b/ios/MullvadSettings/KeychainSettingsStore.swift
@@ -0,0 +1,105 @@
+//
+// KeychainSettingsStore.swift
+// MullvadVPN
+//
+// Created by Sajad Vishkai on 2022-11-22.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadTypes
+import Security
+
+public class KeychainSettingsStore: SettingsStore {
+ public let serviceName: String
+ public let accessGroup: String
+
+ public init(serviceName: String, accessGroup: String) {
+ self.serviceName = serviceName
+ self.accessGroup = accessGroup
+ }
+
+ public func read(key: SettingsKey) throws -> Data {
+ try readItemData(key)
+ }
+
+ public func write(_ data: Data, for key: SettingsKey) throws {
+ try addOrUpdateItem(key, data: data)
+ }
+
+ public func delete(key: SettingsKey) throws {
+ try deleteItem(key)
+ }
+
+ private func addItem(_ item: SettingsKey, data: Data) throws {
+ var query = createDefaultAttributes(item: item)
+ query.merge(createAccessAttributes()) { current, _ in
+ current
+ }
+ query[kSecValueData] = data
+
+ let status = SecItemAdd(query as CFDictionary, nil)
+ if status != errSecSuccess {
+ throw KeychainError(code: status)
+ }
+ }
+
+ private func updateItem(_ item: SettingsKey, data: Data) throws {
+ let query = createDefaultAttributes(item: item)
+ let status = SecItemUpdate(
+ query as CFDictionary,
+ [kSecValueData: data] as CFDictionary
+ )
+
+ if status != errSecSuccess {
+ throw KeychainError(code: status)
+ }
+ }
+
+ private func addOrUpdateItem(_ item: SettingsKey, data: Data) throws {
+ do {
+ try updateItem(item, data: data)
+ } catch let error as KeychainError where error == .itemNotFound {
+ try addItem(item, data: data)
+ } catch {
+ throw error
+ }
+ }
+
+ private func readItemData(_ item: SettingsKey) throws -> Data {
+ var query = createDefaultAttributes(item: item)
+ query[kSecReturnData] = true
+
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ if status == errSecSuccess {
+ return result as? Data ?? Data()
+ } else {
+ throw KeychainError(code: status)
+ }
+ }
+
+ private func deleteItem(_ item: SettingsKey) throws {
+ let query = createDefaultAttributes(item: item)
+ let status = SecItemDelete(query as CFDictionary)
+ if status != errSecSuccess {
+ throw KeychainError(code: status)
+ }
+ }
+
+ private func createDefaultAttributes(item: SettingsKey) -> [CFString: Any] {
+ [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: serviceName,
+ kSecAttrAccount: item.rawValue,
+ ]
+ }
+
+ private func createAccessAttributes() -> [CFString: Any] {
+ [
+ kSecAttrAccessGroup: accessGroup,
+ kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
+ ]
+ }
+}
diff --git a/ios/MullvadSettings/Migration.swift b/ios/MullvadSettings/Migration.swift
new file mode 100644
index 0000000000..0d9678deb5
--- /dev/null
+++ b/ios/MullvadSettings/Migration.swift
@@ -0,0 +1,17 @@
+//
+// Migration.swift
+// MullvadVPN
+//
+// Created by Sajad Vishkai on 2022-11-18.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public protocol Migration {
+ func migrate(
+ with store: SettingsStore,
+ parser: SettingsParser,
+ completion: @escaping (Error?) -> Void
+ )
+}
diff --git a/ios/MullvadSettings/MigrationManager.swift b/ios/MullvadSettings/MigrationManager.swift
new file mode 100644
index 0000000000..6348cf9471
--- /dev/null
+++ b/ios/MullvadSettings/MigrationManager.swift
@@ -0,0 +1,100 @@
+//
+// MigrationManager.swift
+// MullvadVPN
+//
+// Created by Marco Nikic on 2023-08-08.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadLogging
+import MullvadREST
+import MullvadTypes
+
+public enum SettingsMigrationResult {
+ /// Nothing to migrate.
+ case nothing
+
+ /// Successfully performed migration.
+ case success
+
+ /// Failure when migrating store.
+ case failure(Error)
+}
+
+public struct MigrationManager {
+ private let logger = Logger(label: "MigrationManager")
+
+ public init() {}
+
+ /// Migrate settings store if needed.
+ ///
+ /// The following types of error are expected to be returned by this method:
+ /// `SettingsMigrationError`, `UnsupportedSettingsVersionError`, `ReadSettingsVersionError`.
+ public func migrateSettings(
+ store: SettingsStore,
+ proxyFactory: REST.ProxyFactory,
+ migrationCompleted: @escaping (SettingsMigrationResult) -> Void
+ ) {
+ let handleCompletion = { (result: SettingsMigrationResult) in
+ // Reset store upon failure to migrate settings.
+ if case .failure = result {
+ SettingsManager.resetStore()
+ }
+ migrationCompleted(result)
+ }
+
+ do {
+ try checkLatestSettingsVersion(in: store)
+ handleCompletion(.nothing)
+ } catch {
+ handleCompletion(.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)
+ }
+
+ guard settingsVersion != SchemaVersion.current.rawValue else {
+ 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
+
+ public var underlyingError: Error? {
+ inner
+ }
+
+ public var errorDescription: String? {
+ "Failed to migrate settings from \(sourceVersion) to \(targetVersion)."
+ }
+
+ public init(sourceVersion: SchemaVersion, targetVersion: SchemaVersion, underlyingError: Error) {
+ self.sourceVersion = sourceVersion
+ self.targetVersion = targetVersion
+ inner = underlyingError
+ }
+}
diff --git a/ios/MullvadSettings/MullvadSettings.h b/ios/MullvadSettings/MullvadSettings.h
new file mode 100644
index 0000000000..598468f6ea
--- /dev/null
+++ b/ios/MullvadSettings/MullvadSettings.h
@@ -0,0 +1,19 @@
+//
+// Settings.h
+// Settings
+//
+// Created by pronebird on 05/09/2023.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+#import <Foundation/Foundation.h>
+
+//! Project version number for Settings.
+FOUNDATION_EXPORT double SettingsVersionNumber;
+
+//! Project version string for Settings.
+FOUNDATION_EXPORT const unsigned char SettingsVersionString[];
+
+// In this header, you should import all the public headers of your framework using statements like #import <Settings/PublicHeader.h>
+
+
diff --git a/ios/MullvadSettings/SettingsManager.swift b/ios/MullvadSettings/SettingsManager.swift
new file mode 100644
index 0000000000..953c052beb
--- /dev/null
+++ b/ios/MullvadSettings/SettingsManager.swift
@@ -0,0 +1,241 @@
+//
+// SettingsManager.swift
+// MullvadVPN
+//
+// Created by pronebird on 29/04/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadLogging
+import MullvadREST
+import MullvadTypes
+
+private let keychainServiceName = "Mullvad VPN"
+private let accountTokenKey = "accountToken"
+private let accountExpiryKey = "accountExpiry"
+
+public enum SettingsManager {
+ private static let logger = Logger(label: "SettingsManager")
+
+ public static let store: SettingsStore = KeychainSettingsStore(
+ serviceName: keychainServiceName,
+ accessGroup: ApplicationConfiguration.securityGroupIdentifier
+ )
+
+ private static func makeParser() -> SettingsParser {
+ SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder())
+ }
+
+ // MARK: - Last used account
+
+ public static func getLastUsedAccount() throws -> String {
+ let data = try store.read(key: .lastUsedAccount)
+
+ if let string = String(data: data, encoding: .utf8) {
+ return string
+ } else {
+ throw StringDecodingError(data: data)
+ }
+ }
+
+ public static func setLastUsedAccount(_ string: String?) throws {
+ if let string {
+ guard let data = string.data(using: .utf8) else {
+ throw StringEncodingError(string: string)
+ }
+
+ try store.write(data, for: .lastUsedAccount)
+ } else {
+ do {
+ try store.delete(key: .lastUsedAccount)
+ } catch let error as KeychainError where error == .itemNotFound {
+ return
+ } catch {
+ throw error
+ }
+ }
+ }
+
+ // MARK: - Should wipe settings
+
+ public static func getShouldWipeSettings() -> Bool {
+ (try? store.read(key: .shouldWipeSettings)) != nil
+ }
+
+ public static func setShouldWipeSettings() {
+ do {
+ try store.write(Data(), for: .shouldWipeSettings)
+ } catch {
+ logger.error(
+ error: error,
+ message: "Failed to set should wipe settings."
+ )
+ }
+ }
+
+ // MARK: - Settings
+
+ public static func readSettings() throws -> LatestTunnelSettings {
+ let storedVersion: Int
+ let data: Data
+ let parser = makeParser()
+
+ do {
+ data = try store.read(key: .settings)
+ storedVersion = try parser.parseVersion(data: data)
+ } catch {
+ throw ReadSettingsVersionError(underlyingError: error)
+ }
+
+ let currentVersion = SchemaVersion.current
+
+ if storedVersion == currentVersion.rawValue {
+ return try parser.parsePayload(as: LatestTunnelSettings.self, from: data)
+ } else {
+ throw UnsupportedSettingsVersionError(
+ storedVersion: storedVersion,
+ currentVersion: currentVersion
+ )
+ }
+ }
+
+ public static func writeSettings(_ settings: LatestTunnelSettings) throws {
+ let parser = makeParser()
+ let data = try parser.producePayload(settings, version: SchemaVersion.current.rawValue)
+
+ try store.write(data, for: .settings)
+ }
+
+ // MARK: - Device state
+
+ public static func readDeviceState() throws -> DeviceState {
+ let data = try store.read(key: .deviceState)
+ let parser = makeParser()
+
+ return try parser.parseUnversionedPayload(as: DeviceState.self, from: data)
+ }
+
+ public static func writeDeviceState(_ deviceState: DeviceState) throws {
+ let parser = makeParser()
+ let data = try parser.produceUnversionedPayload(deviceState)
+
+ try store.write(data, for: .deviceState)
+ }
+
+ /// Removes all legacy settings, device state and tunnel settings but keeps the last used
+ /// account number stored.
+ public static func resetStore(completely: Bool = false) {
+ logger.debug("Reset store.")
+
+ do {
+ try store.delete(key: .deviceState)
+ } catch {
+ if (error as? KeychainError) != .itemNotFound {
+ logger.error(error: error, message: "Failed to delete device state.")
+ }
+ }
+
+ do {
+ try store.delete(key: .settings)
+ } catch {
+ if (error as? KeychainError) != .itemNotFound {
+ logger.error(error: error, message: "Failed to delete settings.")
+ }
+ }
+
+ if completely {
+ do {
+ try store.delete(key: .lastUsedAccount)
+ } catch {
+ if (error as? KeychainError) != .itemNotFound {
+ logger.error(error: error, message: "Failed to delete last used account.")
+ }
+ }
+
+ do {
+ try store.delete(key: .shouldWipeSettings)
+ } catch {
+ if (error as? KeychainError) != .itemNotFound {
+ logger.error(error: error, message: "Failed to delete should wipe settings.")
+ }
+ }
+ }
+ }
+
+ // MARK: - Private
+
+ private static func checkLatestSettingsVersion() throws {
+ let settingsVersion: Int
+ do {
+ let parser = makeParser()
+ let settingsData = try store.read(key: .settings)
+ settingsVersion = try parser.parseVersion(data: settingsData)
+ } catch .itemNotFound as KeychainError {
+ return
+ } catch {
+ throw ReadSettingsVersionError(underlyingError: error)
+ }
+
+ guard settingsVersion != SchemaVersion.current.rawValue else {
+ return
+ }
+
+ let error = UnsupportedSettingsVersionError(
+ storedVersion: settingsVersion,
+ currentVersion: SchemaVersion.current
+ )
+
+ logger.error(error: error, message: "Encountered an unknown version.")
+
+ throw error
+ }
+}
+
+// MARK: - Supporting types
+
+/// An error type describing a failure to read or parse settings version.
+public struct ReadSettingsVersionError: LocalizedError, WrappingError {
+ private let inner: Error
+
+ public var underlyingError: Error? {
+ inner
+ }
+
+ public var errorDescription: String? {
+ "Failed to read settings version."
+ }
+
+ public init(underlyingError: Error) {
+ inner = underlyingError
+ }
+}
+
+/// An error returned when stored settings version is unknown to the currently running app.
+public struct UnsupportedSettingsVersionError: LocalizedError {
+ public let storedVersion: Int
+ public let currentVersion: SchemaVersion
+
+ public var errorDescription: String? {
+ """
+ Stored settings version was not the same as current version, \
+ stored version: \(storedVersion), current version: \(currentVersion)
+ """
+ }
+}
+
+public struct StringDecodingError: LocalizedError {
+ public let data: Data
+
+ public var errorDescription: String? {
+ "Failed to decode string from data."
+ }
+}
+
+public struct StringEncodingError: LocalizedError {
+ public let string: String
+
+ public var errorDescription: String? {
+ "Failed to encode string into data."
+ }
+}
diff --git a/ios/MullvadSettings/SettingsParser.swift b/ios/MullvadSettings/SettingsParser.swift
new file mode 100644
index 0000000000..16bd1ba7ff
--- /dev/null
+++ b/ios/MullvadSettings/SettingsParser.swift
@@ -0,0 +1,68 @@
+//
+// SettingsParser.swift
+// MullvadVPN
+//
+// Created by Sajad Vishkai on 2022-11-22.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+private struct VersionHeader: Codable {
+ var version: Int
+}
+
+private struct Payload<T: Codable>: Codable {
+ var data: T
+}
+
+private struct VersionedPayload<T: Codable>: Codable {
+ var version: Int
+ var data: T
+}
+
+public struct SettingsParser {
+ /// The decoder used to decode values.
+ private let decoder: JSONDecoder
+
+ /// The encoder used to encode values.
+ private let encoder: JSONEncoder
+
+ public init(decoder: JSONDecoder, encoder: JSONEncoder) {
+ self.decoder = decoder
+ self.encoder = encoder
+ }
+
+ /// Produces versioned data encoded as the given type
+ public func producePayload(_ payload: some Codable, version: Int) throws -> Data {
+ try encoder.encode(VersionedPayload(version: version, data: payload))
+ }
+
+ /// Produces unversioned data encoded as the given type
+ public func produceUnversionedPayload(_ payload: some Codable) throws -> Data {
+ try encoder.encode(payload)
+ }
+
+ /// Returns settings version if found inside the stored data.
+ public func parseVersion(data: Data) throws -> Int {
+ let header = try decoder.decode(VersionHeader.self, from: data)
+
+ return header.version
+ }
+
+ /// Returns unversioned payload parsed as the given type.
+ public func parseUnversionedPayload<T: Codable>(
+ as type: T.Type,
+ from data: Data
+ ) throws -> T {
+ try decoder.decode(T.self, from: data)
+ }
+
+ /// Returns data from versioned payload parsed as the given type.
+ public func parsePayload<T: Codable>(
+ as type: T.Type,
+ from data: Data
+ ) throws -> T {
+ try decoder.decode(Payload<T>.self, from: data).data
+ }
+}
diff --git a/ios/MullvadSettings/SettingsStore.swift b/ios/MullvadSettings/SettingsStore.swift
new file mode 100644
index 0000000000..4609ce8d39
--- /dev/null
+++ b/ios/MullvadSettings/SettingsStore.swift
@@ -0,0 +1,22 @@
+//
+// SettingsStore.swift
+// MullvadVPN
+//
+// Created by Sajad Vishkai on 2022-11-22.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public enum SettingsKey: String, CaseIterable {
+ case settings = "Settings"
+ case deviceState = "DeviceState"
+ case lastUsedAccount = "LastUsedAccount"
+ case shouldWipeSettings = "ShouldWipeSettings"
+}
+
+public protocol SettingsStore {
+ func read(key: SettingsKey) throws -> Data
+ func write(_ data: Data, for key: SettingsKey) throws
+ func delete(key: SettingsKey) throws
+}
diff --git a/ios/MullvadSettings/StoredAccountData.swift b/ios/MullvadSettings/StoredAccountData.swift
new file mode 100644
index 0000000000..276982805c
--- /dev/null
+++ b/ios/MullvadSettings/StoredAccountData.swift
@@ -0,0 +1,40 @@
+//
+// StoredAccountData.swift
+// MullvadVPN
+//
+// Created by Marco Nikic on 2023-07-31.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public struct StoredAccountData: Codable, Equatable {
+ /// Account identifier.
+ public var identifier: String
+
+ /// Account number.
+ public var number: String
+
+ /// Account expiry.
+ public var expiry: Date
+
+ /// Returns `true` if account has expired.
+ public var isExpired: Bool {
+ expiry <= Date()
+ }
+
+ public init(identifier: String, number: String, expiry: Date) {
+ self.identifier = identifier
+ self.number = number
+ self.expiry = expiry
+ }
+}
+
+extension StoredAccountData {
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.identifier = try container.decode(String.self, forKey: .identifier)
+ self.number = try container.decode(String.self, forKey: .number)
+ self.expiry = try container.decode(Date.self, forKey: .expiry)
+ }
+}
diff --git a/ios/MullvadSettings/StoredDeviceData.swift b/ios/MullvadSettings/StoredDeviceData.swift
new file mode 100644
index 0000000000..a0d784af61
--- /dev/null
+++ b/ios/MullvadSettings/StoredDeviceData.swift
@@ -0,0 +1,67 @@
+//
+// StoredDeviceData.swift
+// MullvadVPN
+//
+// Created by Marco Nikic on 2023-07-31.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadTypes
+import WireGuardKitTypes
+
+public struct StoredDeviceData: Codable, Equatable {
+ /// Device creation date.
+ public var creationDate: Date
+
+ /// Device identifier.
+ public var identifier: String
+
+ /// Device name.
+ public var name: String
+
+ /// Whether relay hijacks DNS from this device.
+ public var hijackDNS: Bool
+
+ /// IPv4 address + mask assigned to device.
+ public var ipv4Address: IPAddressRange
+
+ /// IPv6 address + mask assigned to device.
+ public var ipv6Address: IPAddressRange
+
+ /// WireGuard key data.
+ public var wgKeyData: StoredWgKeyData
+
+ /// Returns capitalized device name.
+ public var capitalizedName: String {
+ name.capitalized
+ }
+
+ public init(
+ creationDate: Date,
+ identifier: String,
+ name: String,
+ hijackDNS: Bool,
+ ipv4Address: IPAddressRange,
+ ipv6Address: IPAddressRange,
+ wgKeyData: StoredWgKeyData
+ ) {
+ self.creationDate = creationDate
+ self.identifier = identifier
+ self.name = name
+ self.hijackDNS = hijackDNS
+ self.ipv4Address = ipv4Address
+ self.ipv6Address = ipv6Address
+ self.wgKeyData = wgKeyData
+ }
+
+ /// Fill in part of the structure that contains device related properties from `Device` struct.
+ public mutating func update(from device: Device) {
+ identifier = device.id
+ name = device.name
+ creationDate = device.created
+ hijackDNS = device.hijackDNS
+ ipv4Address = device.ipv4Address
+ ipv6Address = device.ipv6Address
+ }
+}
diff --git a/ios/MullvadSettings/TunnelSettings.swift b/ios/MullvadSettings/TunnelSettings.swift
new file mode 100644
index 0000000000..f58c88c47c
--- /dev/null
+++ b/ios/MullvadSettings/TunnelSettings.swift
@@ -0,0 +1,24 @@
+//
+// TunnelSettings.swift
+// MullvadVPN
+//
+// Created by Marco Nikic on 2023-07-31.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+/// Alias to the latest version of the `TunnelSettings`.
+public typealias LatestTunnelSettings = TunnelSettingsV2
+
+/// Settings and device state schema versions.
+public enum SchemaVersion: Int, Equatable {
+ /// Legacy settings format, stored as `TunnelSettingsV1`.
+ case v1 = 1
+
+ /// New settings format, stored as `TunnelSettingsV2`.
+ case v2 = 2
+
+ /// Current schema version.
+ public static let current = SchemaVersion.v2
+}
diff --git a/ios/MullvadSettings/TunnelSettingsV1.swift b/ios/MullvadSettings/TunnelSettingsV1.swift
new file mode 100644
index 0000000000..5148266e21
--- /dev/null
+++ b/ios/MullvadSettings/TunnelSettingsV1.swift
@@ -0,0 +1,101 @@
+//
+// TunnelSettingsV1.swift
+// MullvadVPN
+//
+// Created by pronebird on 19/06/2019.
+// Copyright © 2019 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadTypes
+import struct Network.IPv4Address
+import struct WireGuardKitTypes.IPAddressRange
+import class WireGuardKitTypes.PrivateKey
+import class WireGuardKitTypes.PublicKey
+
+/// A struct that holds the configuration passed via `NETunnelProviderProtocol`.
+public struct TunnelSettingsV1: Codable, Equatable {
+ public var relayConstraints = RelayConstraints()
+ public var interface = InterfaceSettings()
+}
+
+/// A struct that holds a tun interface configuration.
+public struct InterfaceSettings: Codable, Equatable {
+ public var privateKey: PrivateKeyWithMetadata
+ public var nextPrivateKey: PrivateKeyWithMetadata?
+
+ public var addresses: [IPAddressRange]
+ public var dnsSettings: DNSSettings
+
+ private enum CodingKeys: String, CodingKey {
+ case privateKey, nextPrivateKey, addresses, dnsSettings
+ }
+
+ public init(
+ privateKey: PrivateKeyWithMetadata = PrivateKeyWithMetadata(),
+ nextPrivateKey: PrivateKeyWithMetadata? = nil,
+ addresses: [IPAddressRange] = [],
+ dnsSettings: DNSSettings = DNSSettings()
+ ) {
+ self.privateKey = privateKey
+ self.nextPrivateKey = nextPrivateKey
+ self.addresses = addresses
+ self.dnsSettings = dnsSettings
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ privateKey = try container.decode(PrivateKeyWithMetadata.self, forKey: .privateKey)
+ addresses = try container.decode([IPAddressRange].self, forKey: .addresses)
+
+ // Added in 2022.1
+ nextPrivateKey = try container.decodeIfPresent(
+ PrivateKeyWithMetadata.self,
+ forKey: .nextPrivateKey
+ )
+
+ // Provide default value, since `dnsSettings` key does not exist in <= 2021.2
+ dnsSettings = try container.decodeIfPresent(DNSSettings.self, forKey: .dnsSettings)
+ ?? DNSSettings()
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ try container.encode(privateKey, forKey: .privateKey)
+ try container.encode(nextPrivateKey, forKey: .nextPrivateKey)
+ try container.encode(addresses, forKey: .addresses)
+ try container.encode(dnsSettings, forKey: .dnsSettings)
+ }
+}
+
+/// A struct holding a private WireGuard key with associated metadata
+public struct PrivateKeyWithMetadata: Equatable, Codable {
+ private enum CodingKeys: String, CodingKey {
+ case privateKey = "privateKeyData", creationDate
+ }
+
+ /// When the key was created
+ public let creationDate: Date
+
+ /// Private key
+ public let privateKey: PrivateKey
+
+ /// Public key
+ public var publicKey: PublicKey {
+ privateKey.publicKey
+ }
+
+ /// Initialize the new private key
+ public init() {
+ privateKey = PrivateKey()
+ creationDate = Date()
+ }
+
+ /// Initialize with the existing private key
+ public init(privateKey: PrivateKey, createdAt: Date) {
+ self.privateKey = privateKey
+ creationDate = createdAt
+ }
+}
diff --git a/ios/MullvadSettings/TunnelSettingsV2.swift b/ios/MullvadSettings/TunnelSettingsV2.swift
new file mode 100644
index 0000000000..001cc3adb6
--- /dev/null
+++ b/ios/MullvadSettings/TunnelSettingsV2.swift
@@ -0,0 +1,57 @@
+//
+// TunnelSettingsV2.swift
+// MullvadVPN
+//
+// Created by pronebird on 27/04/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadTypes
+import struct Network.IPv4Address
+import struct WireGuardKitTypes.IPAddressRange
+import class WireGuardKitTypes.PrivateKey
+import class WireGuardKitTypes.PublicKey
+
+public struct TunnelSettingsV2: Codable, Equatable {
+ /// Relay constraints.
+ public var relayConstraints: RelayConstraints
+
+ /// DNS settings.
+ public var dnsSettings: DNSSettings
+
+ public init(
+ relayConstraints: RelayConstraints = RelayConstraints(),
+ dnsSettings: DNSSettings = DNSSettings()
+ ) {
+ 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
+ }
+}