diff --git a/swift/apple/Firezone.xcodeproj/project.pbxproj b/swift/apple/Firezone.xcodeproj/project.pbxproj index b47e0d242..fad61b3ca 100644 --- a/swift/apple/Firezone.xcodeproj/project.pbxproj +++ b/swift/apple/Firezone.xcodeproj/project.pbxproj @@ -546,7 +546,7 @@ MARKETING_VERSION = 1.0; ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = "-lconnlib"; - PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}.network-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -587,7 +587,7 @@ MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; MARKETING_VERSION = 1.0; OTHER_LDFLAGS = "-lconnlib"; - PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}.network-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -629,7 +629,7 @@ "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(CONNLIB_TARGET_DIR)/x86_64-apple-darwin/debug"; MARKETING_VERSION = 1.0; OTHER_LDFLAGS = "-lconnlib"; - PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}.network-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SKIP_INSTALL = YES; @@ -670,7 +670,7 @@ "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(CONNLIB_TARGET_DIR)/x86_64-apple-darwin/release"; MARKETING_VERSION = 1.0; OTHER_LDFLAGS = "-lconnlib"; - PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}.network-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SKIP_INSTALL = YES; @@ -864,6 +864,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -915,6 +916,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "${APP_ID}"; PRODUCT_MODULE_NAME = "$(PRODUCT_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; diff --git a/swift/apple/Firezone/Firezone.entitlements b/swift/apple/Firezone/Firezone.entitlements index 4e920afc0..247e6597f 100644 --- a/swift/apple/Firezone/Firezone.entitlements +++ b/swift/apple/Firezone/Firezone.entitlements @@ -6,6 +6,10 @@ packet-tunnel-provider + com.apple.security.application-groups + + ${APP_GROUP_ID} + com.apple.security.app-sandbox com.apple.security.network.client diff --git a/swift/apple/Firezone/Info.plist b/swift/apple/Firezone/Info.plist index a0133aefd..ab64852b2 100644 --- a/swift/apple/Firezone/Info.plist +++ b/swift/apple/Firezone/Info.plist @@ -15,6 +15,8 @@ + AppGroupIdentifier + ${APP_GROUP_ID} AuthURLScheme $(AUTH_URL_SCHEME) AuthURLHost diff --git a/swift/apple/Firezone/xcconfig/Build.xcconfig b/swift/apple/Firezone/xcconfig/Build.xcconfig index f7ab44197..e5b68e806 100644 --- a/swift/apple/Firezone/xcconfig/Build.xcconfig +++ b/swift/apple/Firezone/xcconfig/Build.xcconfig @@ -1,2 +1,5 @@ CONNLIB_SOURCE_DIR=${PROJECT_DIR}/../../rust/connlib/clients/apple CONNLIB_TARGET_DIR=${PROJECT_DIR}/../../rust/target + +APP_GROUP_ID[sdk=macosx*] = ${DEVELOPMENT_TEAM}.group.${APP_ID} +APP_GROUP_ID[sdk=iphoneos*] = group.${APP_ID} diff --git a/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-iOS b/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-iOS index 01a3f3b00..7a10f0302 100644 --- a/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-iOS +++ b/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-iOS @@ -1,2 +1,2 @@ DEVELOPMENT_TEAM = 0000000000 -PRODUCT_BUNDLE_IDENTIFIER = dev.firezone.ios +APP_ID = dev.firezone.ios diff --git a/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-macOS b/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-macOS index f1fad4428..8d45d4b25 100644 --- a/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-macOS +++ b/swift/apple/Firezone/xcconfig/Developer.xcconfig.ci-macOS @@ -1,2 +1,2 @@ DEVELOPMENT_TEAM = 0000000000 -PRODUCT_BUNDLE_IDENTIFIER = dev.firezone.macos +APP_ID = dev.firezone.macos diff --git a/swift/apple/Firezone/xcconfig/Developer.xcconfig.template b/swift/apple/Firezone/xcconfig/Developer.xcconfig.template index e1fd00e2c..d006cb2ed 100644 --- a/swift/apple/Firezone/xcconfig/Developer.xcconfig.template +++ b/swift/apple/Firezone/xcconfig/Developer.xcconfig.template @@ -4,4 +4,4 @@ DEVELOPMENT_TEAM = // The bundle identifier of the apps. // Should be an app id created at developer.apple.com // with Network Extensions capability. -PRODUCT_BUNDLE_IDENTIFIER = +APP_ID = diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift index cc6e9d7aa..662db4771 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift @@ -17,12 +17,9 @@ public final class AppViewModel: ObservableObject { public init() { Task { - let tunnel = try await TunnelStore.loadOrCreate() self.welcomeViewModel = WelcomeViewModel( appStore: AppStore( - tunnelStore: TunnelStore( - tunnel: tunnel - ) + tunnelStore: TunnelStore.shared ) ) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift index 63a33124b..ad9f45d54 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift @@ -12,7 +12,6 @@ import XCTestDynamicOverlay @MainActor final class AuthViewModel: ObservableObject { - @Dependency(\.settingsClient) private var settingsClient @Dependency(\.authStore) private var authStore var settingsUndefined: () -> Void = unimplemented("\(AuthViewModel.self).settingsUndefined") @@ -20,13 +19,14 @@ final class AuthViewModel: ObservableObject { private var cancellables = Set() func signInButtonTapped() async { - guard let teamId = settingsClient.fetchSettings()?.teamId, !teamId.isEmpty else { + guard let accountId = authStore.tunnelStore.tunnelAuthStatus.accountId(), + !accountId.isEmpty else { settingsUndefined() return } do { - try await authStore.signIn(teamId: teamId) + try await authStore.signIn(accountId: accountId) } catch { dump(error) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift index 259968358..e89fabb6a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift @@ -60,17 +60,22 @@ final class MainViewModel: ObservableObject { } func signOutButtonTapped() { - appStore.auth.signOut() + Task { + do { + try await appStore.auth.signOut() + } catch { + logger.error("Error signing out: \(String(describing: error))") + } + } } func startTunnel() async { do { - if case .signedIn(let authResponse) = self.loginStatus { - try await appStore.tunnel.start(authResponse: authResponse) + if case .signedIn = self.loginStatus { + try await appStore.tunnel.start() } } catch { - logger.error("Error starting tunnel: \(String(describing: error)) -- signing out") - appStore.auth.signOut() + logger.error("Error starting tunnel: \(String(describing: error))") } } @@ -87,11 +92,11 @@ struct MainView: View { Section(header: Text("Authentication")) { Group { switch self.model.loginStatus { - case .signedIn(let authResponse): + case .signedIn(_, let actorName): HStack { - Text(authResponse.actorName == nil ? "Signed in" : "Signed in as") + Text(actorName.isEmpty ? "Signed in" : "Signed in as") Spacer() - Text(authResponse.actorName ?? "") + Text(actorName) .foregroundColor(.secondary) } HStack { @@ -165,9 +170,7 @@ struct MainView_Previews: PreviewProvider { MainView( model: MainViewModel( appStore: AppStore( - tunnelStore: TunnelStore( - tunnel: NETunnelProviderManager() - ) + tunnelStore: TunnelStore.shared ) ) ) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift index 11eb5930b..713d0e664 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift @@ -7,29 +7,51 @@ import Dependencies import SwiftUI import XCTestDynamicOverlay +import Combine public final class SettingsViewModel: ObservableObject { - @Dependency(\.settingsClient) private var settingsClient + @Dependency(\.authStore) private var authStore @Published var settings: Settings public var onSettingsSaved: () -> Void = unimplemented() + private var cancellables = Set() public init() { settings = Settings() - load() } func load() { - if let storedSettings = settingsClient.fetchSettings() { - settings = storedSettings + Task { + authStore.tunnelStore.$tunnelAuthStatus + .filter { $0.isInitialized } + .receive(on: RunLoop.main) + .sink { [weak self] tunnelAuthStatus in + guard let self = self else { return } + self.settings = Settings(accountId: tunnelAuthStatus.accountId() ?? "") + } + .store(in: &cancellables) } } func save() { - settingsClient.saveSettings(settings) - onSettingsSaved() + Task { + let accountId = await authStore.loginStatus.accountId + if accountId == settings.accountId { + // Not changed + return + } + let tunnelAuthStatus: TunnelAuthStatus = await { + if settings.accountId.isEmpty { + return .accountNotSetup + } else { + return await authStore.tunnelAuthStatusForAccount(accountId: settings.accountId) + } + }() + try await authStore.tunnelStore.setAuthStatus(tunnelAuthStatus) + onSettingsSaved() + } } } @@ -69,7 +91,7 @@ public struct SettingsView: View { Button("Save") { self.saveButtonTapped() } - .disabled(!isTeamIdValid(model.settings.teamId)) + .disabled(!isTeamIdValid(model.settings.accountId)) } ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { @@ -92,7 +114,7 @@ public struct SettingsView: View { Button("Save", action: { self.saveButtonTapped() }) - .disabled(!isTeamIdValid(model.settings.teamId)) + .disabled(!isTeamIdValid(model.settings.accountId)) } } } @@ -102,12 +124,12 @@ public struct SettingsView: View { Form { Section { FormTextField( - title: "Team ID:", - baseURLString: AuthStore.getAuthBaseURLFromInfoPlist().absoluteString, - placeholder: "team-id", + title: "Account ID:", + baseURLString: AppInfoPlistConstants.authBaseURL.absoluteString, + placeholder: "account-id", text: Binding( - get: { model.settings.teamId }, - set: { model.settings.teamId = $0 } + get: { model.settings.accountId }, + set: { model.settings.accountId = $0 } ) ) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift index 362ef0266..496749ec6 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift @@ -13,7 +13,6 @@ import SwiftUINavigation #if os(iOS) @MainActor final class WelcomeViewModel: ObservableObject { - @Dependency(\.settingsClient) private var settingsClient @Dependency(\.mainQueue) private var mainQueue private var cancellables = Set() @@ -56,7 +55,7 @@ final class WelcomeViewModel: ObservableObject { defer { bindDestination() } - if settingsClient.fetchSettings()?.teamId == nil { + if case .accountNotSetup = appStore.tunnel.tunnelAuthStatus { destination = .undefinedSettingsAlert(.undefinedSettings) } @@ -154,7 +153,7 @@ struct WelcomeView: View { struct WelcomeView_Previews: PreviewProvider { static var previews: some View { WelcomeView( - model: WelcomeViewModel(appStore: AppStore(tunnelStore: TunnelStore(tunnel: .init()))) + model: WelcomeViewModel(appStore: AppStore(tunnelStore: TunnelStore.shared)) ) } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/AppInfoPlistConstants.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/AppInfoPlistConstants.swift new file mode 100644 index 000000000..e3947c809 --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/AppInfoPlistConstants.swift @@ -0,0 +1,47 @@ +// +// AppInfoPlistConstants.swift +// (c) 2023 Firezone, Inc. +// LICENSE: Apache-2.0 +// + +import Foundation + +struct AppInfoPlistConstants { + + static var authBaseURL: URL { + let infoPlistDictionary = Bundle.main.infoDictionary + guard let urlScheme = (infoPlistDictionary?["AuthURLScheme"] as? String), !urlScheme.isEmpty else { + fatalError("AuthURLScheme missing in app's Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.") + } + guard let urlHost = (infoPlistDictionary?["AuthURLHost"] as? String), !urlHost.isEmpty else { + fatalError("AuthURLHost missing in app's Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.") + } + let urlString = "\(urlScheme)://\(urlHost)/" + guard let url = URL(string: urlString) else { + fatalError("AuthURL: Cannot form valid URL from string: \(urlString)") + } + return url + } + + static var controlPlaneURL: URL { + let infoPlistDictionary = Bundle.main.infoDictionary + guard let urlScheme = (infoPlistDictionary?["ControlPlaneURLScheme"] as? String), !urlScheme.isEmpty else { + fatalError("ControlPlaneURLScheme missing in app's Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.") + } + guard let urlHost = (infoPlistDictionary?["ControlPlaneURLHost"] as? String), !urlHost.isEmpty else { + fatalError("ControlPlaneURLHost missing in app's Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.") + } + let urlString = "\(urlScheme)://\(urlHost)/" + guard let url = URL(string: urlString) else { + fatalError("ControlPlaneURL: Cannot form valid URL from string: \(urlString)") + } + return url + } + + static var appGroupId: String { + guard let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String else { + fatalError("AppGroupIdentifier missing in app's Info.plist") + } + return appGroupId + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain+AuthResponse.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain+AuthResponse.swift deleted file mode 100644 index 7b55ede72..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain+AuthResponse.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// Keychain+AuthResponse.swift -// (c) 2023 Firezone, Inc. -// LICENSE: Apache-2.0 -// - -extension KeychainStorage { - static let tokenKey = "token" - static let actorNameKey = "actorName" - - func token() async throws -> String? { - let token = try await load(KeychainStorage.tokenKey).flatMap { data in - String(data: data, encoding: .utf8) - } - - guard let token else { return nil } - return token - } - - func actorName() async throws -> String? { - let actorName = try await load(KeychainStorage.actorNameKey).flatMap { data in - String(data: data, encoding: .utf8) - } - - guard let actorName else { return nil } - return actorName - } - - func save(token: String, actorName: String?) async throws { - try await store(KeychainStorage.tokenKey, token.data(using: .utf8)!) - - if let actorName { - try await store(KeychainStorage.actorNameKey, actorName.data(using: .utf8)!) - } - } - - func deleteAuthResponse() async throws { - try await delete(KeychainStorage.tokenKey) - try await delete(KeychainStorage.actorNameKey) - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift index 9f722caa5..ebf1678fe 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift @@ -6,90 +6,240 @@ import Foundation -enum KeychainError: Error { +public enum KeychainError: Error { case securityError(Status) + case appleSecError(call: String, status: Keychain.SecStatus) + case nilResultFromAppleSecCall(call: String) + case resultFromAppleSecCallIsInvalid(call: String) + case unableToFindSavedItem + case unableToGetAppGroupIdFromInfoPlist + case unableToFormExtensionPath + case unableToGetPluginsPath } -actor Keychain { +public actor Keychain { private static let account = "Firezone" + private let workQueue = DispatchQueue(label: "FirezoneKeychainWorkQueue") - func store(key: String, data: Data) throws { - let query = ([ - kSecClass: kSecClassGenericPassword, - kSecAttrService: getServiceIdentifier(key), - kSecAttrAccount: Keychain.account, - kSecValueData: data, - ] as [CFString: Any]) as CFDictionary + public typealias Token = String + public typealias PersistentRef = Data - let status = SecItemAdd(query, nil) + public struct TokenAttributes { + let authURLString: String + let actorName: String + } - if status == Status.duplicateItem { - try update(key: key, data: data) - } else if status != Status.success { - throw securityError(status) + public enum SecStatus: Equatable { + case status(Status) + case unknownStatus(OSStatus) + + init(_ osStatus: OSStatus) { + if let status = Status(rawValue: osStatus) { + self = .status(status) + } else { + self = .unknownStatus(osStatus) + } + } + + var isSuccess: Bool { + return self == .status(.success) } } - func update(key: String, data: Data) throws { - let query = ([ + public init() { + } + + func store(token: Token, tokenAttributes: TokenAttributes) async throws -> PersistentRef { + #if os(iOS) + let query = [ + // Common for both iOS and macOS: kSecClass: kSecClassGenericPassword, - kSecAttrService: getServiceIdentifier(key), - kSecAttrAccount: Keychain.account, - ] as [CFString: Any]) as CFDictionary - - let updatedData = [kSecValueData: data] as CFDictionary - - let status = SecItemUpdate(query, updatedData) - - if status != Status.success { - throw securityError(status) + kSecAttrLabel: "Firezone access token (\(tokenAttributes.actorName))", + kSecAttrDescription: "Firezone access token", + kSecAttrService: tokenAttributes.authURLString, + kSecAttrAccount: "\(tokenAttributes.actorName): \(UUID().uuidString)", // The UUID uniquifies this item in the keychain + kSecValueData: token.data(using: .utf8) as Any, + kSecReturnPersistentRef: true, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, + // Specific to iOS: + kSecAttrAccessGroup: AppInfoPlistConstants.appGroupId as CFString as Any + ] as [CFString: Any] + #elseif os(macOS) + let query = [ + // Common for both iOS and macOS: + kSecClass: kSecClassGenericPassword, + kSecAttrLabel: "Firezone access token (\(tokenAttributes.actorName))", + kSecAttrDescription: "Firezone access token", + kSecAttrService: tokenAttributes.authURLString, + kSecAttrAccount: "\(tokenAttributes.actorName): \(UUID().uuidString)", // The UUID uniquifies this item in the keychain + kSecValueData: token.data(using: .utf8) as Any, + kSecReturnPersistentRef: true, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, + // Specific to macOS: + kSecAttrAccess: try secAccessForAppAndNetworkExtension() + ] as [CFString: Any] + #endif + return try await withCheckedThrowingContinuation { [weak self] continuation in + self?.workQueue.async { + var ref: CFTypeRef? + let ret = SecStatus(SecItemAdd(query as CFDictionary, &ref)) + guard ret.isSuccess else { + continuation.resume(throwing: KeychainError.appleSecError(call: "SecItemAdd", status: ret)) + return + } + guard let savedPersistentRef = ref as? Data else { + continuation.resume(throwing: KeychainError.nilResultFromAppleSecCall(call: "SecItemAdd")) + return + } + // Remove any other keychain items for the same service URL + var checkForStaleItemsResult: CFTypeRef? + let checkForStaleItemsQuery = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: tokenAttributes.authURLString, + kSecMatchLimit: kSecMatchLimitAll, + kSecReturnPersistentRef: true + ] as [CFString: Any] + let checkRet = SecStatus(SecItemCopyMatching(checkForStaleItemsQuery as CFDictionary, &checkForStaleItemsResult)) + var isSavedItemFound = false + if checkRet.isSuccess, let allRefs = checkForStaleItemsResult as? [Data] { + for ref in allRefs { + if ref == savedPersistentRef { + isSavedItemFound = true + } else { + SecItemDelete([kSecValuePersistentRef: ref] as CFDictionary) + } + } + } + guard isSavedItemFound else { + continuation.resume(throwing: KeychainError.unableToFindSavedItem) + return + } + continuation.resume(returning: savedPersistentRef) + } } } - func load(key: String) throws -> Data? { - let query = ([ - kSecClass: kSecClassGenericPassword, - kSecAttrService: getServiceIdentifier(key), - kSecAttrAccount: Keychain.account, - kSecReturnData: kCFBooleanTrue!, - kSecMatchLimit: kSecMatchLimitOne, - ] as [CFString: Any]) as CFDictionary - - var data: AnyObject? - - let status = SecItemCopyMatching(query, &data) - - if status == Status.success { - return data as? Data - } else if status == Status.itemNotFound { - return nil + #if os(macOS) + private func secAccessForAppAndNetworkExtension() throws -> SecAccess { + // Creating a trusted-application-based SecAccess APIs are deprecated in favour of + // data-protection keychain APIs. However, data-protection keychain doesn't support + // accessing from non-userspace processes, like the tunnel process, so we can only + // use the deprecated APIs for now. + func secTrustedApplicationForPath(_ path: String?) throws -> SecTrustedApplication? { + var trustedApp: SecTrustedApplication? + let ret = SecStatus(SecTrustedApplicationCreateFromPath(path, &trustedApp)) + guard ret.isSuccess else { + throw KeychainError.appleSecError(call: "SecTrustedApplicationCreateFromPath", status: ret) + } + if let trustedApp = trustedApp { + return trustedApp + } else { + throw KeychainError.nilResultFromAppleSecCall(call: "SecTrustedApplicationCreateFromPath(\(path ?? "nil"))") + } + } + guard let pluginsURL = Bundle.main.builtInPlugInsURL else { + throw KeychainError.unableToGetPluginsPath + } + let extensionPath = pluginsURL.appendingPathComponent("FirezoneNetworkExtensionmacOS.appex", isDirectory: true).path + let trustedApps = [ + try secTrustedApplicationForPath(nil), + try secTrustedApplicationForPath(extensionPath) + ] + var access: SecAccess? + let ret = SecStatus(SecAccessCreate("Firezone Token" as CFString, trustedApps as CFArray, &access)) + guard ret.isSuccess else { + throw KeychainError.appleSecError(call: "SecAccessCreate", status: ret) + } + if let access = access { + return access } else { - throw securityError(status) + throw KeychainError.nilResultFromAppleSecCall(call: "SecAccessCreate") + } + } + #endif + + // This function is public because the tunnel needs to call it to get the token + public func load(persistentRef: PersistentRef) async -> Token? { + return await withCheckedContinuation { [weak self] continuation in + self?.workQueue.async { + let query = [ + kSecValuePersistentRef: persistentRef, + kSecReturnData: true + ] as [CFString: Any] + var result: CFTypeRef? + let ret = SecStatus(SecItemCopyMatching(query as CFDictionary, &result)) + if ret.isSuccess, + let resultData = result as? Data, + let resultString = String(data: resultData, encoding: .utf8) { + continuation.resume(returning: resultString) + } else { + continuation.resume(returning: nil) + } + } } } - func delete(key: String) throws { - let query = ([ - kSecClass: kSecClassGenericPassword, - kSecAttrService: getServiceIdentifier(key), - kSecAttrAccount: Keychain.account, - ] as [CFString: Any]) as CFDictionary - - let status = SecItemDelete(query) - - if status != Status.success { - throw securityError(status) + func loadAttributes(persistentRef: PersistentRef) async -> TokenAttributes? { + return await withCheckedContinuation { [weak self] continuation in + self?.workQueue.async { + let query = [ + kSecValuePersistentRef: persistentRef, + kSecReturnAttributes: true + ] as [CFString: Any] + var result: CFTypeRef? + let ret = SecStatus(SecItemCopyMatching(query as CFDictionary, &result)) + if ret.isSuccess, let result = result { + if CFGetTypeID(result) == CFDictionaryGetTypeID() { + let cfDict = result as! CFDictionary + let dict = cfDict as NSDictionary + if let service = dict[kSecAttrService] as? String, + let account = dict[kSecAttrAccount] as? String { + let actorName = String(account[account.startIndex ..< (account.lastIndex(of: ":") ?? account.endIndex)]) + let attributes = TokenAttributes( + authURLString: service, + actorName: actorName) + continuation.resume(returning: attributes) + return + } + } + } + continuation.resume(returning: nil) + } } } - private func getServiceIdentifier(_ key: String) -> String { - var bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.firezone.firezone" - - if bundleIdentifier.hasSuffix(".network-extension") { - bundleIdentifier.removeLast(".network-extension".count) + func delete(persistentRef: PersistentRef) async throws { + return try await withCheckedThrowingContinuation { [weak self] continuation in + self?.workQueue.async { + let query = [kSecValuePersistentRef: persistentRef] as [CFString: Any] + let ret = SecStatus(SecItemDelete(query as CFDictionary)) + guard (ret.isSuccess || ret == .status(.itemNotFound)) else { + continuation.resume(throwing: KeychainError.appleSecError(call: "SecItemDelete", status: ret)) + return + } + continuation.resume(returning: ()) + } } + } - return bundleIdentifier + "." + key + func search(authURLString: String) async -> PersistentRef? { + return await withCheckedContinuation { [weak self] continuation in + self?.workQueue.async { + let query = [ + kSecClass: kSecClassGenericPassword, + kSecAttrDescription: "Firezone access token", + kSecAttrService: authURLString, + kSecReturnPersistentRef: true, + ] as [CFString: Any] + var result: CFTypeRef? + let ret = SecStatus(SecItemCopyMatching(query as CFDictionary, &result)) + if ret.isSuccess, let tokenRef = result as? Data { + continuation.resume(returning: tokenRef) + } else { + continuation.resume(returning: nil) + } + } + } } private func securityError(_ status: OSStatus) -> Error { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift index a379af9a2..d02721135 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/KeychainStorage.swift @@ -8,9 +8,10 @@ import Dependencies import Foundation struct KeychainStorage: Sendable { - var store: @Sendable (String, Data) async throws -> Void - var load: @Sendable (String) async throws -> Data? - var delete: @Sendable (String) async throws -> Void + var store: @Sendable (Keychain.Token, Keychain.TokenAttributes) async throws -> Keychain.PersistentRef + var delete: @Sendable (Keychain.PersistentRef) async throws -> Void + var loadAttributes: @Sendable (Keychain.PersistentRef) async -> Keychain.TokenAttributes? + var searchByAuthURL: @Sendable (URL) async -> Keychain.PersistentRef? } extension KeychainStorage: DependencyKey { @@ -18,25 +19,33 @@ extension KeychainStorage: DependencyKey { let keychain = Keychain() return KeychainStorage( - store: { try await keychain.store(key: $0, data: $1) }, - load: { try await keychain.load(key: $0) }, - delete: { try await keychain.delete(key: $0) } + store: { try await keychain.store(token: $0, tokenAttributes: $1) }, + delete: { try await keychain.delete(persistentRef: $0) }, + loadAttributes: { await keychain.loadAttributes(persistentRef: $0) }, + searchByAuthURL: { await keychain.search(authURLString: $0.absoluteString) } ) } static var testValue: KeychainStorage { - let storage = LockIsolated([String: Data]()) + let storage = LockIsolated([Data: (Keychain.Token, Keychain.TokenAttributes)]()) return KeychainStorage( - store: { key, data in + store: { token, attributes in storage.withValue { - $0[key] = data + let uuid = UUID().uuidString.data(using: .utf8)! + $0[uuid] = (token, attributes) + return uuid } }, - load: { storage.value[$0] }, - delete: { key in + delete: { ref in storage.withValue { - $0[key] = nil + $0[ref] = nil } + }, + loadAttributes: { ref in + storage.value[ref]?.1 + }, + searchByAuthURL: { authURL in + nil } ) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/Settings.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift similarity index 82% rename from swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/Settings.swift rename to swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift index 280ea1847..a63279322 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/Settings.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift @@ -7,5 +7,5 @@ import Foundation struct Settings: Codable, Hashable { - var teamId: String = "" + var accountId: String = "" } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/SettingsClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/SettingsClient.swift deleted file mode 100644 index 2e6841fd7..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/SettingsClient/SettingsClient.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// SettingsClient.swift -// (c) 2023 Firezone, Inc. -// LICENSE: Apache-2.0 -// - -import Dependencies -import Foundation - -struct SettingsClient { - var fetchSettings: () -> Settings? - var saveSettings: (Settings?) -> Void -} - -extension SettingsClient: DependencyKey { - static let liveValue = SettingsClient( - fetchSettings: { - guard let data = UserDefaults.standard.data(forKey: "settings") else { - return nil - } - - return try? JSONDecoder().decode(Settings.self, from: data) - }, - saveSettings: { settings in - let data = try? JSONEncoder().encode(settings) - UserDefaults.standard.set(data, forKey: "settings") - } - ) - - static var testValue: SettingsClient { - let settings = LockIsolated(Settings?.none) - return SettingsClient( - fetchSettings: { settings.value }, - saveSettings: { settings.setValue($0) } - ) - } -} - -extension DependencyValues { - var settingsClient: SettingsClient { - get { self[SettingsClient.self] } - set { self[SettingsClient.self] = newValue } - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift index e869ebb11..5c4ba6f81 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift @@ -43,12 +43,11 @@ final class AppStore: ObservableObject { private func handleLoginStatusChanged(_ loginStatus: AuthStore.LoginStatus) async { switch loginStatus { - case .signedIn(let authResponse): + case .signedIn: do { - try await tunnel.start(authResponse: authResponse) + try await tunnel.start() } catch { - logger.error("Error starting tunnel: \(String(describing: error)) -- signing out") - auth.signOut() + logger.error("Error starting tunnel: \(String(describing: error))") } case .signedOut: tunnel.stop() @@ -59,6 +58,8 @@ final class AppStore: ObservableObject { private func signOutAndStopTunnel() { tunnel.stop() - auth.signOut() + Task { + try? await auth.signOut() + } } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift index e11e799e8..0e894bf68 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift @@ -24,106 +24,118 @@ extension DependencyValues { final class AuthStore: ObservableObject { private let logger = Logger.make(for: AuthStore.self) - static let shared = AuthStore() + static let shared = AuthStore(tunnelStore: TunnelStore.shared) enum LoginStatus { case uninitialized - case signedOut - case signedIn(AuthResponse) + case signedOut(accountId: String?) + case signedIn(accountId: String, actorName: String) + + var accountId: String? { + switch self { + case .uninitialized: return nil + case .signedOut(let accountId): return accountId + case .signedIn(let accountId, _): return accountId + } + } } @Dependency(\.keychain) private var keychain @Dependency(\.auth) private var auth - @Dependency(\.settingsClient) private var settingsClient - private let authBaseURL: URL + let tunnelStore: TunnelStore + + public let authBaseURL: URL private var cancellables = Set() @Published private(set) var loginStatus: LoginStatus - private init() { - self.authBaseURL = Self.getAuthBaseURLFromInfoPlist() + private init(tunnelStore: TunnelStore) { + self.tunnelStore = tunnelStore + self.authBaseURL = AppInfoPlistConstants.authBaseURL self.loginStatus = .uninitialized - Task { - self.loginStatus = await { () -> LoginStatus in - guard let teamId = settingsClient.fetchSettings()?.teamId else { - logger.debug("No team-id found in settings") - return .signedOut - } - guard let token = try? await keychain.token() else { - logger.debug("Token not found in keychain") - return .signedOut - } - guard let actorName = try? await keychain.actorName() else { - logger.debug("Actor not found in keychain") - return .signedOut - } - let portalURL = self.authURL(teamId: teamId) - let authResponse = AuthResponse(portalURL: portalURL, token: token, actorName: actorName) - logger.debug("Token recovered from keychain.") - return .signedIn(authResponse) - }() - } - $loginStatus - .sink { [weak self] loginStatus in - Task { [weak self] in - switch loginStatus { - case .signedIn(let authResponse): - try? await self?.keychain.save(token: authResponse.token, actorName: authResponse.actorName) - self?.logger.debug("authResponse saved on keychain.") - case .signedOut: - try? await self?.keychain.deleteAuthResponse() - self?.logger.debug("token deleted from keychain.") - case .uninitialized: - break - } + tunnelStore.$tunnelAuthStatus + .sink { [weak self] tunnelAuthStatus in + guard let self = self else { return } + Task { + self.loginStatus = await self.getLoginStatus(from: tunnelAuthStatus) } } .store(in: &cancellables) } - func signIn(teamId: String) async throws { + private func getLoginStatus(from tunnelAuthStatus: TunnelAuthStatus) async -> LoginStatus { + switch tunnelAuthStatus { + case .tunnelUninitialized: + return .uninitialized + case .accountNotSetup: + return .signedOut(accountId: nil) + case .signedOut(let tunnelAuthBaseURL, let tunnelAccountId): + if self.authBaseURL == tunnelAuthBaseURL { + return .signedOut(accountId: tunnelAccountId) + } else { + return .signedOut(accountId: nil) + } + case .signedIn(let tunnelAuthBaseURL, let tunnelAccountId, let tokenReference): + guard self.authBaseURL == tunnelAuthBaseURL else { + return .signedOut(accountId: nil) + } + let tunnelPortalURLString = self.authURL(accountId: tunnelAccountId).absoluteString + guard let tokenAttributes = await keychain.loadAttributes(tokenReference), + tunnelPortalURLString == tokenAttributes.authURLString else { + return .signedOut(accountId: tunnelAccountId) + } + return .signedIn(accountId: tunnelAccountId, actorName: tokenAttributes.actorName) + } + } + + func signIn(accountId: String) async throws { logger.trace("\(#function)") - let portalURL = authURL(teamId: teamId) + let portalURL = authURL(accountId: accountId) let authResponse = try await auth.signIn(portalURL) - self.loginStatus = .signedIn(authResponse) + let attributes = Keychain.TokenAttributes(authURLString: portalURL.absoluteString, actorName: authResponse.actorName ?? "") + let tokenRef = try await keychain.store(authResponse.token, attributes) + + try await tunnelStore.setAuthStatus(.signedIn(authBaseURL: self.authBaseURL, accountId: accountId, tokenReference: tokenRef)) } func signIn() async throws { logger.trace("\(#function)") - guard let teamId = settingsClient.fetchSettings()?.teamId, !teamId.isEmpty else { - logger.debug("No team-id found in settings") + guard case .signedOut(let accountId) = self.loginStatus, let accountId = accountId, !accountId.isEmpty else { + logger.debug("No account-id found in tunnel") throw FirezoneError.missingTeamId } - try await signIn(teamId: teamId) + try await signIn(accountId: accountId) } - func signOut() { + func signOut() async throws { logger.trace("\(#function)") - loginStatus = .signedOut + guard case .signedIn = self.loginStatus else { + return + } + + Task { + if let tokenRef = try await tunnelStore.stopAndSignOut() { + try await keychain.delete(tokenRef) + } + } } - static func getAuthBaseURLFromInfoPlist() -> URL { - let infoPlistDictionary = Bundle.main.infoDictionary - guard let urlScheme = (infoPlistDictionary?["AuthURLScheme"] as? String), !urlScheme.isEmpty else { - fatalError("AuthURLScheme missing in Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.") + func tunnelAuthStatusForAccount(accountId: String) async -> TunnelAuthStatus { + let portalURL = authURL(accountId: accountId) + if let tokenRef = await keychain.searchByAuthURL(portalURL) { + return .signedIn(authBaseURL: authBaseURL, accountId: accountId, tokenReference: tokenRef) + } else { + return .signedOut(authBaseURL: authBaseURL, accountId: accountId) } - guard let urlHost = (infoPlistDictionary?["AuthURLHost"] as? String), !urlHost.isEmpty else { - fatalError("AuthURLHost missing in Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.") - } - let urlString = "\(urlScheme)://\(urlHost)/" - guard let url = URL(string: urlString) else { - fatalError("Cannot form valid URL from string: \(urlString)") - } - return url } - func authURL(teamId: String) -> URL { - self.authBaseURL.appendingPathComponent(teamId) + func authURL(accountId: String) -> URL { + self.authBaseURL.appendingPathComponent(accountId) } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift index 6286f3f56..930c1ce9e 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift @@ -16,74 +16,91 @@ enum TunnelStoreError: Error { final class TunnelStore: ObservableObject { private static let logger = Logger.make(for: TunnelStore.self) - var tunnel: NETunnelProviderManager { - didSet { setupTunnelObservers() } - } + static let shared = TunnelStore() + + static let keyAuthBaseURLString = "authBaseURLString" + static let keyAccountId = "accountId" + + @Published private var tunnel: NETunnelProviderManager? + @Published private(set) var tunnelAuthStatus: TunnelAuthStatus = TunnelAuthStatus(protocolConfiguration: nil) @Published private(set) var status: NEVPNStatus { didSet { TunnelStore.logger.info("status changed: \(self.status.description)") } } - @Published private(set) var isEnabled = false { - didSet { TunnelStore.logger.info("isEnabled changed: \(self.isEnabled.description)") } - } - @Published private(set) var resources = DisplayableResources() private var resourcesTimer: Timer? { didSet(oldValue) { oldValue?.invalidate() } } - private let controlPlaneURL: URL private var tunnelObservingTasks: [Task] = [] private var startTunnelContinuation: CheckedContinuation<(), Error>? + private var cancellables = Set() - init(tunnel: NETunnelProviderManager) { - self.controlPlaneURL = Self.getControlPlaneURLFromInfoPlist() - self.tunnel = tunnel - self.status = tunnel.connection.status - tunnel.isEnabled = true - setupTunnelObservers() + init() { + self.tunnel = nil + self.tunnelAuthStatus = TunnelAuthStatus(protocolConfiguration: nil) + self.status = .invalid + + Task { + await initializeTunnel() + } } - static func loadOrCreate() async throws -> NETunnelProviderManager { - logger.trace("\(#function)") + func initializeTunnel() async { + do { + let managers = try await NETunnelProviderManager.loadAllFromPreferences() + Self.logger.log("\(#function): \(managers.count) tunnel managers found") + if let tunnel = managers.first { + Self.logger.log("\(#function): Tunnel already exists") + self.tunnel = tunnel + self.tunnelAuthStatus = TunnelAuthStatus(protocolConfiguration: tunnel.protocolConfiguration as? NETunnelProviderProtocol) + } else { + let tunnel = NETunnelProviderManager() + tunnel.localizedDescription = "Firezone" + tunnel.protocolConfiguration = TunnelAuthStatus.accountNotSetup.toProtocolConfiguration() + try await tunnel.saveToPreferences() + Self.logger.log("\(#function): Tunnel created") + self.tunnel = tunnel + self.tunnelAuthStatus = .accountNotSetup + } + setupTunnelObservers() + Self.logger.log("\(#function): TunnelStore initialized") + } catch { + Self.logger.error("Error (\(#function)): \(error)") + } + } - let managers = try await NETunnelProviderManager.loadAllFromPreferences() - - if let tunnel = managers.first { - return tunnel + func setAuthStatus(_ tunnelAuthStatus: TunnelAuthStatus) async throws { + guard let tunnel = tunnel else { + fatalError("Tunnel not initialized yet") } - let tunnel = makeManager() + let wasConnected = (tunnel.connection.status == .connected || tunnel.connection.status == .connecting) + if wasConnected { + stop() + } + tunnel.protocolConfiguration = tunnelAuthStatus.toProtocolConfiguration() try await tunnel.saveToPreferences() - try await tunnel.loadFromPreferences() - - return tunnel + self.tunnelAuthStatus = tunnelAuthStatus } - func start(authResponse: AuthResponse) async throws { + func start() async throws { + guard let tunnel = tunnel else { + Self.logger.log("\(#function): TunnelStore is not initialized") + return + } + TunnelStore.logger.trace("\(#function)") - // make sure we have latest preferences before starting - try await tunnel.loadFromPreferences() - if tunnel.connection.status == .connected || tunnel.connection.status == .connecting { - if let (tunnelControlPlaneURLString, tunnelToken) = Self.getTunnelConfigurationParameters(of: tunnel) { - if tunnelControlPlaneURLString == self.controlPlaneURL.absoluteString && tunnelToken == authResponse.token { - // Already connected / connecting with the required configuration - TunnelStore.logger.debug("\(#function): Already connected / connecting. Nothing to do.") - return - } - } + return } - tunnel.protocolConfiguration = Self.makeProtocolConfiguration( - controlPlaneURL: self.controlPlaneURL, - token: authResponse.token - ) tunnel.isEnabled = true try await tunnel.saveToPreferences() + try await tunnel.loadFromPreferences() let session = tunnel.connection as! NETunnelProviderSession try session.startTunnel() @@ -93,11 +110,34 @@ final class TunnelStore: ObservableObject { } func stop() { + guard let tunnel = tunnel else { + Self.logger.log("\(#function): TunnelStore is not initialized") + return + } + TunnelStore.logger.trace("\(#function)") let session = tunnel.connection as! NETunnelProviderSession session.stopTunnel() } + func stopAndSignOut() async throws -> Keychain.PersistentRef? { + guard let tunnel = tunnel else { + Self.logger.log("\(#function): TunnelStore is not initialized") + return nil + } + + TunnelStore.logger.trace("\(#function)") + let session = tunnel.connection as! NETunnelProviderSession + session.stopTunnel() + + if case .signedIn(let authBaseURL, let accountId, let tokenReference) = self.tunnelAuthStatus { + try await setAuthStatus(.signedOut(authBaseURL: authBaseURL, accountId: accountId)) + return tokenReference + } + + return nil + } + func beginUpdatingResources() { self.updateResources() let timer = Timer(timeInterval: 1 /*second*/, repeats: true) { [weak self] _ in @@ -114,6 +154,11 @@ final class TunnelStore: ObservableObject { } private func updateResources() { + guard let tunnel = tunnel else { + Self.logger.log("\(#function): TunnelStore is not initialized") + return + } + let session = tunnel.connection as! NETunnelProviderSession guard session.status == .connected else { self.resources = DisplayableResources() @@ -139,57 +184,9 @@ final class TunnelStore: ObservableObject { let manager = NETunnelProviderManager() manager.localizedDescription = "Firezone" - let proto = makeProtocolConfiguration() - manager.protocolConfiguration = proto - manager.isEnabled = true - return manager } - static func getControlPlaneURLFromInfoPlist() -> URL { - let infoPlistDictionary = Bundle.main.infoDictionary - guard let urlScheme = (infoPlistDictionary?["ControlPlaneURLScheme"] as? String), !urlScheme.isEmpty else { - fatalError("AuthURLScheme missing in Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.") - } - guard let urlHost = (infoPlistDictionary?["ControlPlaneURLHost"] as? String), !urlHost.isEmpty else { - fatalError("AuthURLHost missing in Info.plist. Please define AUTH_URL_SCHEME, AUTH_URL_HOST, CONTROL_PLANE_URL_SCHEME, and CONTROL_PLANE_URL_HOST in Server.xcconfig.") - } - let urlString = "\(urlScheme)://\(urlHost)/" - guard let url = URL(string: urlString) else { - fatalError("Cannot form valid URL from string: \(urlString)") - } - return url - } - - private static func makeProtocolConfiguration(controlPlaneURL: URL? = nil, token: String? = nil) -> NETunnelProviderProtocol { - let proto = NETunnelProviderProtocol() - - proto.providerBundleIdentifier = Bundle.main.bundleIdentifier.map { - "\($0).network-extension" - } - if let controlPlaneURL = controlPlaneURL, let token = token { - proto.providerConfiguration = [ - "controlPlaneURL": controlPlaneURL.absoluteString, - "token": token - ] - } - proto.serverAddress = "Firezone addresses" - return proto - } - - private static func getTunnelConfigurationParameters(of tunnelProvider: NETunnelProviderManager) -> (String, String)? { - guard let tunnelProtocol = tunnelProvider.protocolConfiguration as? NETunnelProviderProtocol else { - return nil - } - guard let controlPlaneURLString = tunnelProtocol.providerConfiguration?["controlPlaneURL"] as? String else { - return nil - } - guard let token = tunnelProtocol.providerConfiguration?["token"] as? String else { - return nil - } - return (controlPlaneURLString, token) - } - private func setupTunnelObservers() { TunnelStore.logger.trace("\(#function)") @@ -229,11 +226,89 @@ final class TunnelStore: ObservableObject { func removeProfile() async throws { TunnelStore.logger.trace("\(#function)") + guard let tunnel = tunnel else { + Self.logger.log("\(#function): TunnelStore is not initialized") + return + } try await tunnel.removeFromPreferences() } } +enum TunnelAuthStatus { + case tunnelUninitialized + case accountNotSetup + case signedOut(authBaseURL: URL, accountId: String) + case signedIn(authBaseURL: URL, accountId: String, tokenReference: Data) + + var isInitialized: Bool { + switch self { + case .tunnelUninitialized: return false + default: return true + } + } + + init(protocolConfiguration: NETunnelProviderProtocol?) { + if let protocolConfiguration = protocolConfiguration { + let providerConfig = protocolConfiguration.providerConfiguration + let authBaseURL: URL? = { + guard let urlString = providerConfig?[TunnelStore.keyAuthBaseURLString] as? String else { return nil } + return URL(string: urlString) + }() + let accountId = providerConfig?[TunnelStore.keyAccountId] as? String + let tokenRef = protocolConfiguration.passwordReference + if let authBaseURL = authBaseURL, let accountId = accountId { + if let tokenRef = tokenRef { + self = .signedIn(authBaseURL: authBaseURL, accountId: accountId, tokenReference: tokenRef) + } else { + self = .signedOut(authBaseURL: authBaseURL, accountId: accountId) + } + } else { + self = .accountNotSetup + } + } else { + self = .tunnelUninitialized + } + } + + func toProtocolConfiguration() -> NETunnelProviderProtocol { + let protocolConfiguration = NETunnelProviderProtocol() + protocolConfiguration.providerBundleIdentifier = Bundle.main.bundleIdentifier.map { + "\($0).network-extension" + } + protocolConfiguration.serverAddress = AppInfoPlistConstants.controlPlaneURL.absoluteString + + switch self { + case .tunnelUninitialized, .accountNotSetup: + break + case .signedOut(let authBaseURL, let accountId): + protocolConfiguration.providerConfiguration = [ + TunnelStore.keyAuthBaseURLString: authBaseURL.absoluteString, + TunnelStore.keyAccountId: accountId + ] + case .signedIn(let authBaseURL, let accountId, let tokenReference): + protocolConfiguration.providerConfiguration = [ + TunnelStore.keyAuthBaseURLString: authBaseURL.absoluteString, + TunnelStore.keyAccountId: accountId + ] + protocolConfiguration.passwordReference = tokenReference + } + + return protocolConfiguration + } + + func accountId() -> String? { + switch self { + case .tunnelUninitialized, .accountNotSetup: + return nil + case .signedOut(_, let accountId): + return accountId + case .signedIn(_, let accountId, _): + return accountId + } + } +} + // MARK: - Extensions /// Make NEVPNStatus convertible to a string diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 340be8e41..5e4631a2e 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -43,7 +43,7 @@ public final class MenuBar: NSObject { private var connectingAnimationTimer: Timer? let settingsViewModel: SettingsViewModel - private var loginStatus: AuthStore.LoginStatus = .signedOut + private var loginStatus: AuthStore.LoginStatus = .signedOut(accountId: nil) private var tunnelStatus: NEVPNStatus = .invalid @@ -64,8 +64,7 @@ public final class MenuBar: NSObject { } Task { - let tunnel = try await TunnelStore.loadOrCreate() - self.appStore = AppStore(tunnelStore: TunnelStore(tunnel: tunnel)) + self.appStore = AppStore(tunnelStore: TunnelStore.shared) updateStatusItemIcon() } } @@ -206,12 +205,11 @@ public final class MenuBar: NSObject { @objc private func reconnectButtonTapped() { Task { - if case .signedIn(let authResponse) = appStore?.auth.loginStatus { + if case .signedIn = appStore?.auth.loginStatus { do { - try await appStore?.tunnel.start(authResponse: authResponse) + try await appStore?.tunnel.start() } catch { - logger.error("error connecting to tunnel: \(String(describing: error)) -- signing out") - appStore?.auth.signOut() + logger.error("error connecting to tunnel (reconnect): \(String(describing: error))") } } } @@ -230,7 +228,13 @@ public final class MenuBar: NSObject { } @objc private func signOutButtonTapped() { - appStore?.auth.signOut() + Task { + do { + try await appStore?.auth.signOut() + } catch { + logger.error("error signing out: \(String(describing: error))") + } + } } @objc private func settingsButtonTapped() { @@ -306,13 +310,8 @@ public final class MenuBar: NSObject { signInMenuItem.title = "Sign In" signInMenuItem.target = self signOutMenuItem.isHidden = true - case .signedIn(let authResponse): - signInMenuItem.title = { - guard let actorName = authResponse.actorName else { - return "Signed in" - } - return "Signed in as \(actorName)" - }() + case .signedIn(_, let actorName): + signInMenuItem.title = actorName.isEmpty ? "Signed in" : "Signed in as \(actorName)" signInMenuItem.target = nil signOutMenuItem.isHidden = false } diff --git a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_iOS.entitlements b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_iOS.entitlements index ffab33e01..aa8672e95 100644 --- a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_iOS.entitlements +++ b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_iOS.entitlements @@ -6,5 +6,9 @@ packet-tunnel-provider + com.apple.security.application-groups + + ${APP_GROUP_ID} + diff --git a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_macOS.entitlements b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_macOS.entitlements index dbbd02597..7546e62e5 100644 --- a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_macOS.entitlements +++ b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension_macOS.entitlements @@ -6,6 +6,10 @@ packet-tunnel-provider + com.apple.security.application-groups + + ${APP_GROUP_ID} + com.apple.security.app-sandbox com.apple.security.network.client diff --git a/swift/apple/FirezoneNetworkExtension/Info.plist b/swift/apple/FirezoneNetworkExtension/Info.plist index 3059459e1..2ea50fd66 100644 --- a/swift/apple/FirezoneNetworkExtension/Info.plist +++ b/swift/apple/FirezoneNetworkExtension/Info.plist @@ -9,5 +9,7 @@ NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).PacketTunnelProvider + AppGroupIdentifier + ${APP_GROUP_ID} diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index a868c422c..a33909397 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -7,9 +7,11 @@ import Dependencies import NetworkExtension import os +import FirezoneKit -enum PacketTunnelProviderError: String, Error { - case savedProtocolConfigurationIsInvalid +enum PacketTunnelProviderError: Error { + case savedProtocolConfigurationIsInvalid(String) + case tokenNotFoundInKeychain case couldNotSetNetworkSettings } @@ -23,31 +25,38 @@ class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler: @escaping (Error?) -> Void ) { Self.logger.trace("\(#function)") - guard let tunnelProviderProtocol = self.protocolConfiguration as? NETunnelProviderProtocol else { - completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid) + + guard let controlPlaneURLString = protocolConfiguration.serverAddress else { + Self.logger.error("serverAddress is missing") + completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("serverAddress")) return } - let providerConfiguration = tunnelProviderProtocol.providerConfiguration - guard let controlPlaneURLString = providerConfiguration?["controlPlaneURL"] as? String else { - completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid) + guard let tokenRef = protocolConfiguration.passwordReference else { + Self.logger.error("passwordReference is missing") + completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("passwordReference")) return } - guard let token = providerConfiguration?["token"] as? String else { - completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid) - return - } - let adapter = Adapter(controlPlaneURLString: controlPlaneURLString, token: token, packetTunnelProvider: self) - self.adapter = adapter - do { - try adapter.start() { error in - if let error { - Self.logger.error("Error in adapter.start: \(error)") + + Task { + let keychain = Keychain() + guard let token = await keychain.load(persistentRef: tokenRef) else { + completionHandler(PacketTunnelProviderError.tokenNotFoundInKeychain) + return + } + + let adapter = Adapter(controlPlaneURLString: controlPlaneURLString, token: token, packetTunnelProvider: self) + self.adapter = adapter + do { + try adapter.start() { error in + if let error { + Self.logger.error("Error in adapter.start: \(error)") + } + completionHandler(error) } + } catch { completionHandler(error) } - } catch { - completionHandler(error) } }