diff --git a/swift/apple/Firezone/Application/FirezoneApp.swift b/swift/apple/Firezone/Application/FirezoneApp.swift index d85853823..098aa2482 100644 --- a/swift/apple/Firezone/Application/FirezoneApp.swift +++ b/swift/apple/Firezone/Application/FirezoneApp.swift @@ -69,6 +69,20 @@ struct FirezoneApp: App { public var store: Store? func applicationDidFinishLaunching(_: Notification) { + + Task { + // In 1.4.0 and higher, the macOS client uses a system extension as its + // Network Extension packaging type. It runs as root and can't read the + // existing firezone-id file. So read it here from the app process instead + // and save it to the Keychain, where we should store shared persistent + // data going forward. + // + // Can be removed once all clients >= 1.4.0 + try await FirezoneId.migrate() + + try await FirezoneId.createIfMissing() + } + if let store = store { menuBar = MenuBar(model: SessionViewModel(favorites: favorites, store: store)) } diff --git a/swift/apple/Firezone/Firezone.entitlements b/swift/apple/Firezone/Firezone.entitlements index 92c8fb3e8..1c3620cc3 100644 --- a/swift/apple/Firezone/Firezone.entitlements +++ b/swift/apple/Firezone/Firezone.entitlements @@ -11,6 +11,11 @@ com.apple.security.application-groups $(APP_GROUP_ID) + + $(APP_GROUP_ID_PRE_1_4_0) com.apple.developer.system-extension.install diff --git a/swift/apple/Firezone/xcconfig/debug.xcconfig b/swift/apple/Firezone/xcconfig/debug.xcconfig index 40024bc3a..8b461dc5b 100644 --- a/swift/apple/Firezone/xcconfig/debug.xcconfig +++ b/swift/apple/Firezone/xcconfig/debug.xcconfig @@ -2,5 +2,7 @@ DEVELOPMENT_TEAM = 47R2M6779T PRODUCT_BUNDLE_IDENTIFIER = dev.firezone.firezone APP_GROUP_ID[sdk=macosx*] = 47R2M6779T.dev.firezone.firezone +APP_GROUP_ID_PRE_1_4_0[sdk=macosx*] = 47R2M6779T.group.dev.firezone.firezone APP_GROUP_ID[sdk=iphoneos*] = group.dev.firezone.firezone +APP_GROUP_ID_PRE_1_4_0[sdk=iphoneos*] = group.dev.firezone.firezone CODE_SIGN_STYLE = Automatic diff --git a/swift/apple/Firezone/xcconfig/release.xcconfig b/swift/apple/Firezone/xcconfig/release.xcconfig index 2911175c8..60c40c9a3 100644 --- a/swift/apple/Firezone/xcconfig/release.xcconfig +++ b/swift/apple/Firezone/xcconfig/release.xcconfig @@ -2,7 +2,9 @@ DEVELOPMENT_TEAM = 47R2M6779T PRODUCT_BUNDLE_IDENTIFIER = dev.firezone.firezone APP_GROUP_ID[sdk=macosx*] = 47R2M6779T.dev.firezone.firezone +APP_GROUP_ID_PRE_1_4_0[sdk=macosx*] = 47R2M6779T.group.dev.firezone.firezone APP_GROUP_ID[sdk=iphoneos*] = group.dev.firezone.firezone +APP_GROUP_ID_PRE_1_4_0[sdk=iphoneos*] = group.dev.firezone.firezone CODE_SIGN_STYLE = Manual CODE_SIGN_IDENTITY = Apple Distribution: Firezone, Inc. (47R2M6779T) IOS_APP_PROVISIONING_PROFILE_IDENTIFIER = 07102026-065f-4cc0-800b-5f8595c50ce8 diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift index 71e75887e..44bd8eb18 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift @@ -12,16 +12,6 @@ import UIKit #endif public class DeviceMetadata { - // If firezone-id hasn't ever been written, the app is considered - // to be launched for the first time. - public static func firstTime() -> Bool { - let fileExists = FileManager.default.fileExists( - atPath: SharedAccess.baseFolderURL.appendingPathComponent("firezone-id").path - ) - - return !fileExists - } - public static func getDeviceName() -> String { // Returns a generic device name on iOS 16 and higher // See https://github.com/firezone/firezone/issues/3034 @@ -42,31 +32,6 @@ public class DeviceMetadata { return "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)" } - // Returns the Firezone ID as cached by the application or generates and persists a new one - // if that doesn't exist. The Firezone ID is a UUIDv4 that is used to dedup this device - // for upsert and identification in the admin portal. - public static func getOrCreateFirezoneId() -> String { - let fileURL = SharedAccess.baseFolderURL.appendingPathComponent("firezone-id") - - do { - return try String(contentsOf: fileURL, encoding: .utf8) - } catch { - // Handle the error if the file doesn't exist or isn't readable - // Recreate the file, save a new UUIDv4, and return it - let newUUIDString = UUID().uuidString - - do { - try newUUIDString.write(to: fileURL, atomically: true, encoding: .utf8) - } catch { - Log.app.error( - "\(#function): Could not save firezone-id file \(fileURL.path)! Error: \(error)" - ) - } - - return newUUIDString - } - } - #if os(iOS) public static func deviceInfo() -> DeviceInfo { return DeviceInfo(identifierForVendor: UIDevice.current.identifierForVendor!.uuidString) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift index d854a55d9..6a4f89bd1 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Keychain/Keychain.swift @@ -18,17 +18,9 @@ public enum KeychainError: Error { } public actor Keychain { - private let label = "Firezone token" - private let description = "Firezone access token used to authenticate the client." - private let service = Bundle.main.bundleIdentifier! - - // Bump this for backwards-incompatible Keychain changes; this is effectively the - // upsert key. - private let account = "1" - + public static let shared = Keychain() private let workQueue = DispatchQueue(label: "FirezoneKeychainWorkQueue") - public typealias Token = String public typealias PersistentRef = Data public enum SecStatus: Equatable { @@ -50,23 +42,9 @@ public actor Keychain { public init() {} - public func add(token: Token) async throws { + public func add(query: [CFString: Any]) async throws { return try await withCheckedThrowingContinuation { [weak self] continuation in - self?.workQueue.async { [weak self] in - guard let self = self else { - continuation.resume(throwing: KeychainError.securityError(.unexpectedError)) - return - } - - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrLabel: self.label, - kSecAttrAccount: self.account, - kSecAttrDescription: self.description, - kSecAttrService: self.service, - kSecValueData: token.data(using: .utf8) as Any, - ] - + self?.workQueue.async { var ref: CFTypeRef? let ret = SecStatus(SecItemAdd(query as CFDictionary, &ref)) guard ret.isSuccess else { @@ -81,24 +59,12 @@ public actor Keychain { } } - public func update(token: Token) async throws { + public func update( + query: [CFString: Any], + attributesToUpdate: [CFString: Any] + ) async throws { return try await withCheckedThrowingContinuation { [weak self] continuation in - self?.workQueue.async { [weak self] in - guard let self = self else { - continuation.resume(throwing: KeychainError.securityError(.unexpectedError)) - return - } - - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrLabel: self.label, - kSecAttrAccount: self.account, - kSecAttrDescription: self.description, - kSecAttrService: self.service, - ] - let attributesToUpdate = [ - kSecValueData: token.data(using: .utf8) as Any - ] + self?.workQueue.async { let ret = SecStatus( SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary)) guard ret.isSuccess else { @@ -111,8 +77,7 @@ public actor Keychain { } } - // This function is public because the tunnel needs to call it to get the token - public func load(persistentRef: PersistentRef) async -> Token? { + public func load(persistentRef: PersistentRef) async -> Data? { return await withCheckedContinuation { [weak self] continuation in self?.workQueue.async { let query = @@ -124,10 +89,9 @@ public actor Keychain { 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) + let resultData = result as? Data { - continuation.resume(returning: resultString) + continuation.resume(returning: resultData) } else { continuation.resume(returning: nil) } @@ -135,23 +99,20 @@ public actor Keychain { } } - public func search() async -> PersistentRef? { + public func search(query: [CFString: Any]) async -> PersistentRef? { return await withCheckedContinuation { [weak self] continuation in guard let self = self else { return } self.workQueue.async { - let query = - [ - kSecClass: kSecClassGenericPassword, - kSecAttrLabel: self.label, - kSecAttrAccount: self.account, - kSecAttrDescription: self.description, - kSecAttrService: self.service, - kSecReturnPersistentRef: true, - ] as [CFString: Any] + let query = query.merging([ + kSecClass: kSecClassGenericPassword, + kSecReturnPersistentRef: true, + ]) { (current, new) in new } + var result: CFTypeRef? let ret = SecStatus(SecItemCopyMatching(query as CFDictionary, &result)) - if ret.isSuccess, let tokenRef = result as? Data { - continuation.resume(returning: tokenRef) + + if ret.isSuccess, let persistentRef = result as? Data { + continuation.resume(returning: persistentRef) } else { continuation.resume(returning: nil) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift new file mode 100644 index 000000000..91718b1d4 --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift @@ -0,0 +1,127 @@ +// +// FirezoneId.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// +// Convenience wrapper for working with our firezone-id stored in the Keychain. + +import Foundation + +public struct FirezoneId { + private static let query: [CFString: Any] = [ + kSecAttrLabel: "Firezone id", + kSecAttrAccount: "2", + kSecAttrService: AppInfoPlistConstants.appGroupId, + kSecAttrDescription: "Firezone device id", + ] + + private var uuid: UUID + + public init(_ uuid: UUID? = nil) { + self.uuid = uuid ?? UUID() + } + + // Upsert the firezone-id to the Keychain + public func save(_ keychain: Keychain = Keychain.shared) async throws { + guard await keychain.search(query: FirezoneId.query) == nil + else { + let query = FirezoneId.query.merging([ + kSecClass: kSecClassGenericPassword + ]) { (_, new) in new } + return try await keychain.update( + query: query, + attributesToUpdate: [kSecValueData: uuid.toData()] + ) + } + + let query = FirezoneId.query.merging([ + kSecClass: kSecClassGenericPassword, + kSecValueData: uuid.toData() + ]) { (_, new) in new } + + try await keychain.add(query: query) + } + + // Attempt to load the firezone-id from the Keychain + public static func load(_ keychain: Keychain = Keychain.shared) async throws -> FirezoneId? { + guard let idRef = await keychain.search(query: query) + else { return nil } + + guard let data = await keychain.load(persistentRef: idRef) + else { return nil } + + guard data.count == UUID.sizeInBytes + else { + fatalError("Firezone ID loaded from keychain must be exactly \(UUID.sizeInBytes) bytes") + } + + let uuid = UUID(fromData: data) + return FirezoneId(uuid) + } + + // Prior to 1.4.0, our firezone-id was saved in a file. Starting with 1.4.0, + // the macOS client uses a system extension, which makes sharing folders with + // the app cumbersome, so we moved to using the keychain for this due to its + // better ergonomics. If the old firezone-id doesn't exist, this function + // is a no-op. + // + // Can be refactored to remove the file check once all clients >= 1.4.0 + public static func migrate() async throws { + guard try await load() == nil + else { return } // New firezone-id already saved in Keychain + +#if os(macOS) + let appGroupIdPre_1_4_0 = "47R2M6779T.group.dev.firezone.firezone" +#elseif os(iOS) + let appGroupIdPre_1_4_0 = "group.dev.firezone.firezone" +#endif + + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdPre_1_4_0) + else { fatalError("Couldn't find app group container") } + + let idFileURL = containerURL.appendingPathComponent("firezone-id") + + // If the file isn't there or can't be read, bail + guard FileManager.default.fileExists(atPath: idFileURL.path), + let uuidString = try? String(contentsOf: idFileURL) + else { return } + + let firezoneId = FirezoneId(UUID(uuidString: uuidString)) + try await firezoneId.save() + } + + public static func createIfMissing() async throws { + guard try await load() == nil + else { return } // New firezone-id already saved in Keychain + + let firezoneId = FirezoneId(UUID()) + try await firezoneId.save() + } +} + +// Convenience extension to convert to/from Data for storing in Keychain +extension UUID { + // We need the size of a UUID to (1) know how big to make the Data buffer, + // and (2) to make sure the UUID we read from the keychain is a valid length. + public static let sizeInBytes = MemoryLayout.size(ofValue: UUID()) + + init(fromData: Data) { + self = fromData.withUnsafeBytes { rawBufferPointer in + guard let baseAddress = rawBufferPointer.baseAddress + else { + fatalError("Buffer should point to a valid memory address") + } + + return UUID(uuid: baseAddress.assumingMemoryBound(to: uuid_t.self).pointee) + } + } + + func toData() -> Data { + let data = withUnsafePointer(to: self) { rawBufferPinter in + Data(bytes: rawBufferPinter, count: UUID.sizeInBytes) + } + + return data + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Token.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Token.swift new file mode 100644 index 000000000..e7fe2b78c --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Token.swift @@ -0,0 +1,80 @@ +// +// Token.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// +// Convenience wrapper for working with our auth token stored in the Keychain. + +import Foundation + +public struct Token: CustomStringConvertible { + private static let query: [CFString: Any] = [ + kSecAttrLabel: "Firezone token", + kSecAttrAccount: "1", + kSecAttrService: AppInfoPlistConstants.appGroupId, + kSecAttrDescription: "Firezone access token", + ] + + private var data: Data + + public var description: String { String(data: data, encoding: .utf8)! } + + public init?(_ tokenString: String?) { + guard let tokenString = tokenString, + let data = tokenString.data(using: .utf8) + else { return nil } + + self.data = data + } + + public init(_ data: Data) { + self.data = data + } + + public static func delete( + _ keychain: Keychain = Keychain.shared + ) async throws { + + guard let tokenRef = await keychain.search(query: query) + else { return } + + try await keychain.delete(persistentRef: tokenRef) + } + + // Upsert token to Keychain + public func save(_ keychain: Keychain = Keychain.shared) async throws { + + guard await keychain.search(query: Token.query) == nil + else { + let query = Token.query.merging([ + kSecClass: kSecClassGenericPassword + ]) { (_, new) in new } + + return try await keychain.update( + query: query, + attributesToUpdate: [kSecValueData: data] + ) + } + + let query = Token.query.merging([ + kSecClass: kSecClassGenericPassword, + kSecValueData: data + ]) { (_, new) in new } + + try await keychain.add(query: query) + } + + // Attempt to load token from Keychain + public static func load( + _ keychain: Keychain = Keychain.shared + ) async throws -> Token? { + + guard let tokenRef = await keychain.search(query: query) + else { return nil } + + guard let data = await keychain.load(persistentRef: tokenRef) + else { return nil } + + return Token(data) + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift index 34aebf508..77ddf8720 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift @@ -41,10 +41,17 @@ public class AppViewModel: ObservableObject { self.status = status #if os(macOS) - if status == .invalid || DeviceMetadata.firstTime() { - AppViewModel.WindowDefinition.main.openWindow() - } else { - AppViewModel.WindowDefinition.main.window()?.close() + Task { + let firezoneId = try await FirezoneId.load() + + if status == .invalid || firezoneId == nil { + + // Show the Wecome view if VPN permission needs to be granted + // or it's the first time starting + AppViewModel.WindowDefinition.main.openWindow() + } else { + AppViewModel.WindowDefinition.main.window()?.close() + } } #endif }) diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index 30d9f7aec..d516427f3 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -78,13 +78,13 @@ class Adapter { /// Starting parameters private var apiURL: String - private var token: String + private var token: Token private let logFilter: String private let connlibLogFolderPath: String init( apiURL: String, - token: String, + token: Token, logFilter: String, internetResourceEnabled: Bool, packetTunnelProvider: PacketTunnelProvider @@ -115,7 +115,7 @@ class Adapter { } /// Start the tunnel. - public func start() throws { + public func start() async throws { Log.tunnel.log("Adapter.start") guard case .tunnelStopped = self.state else { throw AdapterError.invalidState @@ -131,12 +131,15 @@ class Adapter { do { let jsonEncoder = JSONEncoder() jsonEncoder.keyEncodingStrategy = .convertToSnakeCase + + let firezoneId = try await FirezoneId.load() + // Grab a session pointer let session = try WrappedSession.connect( apiURL, - token, - DeviceMetadata.getOrCreateFirezoneId(), + "\(token)", + "\(firezoneId!)", DeviceMetadata.getDeviceName(), DeviceMetadata.getOSVersion(), connlibLogFolderPath, diff --git a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension.entitlements b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension.entitlements index 754f9edf6..bf13ab5f8 100644 --- a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension.entitlements +++ b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension.entitlements @@ -9,6 +9,11 @@ com.apple.security.application-groups $(APP_GROUP_ID) + + $(APP_GROUP_ID_PRE_1_4_0) com.apple.security.app-sandbox diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index 9125ae518..6ca33e236 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -25,35 +25,28 @@ class PacketTunnelProvider: NEPacketTunnelProvider { Task { do { - var token = options?["token"] as? String - let keychain = Keychain() - let tokenRef = await keychain.search() + // Can be removed after all clients >= 1.4.0 + try await FirezoneId.migrate() - if let token = token { - // 1. If we're passed a token, save it to keychain + // The tunnel can come up without the app having been launched first, so + // initialize the id here too. + try await FirezoneId.createIfMissing() - // Apple recommends updating Keychain items in place if possible - // In reality this won't happen unless there's some kind of race condition - // because we would have deleted the item upon sign out. - if tokenRef != nil { - try await keychain.update(token: token) - } else { - try await keychain.add(token: token) - } + var passedToken = options?["token"] as? String + var keychainToken = try await Token.load() - } else { + // Use the provided token or try loading one from the Keychain + guard let token = Token(passedToken) ?? keychainToken + else { + completionHandler(PacketTunnelProviderError.tokenNotFoundInKeychain) - // 2. Otherwise, load it from the keychain - guard let tokenRef = tokenRef - else { - completionHandler(PacketTunnelProviderError.tokenNotFoundInKeychain) - return - } - - token = await keychain.load(persistentRef: tokenRef) + return } - // 3. Now we should have a token, so connect + // Save the token back to the Keychain + try await token.save() + + // Now we should have a token, so continue connecting guard let apiURL = protocolConfiguration.serverAddress else { completionHandler( @@ -72,12 +65,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } - guard let token = token - else { - completionHandler(PacketTunnelProviderError.tokenNotFoundInKeychain) - return - } - let internetResourceEnabled: Bool = if let internetResourceEnabledJSON = providerConfiguration[TunnelManagerKeys.internetResourceEnabled]?.data(using: .utf8) { (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false } else { @@ -89,7 +76,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { self.adapter = adapter - try adapter.start() + try await adapter.start() // Tell the system the tunnel is up, moving the tunnelManager status to // `connected`. @@ -112,7 +99,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { if case .authenticationCanceled = reason { do { // This was triggered from onDisconnect, so clear our token - Task { await clearToken() } + Task { try await Token.delete() } // There's no good way to send data like this from the // Network Extension to the GUI, so save it to a file for the GUI to read upon @@ -145,7 +132,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { adapter?.setInternetResourceEnabled(value) case .signOut: Task { - await clearToken() + try await Token.delete() } case .getResourceList(let value): adapter?.getResourcesIfVersionDifferentFrom(hash: value) { @@ -154,22 +141,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } } - - enum TokenError: Error { - case TokenNotFound - } - - private func clearToken() async { - do { - let keychain = Keychain() - guard let ref = await keychain.search() else { - throw TokenError.TokenNotFound - } - try await keychain.delete(persistentRef: ref) - } catch { - Log.tunnel.error("\(#function): Error: \(error)") - } - } } extension NEProviderStopReason: CustomStringConvertible {