diff --git a/README.md b/README.md new file mode 100644 index 0000000..04bedcb --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# ValidationRelay + +ValidationRelay is an iOS companion used by OpenBubbles to keep the Apple-side +validation channel available. It is a relay component, not an iMessage client: +the Android app still owns the user interface and message persistence, while +this app provides device identifiers and validation data over its WebSocket +connection. + +## Repository status and licensing + +This checkout does not currently contain a declared repository license. Do not +redistribute binaries or publish a fork until the project owner confirms the +license and the terms for the Apple/TrollStore integration. Contributions may +still be prepared as reviewable patches, but licensing is a release gate. + +## Build requirements + +- macOS with Xcode and the iOS SDK +- An iOS device that can install the resulting app (the project deployment + settings currently include iOS 14 and newer) +- Swift Package Manager access to `pusher/NWWebSocket` 0.5.10 or newer within + the declared major range +- For the TrollStore path, the repository's `TrollStore/` submodule and its + documented signing tools + +The checked-in `build.sh` produces an unsigned device build and then packages a +`.tipa` using `ldid` for the normal path. `./build.sh --embed` additionally +builds the embedded fastPathSign helper. Review the script before running it on +a new machine because signing and installation are intentionally environment- +specific. + +## Runtime architecture + +`RelayConnectionManager` owns one active WebSocket delegate, reconnect backoff, +connection timeout, foreground health checks, and the watchdog. Registration +code and secret are treated as a pair. The secret is stored in Keychain when +available, with a legacy local-storage migration only for existing installs. + +Validation generation runs on a dedicated serial queue. Apple certificate and +validation requests use HTTPS, bounded URL-session timeouts, throwing plist +decoders, and native-operation status checks. The UI receives a success or +failure result on the main queue. + +## Safe testing checklist + +1. Use a test Apple account and a non-production relay where possible. +2. Keep the relay app foregrounded and the phone awake for initial testing. +3. Test connect, reconnect, network change, foreground resume, malformed + registration response, and validation failure separately. +4. Confirm that one connection manager and one reconnect timer are active. +5. Check the event log for operation names and redacted IDs only. +6. Never paste registration secrets, Apple credentials, device identifiers, or + validation payloads into an issue. + +An apparent WebSocket “connected” state is not sufficient evidence of a usable +relay. The registration response, validation request, and downstream message +acknowledgement must each succeed. + +## Field-validated configuration + +The following workflow has been exercised successfully on one concrete +configuration: + +- macOS/Xcode compilation of the iOS target +- Signing and installation on an iPhone 7 through TrollStore/ImmortalizerTS +- Apple validation-data exchange +- Carrier registration and physical-device relay behavior +- Foreground/Keep Awake behavior, plus locked-phone and display-off endurance + behavior on a non-jailbroken TrollStore device, with ImmortalizerTS working + and the relay remaining operational +- Device: iPhone 7 running iOS 15.7.9 + +This is field validation of the checked configuration, not a universal +compatibility guarantee for every iOS release, carrier, signing environment, or +device model. Future release candidates should repeat the locked-phone, +display-off, foreground, reconnect, and network-change endurance tests. iOS +suspension and background execution remain OS- and device-dependent, even when +this setup is working correctly. + +## Known limitations + +- Windows-side tooling can capture iPhone Bluetooth traffic for diagnostics, + but it does not expose raw Apple UWB frames or private Find My session keys. +- A phone can be connected to the relay while Apple validation is failing due + to certificate, network, account, or native `absd` errors. +- Background execution and network path migration remain device/OS dependent; + the app cannot guarantee an always-on WebSocket when iOS suspends it. +- Xcode compilation and a long locked-device endurance test were validated for + the iPhone 7/iOS 15.7.9 setup above, but remain required again for future + release candidates and other device/OS combinations. + +For Android-client routing and redacted receive diagnostics, see the +corresponding `docs/DEVELOPMENT.md` and `docs/DIAGNOSTICS.md` files in the +OpenBubbles Android client checkout. diff --git a/ValidationRelay.xcodeproj/project.pbxproj b/ValidationRelay.xcodeproj/project.pbxproj index 7e10d67..b817446 100644 --- a/ValidationRelay.xcodeproj/project.pbxproj +++ b/ValidationRelay.xcodeproj/project.pbxproj @@ -318,7 +318,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = ValidationRelay/ValidationRelay.entitlements; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"ValidationRelay/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -335,7 +335,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8; + MARKETING_VERSION = 1.8.2; PRODUCT_BUNDLE_IDENTIFIER = dev.jjtech.experiments.ValidationRelay; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -357,7 +357,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = ValidationRelay/ValidationRelay.entitlements; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"ValidationRelay/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -374,7 +374,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8; + MARKETING_VERSION = 1.8.2; PRODUCT_BUNDLE_IDENTIFIER = dev.jjtech.experiments.ValidationRelay; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -417,7 +417,7 @@ repositoryURL = "https://github.com/pusher/NWWebSocket.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.5.4; + minimumVersion = 0.5.10; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/ValidationRelay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ValidationRelay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 55f6b9e..c4cb7a9 100644 --- a/ValidationRelay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ValidationRelay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pusher/NWWebSocket.git", "state" : { - "revision" : "1e545fcb53966272fc042aa17ae932f11239e00f", - "version" : "0.5.4" + "revision" : "3d3b8a24b17a0f079195031ca86af117fc158bef", + "version" : "0.5.10" } } ], diff --git a/ValidationRelay/ContentView.swift b/ValidationRelay/ContentView.swift index d51615c..e83eb8b 100644 --- a/ValidationRelay/ContentView.swift +++ b/ValidationRelay/ContentView.swift @@ -8,6 +8,7 @@ import SwiftUI struct ContentView: View { + @Environment(\.scenePhase) private var scenePhase @AppStorage("autoConnect") private var wantRelayConnected = true @AppStorage("keepAwake") private var keepAwake = true @@ -15,30 +16,34 @@ struct ContentView: View { @AppStorage("customRelayURL") private var customRelayURL = "" @ObservedObject var relayConnectionManager: RelayConnectionManager - - init(relayConnectionManager: RelayConnectionManager) { - self.relayConnectionManager = relayConnectionManager - if wantRelayConnected { - relayConnectionManager.connect(getCurrentRelayURL()) - } - if keepAwake { - UIApplication.shared.isIdleTimerDisabled = true - } - } - - func getCurrentRelayURL() -> URL { + + func getCurrentRelayURL() -> URL? { if selectedRelay == "Custom" { - if let url = URL(string: customRelayURL) { - return url + guard let components = URLComponents(string: customRelayURL.trimmingCharacters(in: .whitespacesAndNewlines)), + components.scheme?.lowercased() == "wss", + components.host?.isEmpty == false, + components.user == nil, + components.password == nil, + components.percentEncodedQuery == nil, + components.fragment == nil else { + return nil } + return components.url } else if selectedRelay == "pypush" { return URL(string: "wss://registration-relay.jjtech.dev/api/v1/provider")! } // Default to Beeper relay - selectedRelay = "Beeper" return URL(string: "wss://registration-relay.beeper.com/api/v1/provider")! } + + func connectCurrentRelay() { + guard let url = getCurrentRelayURL() else { + relayConnectionManager.connectionStatusMessage = "Custom relay must be a secure wss:// URL without embedded credentials or a query." + return + } + relayConnectionManager.connectIfNeeded(url) + } var body: some View { NavigationView { @@ -48,7 +53,7 @@ struct ContentView: View { .onChange(of: wantRelayConnected) { newValue in // Connect or disconnect the relay if newValue { - relayConnectionManager.connect(getCurrentRelayURL()) + connectCurrentRelay() } else { relayConnectionManager.disconnect() } @@ -99,11 +104,7 @@ struct ContentView: View { } Toggle("Keep Awake", isOn: $keepAwake) .onChange(of: keepAwake) { newValue in - if keepAwake { - UIApplication.shared.isIdleTimerDisabled = true - } else { - UIApplication.shared.isIdleTimerDisabled = false - } + UIApplication.shared.isIdleTimerDisabled = newValue } Button("Reset Registration Code") { relayConnectionManager.savedRegistrationURL = "" @@ -115,12 +116,27 @@ struct ContentView: View { .foregroundColor(.red) .frame(maxWidth: .infinity) } footer: { - Text("You will need to re-enter the code on your other devices") + Text("You will need to re-enter the code on your other devices. Keep ValidationRelay open in the foreground and keep this iPhone awake.") } } .listStyle(.grouped) .navigationBarHidden(true) .navigationBarTitle("", displayMode: .inline) + .onAppear { + UIApplication.shared.isIdleTimerDisabled = keepAwake + if wantRelayConnected { + connectCurrentRelay() + } + } + .onChange(of: scenePhase) { newPhase in + guard newPhase == .active, + wantRelayConnected, + let url = getCurrentRelayURL() else { + return + } + UIApplication.shared.isIdleTimerDisabled = keepAwake + relayConnectionManager.refreshOnForeground(url) + } } } diff --git a/ValidationRelay/Info.plist b/ValidationRelay/Info.plist index 28094e2..6631ffa 100644 --- a/ValidationRelay/Info.plist +++ b/ValidationRelay/Info.plist @@ -2,9 +2,5 @@ - UIBackgroundModes - - processing - diff --git a/ValidationRelay/LogView.swift b/ValidationRelay/LogView.swift index 04234e8..b03fa06 100644 --- a/ValidationRelay/LogView.swift +++ b/ValidationRelay/LogView.swift @@ -15,11 +15,15 @@ struct LogItem: Identifiable, Hashable { } class LogItems: ObservableObject { + private let maximumItemCount = 500 @Published var items: [LogItem] = [] func log(_ message: String, isError: Bool = false) { let item = LogItem(message: message, isError: isError, date: Date()) items.append(item) + if items.count > maximumItemCount { + items.removeFirst(items.count - maximumItemCount) + } } } diff --git a/ValidationRelay/Relay.swift b/ValidationRelay/Relay.swift index fd5922c..76ec0a7 100644 --- a/ValidationRelay/Relay.swift +++ b/ValidationRelay/Relay.swift @@ -8,9 +8,17 @@ import Foundation import Network import NWWebSocket +import Security import SwiftUI -func getIdentifiers() -> [String: String] { +func copyMobileGestaltString(_ key: String) -> String? { + guard let answer = MGCopyAnswer(key as CFString) else { + return nil + } + return answer.takeRetainedValue() as? String +} + +func getIdentifiers() -> [String: String]? { var ustruct: utsname = utsname() uname(&ustruct) var ustruct2 = ustruct @@ -21,74 +29,327 @@ func getIdentifiers() -> [String: String] { } + guard let softwareBuildID = buildNumber(), + let uniqueDeviceID = copyMobileGestaltString("UniqueDeviceID"), + let serialNumber = copyMobileGestaltString("SerialNumber") else { + return nil + } + let identifiers = [ "hardware_version": machine, "software_name": "iPhone OS", "software_version": UIDevice.current.systemVersion, - "software_build_id": buildNumber()!, - "unique_device_id": MGCopyAnswer("UniqueDeviceID" as CFString)!.takeRetainedValue() as! String, - "serial_number": MGCopyAnswer("SerialNumber" as CFString)!.takeRetainedValue() as! String + "software_build_id": softwareBuildID, + "unique_device_id": uniqueDeviceID, + "serial_number": serialNumber ] return identifiers } +enum RegistrationSecretStore { + private static let service = "app.openbubbles.ValidationRelay" + private static let account = "registration-secret" + + static func read() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } + + @discardableResult + static func write(_ value: String) -> Bool { + if value.isEmpty { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + + guard let data = value.data(using: .utf8) else { + return false + } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { + return true + } + guard updateStatus == errSecItemNotFound else { + return false + } + + var newItem = query + newItem.merge(attributes) { _, new in new } + return SecItemAdd(newItem as CFDictionary, nil) == errSecSuccess + } +} + class RelayConnectionManager: ObservableObject { + private static let fallbackRegistrationSecretKey = "savedRegistrationSecret" + @Published var registrationCode: String = "None" @Published var connectionStatusMessage: String = "" @Published var logItems = LogItems() // These must all be saved together - @AppStorage("savedRegistrationSecret") public var savedRegistrationSecret = "" @AppStorage("savedRegistrationCode") public var savedRegistrationCode = "" @AppStorage("savedRegistrationURL") public var savedRegistrationURL = "" + + public var savedRegistrationSecret: String { + get { + if let keychainSecret = RegistrationSecretStore.read(), + !keychainSecret.isEmpty { + return keychainSecret + } + return UserDefaults.standard.string( + forKey: Self.fallbackRegistrationSecretKey + ) ?? "" + } + set { + if newValue.isEmpty { + _ = RegistrationSecretStore.write("") + UserDefaults.standard.removeObject( + forKey: Self.fallbackRegistrationSecretKey + ) + } else if RegistrationSecretStore.write(newValue) { + UserDefaults.standard.removeObject( + forKey: Self.fallbackRegistrationSecretKey + ) + } else { + UserDefaults.standard.set( + newValue, + forKey: Self.fallbackRegistrationSecretKey + ) + logItems.log( + "Keychain unavailable; using local app storage for relay credentials", + isError: true + ) + } + } + } var currentURL: URL? = nil var connectionDelegate: RelayConnectionDelegate? = nil var reconnectWork: DispatchWorkItem? = nil + var connectionTimeoutWork: DispatchWorkItem? = nil + var watchdogTimer: DispatchSourceTimer? = nil + var lastPongAt: Date? = nil + var shouldReconnect = false var backoff: Int = 2 let maxBackoff: Int = 64 + let connectionTimeout: TimeInterval = 30 + let watchdogInterval: TimeInterval = 30 + let staleConnectionInterval: TimeInterval = 90 + + init() { + if let legacySecret = UserDefaults.standard.string( + forKey: Self.fallbackRegistrationSecretKey + ), + !legacySecret.isEmpty, + RegistrationSecretStore.read() == nil { + if RegistrationSecretStore.write(legacySecret) { + UserDefaults.standard.removeObject( + forKey: Self.fallbackRegistrationSecretKey + ) + } + } else if RegistrationSecretStore.read() != nil { + UserDefaults.standard.removeObject( + forKey: Self.fallbackRegistrationSecretKey + ) + } + } + + func connectIfNeeded(_ url: URL) { + if shouldReconnect, currentURL == url, connectionDelegate != nil { + return + } + connect(url) + } + + func refreshOnForeground(_ url: URL) { + guard shouldReconnect, currentURL == url else { + connect(url) + return + } + if connectionStatusMessage != "Connected" || + lastPongAt.map({ Date().timeIntervalSince($0) > staleConnectionInterval }) == true { + triggerReconnect() + } + } + + private func beginConnection(_ url: URL) { + let delegate = RelayConnectionDelegate(manager: self, url: url) + connectionDelegate = delegate + + connectionTimeoutWork?.cancel() + let work = DispatchWorkItem { [weak self, weak delegate] in + guard let self, + let delegate, + self.connectionDelegate === delegate, + self.connectionStatusMessage != "Connected" else { + return + } + self.connectionTimeoutWork = nil + self.logItems.log("Websocket connection timed out", isError: true) + self.triggerReconnect(delegate) + } + connectionTimeoutWork = work + DispatchQueue.main.asyncAfter( + deadline: .now() + connectionTimeout, + execute: work + ) + } func connect(_ url: URL) { - logItems.log("Connecting to \(url)") + let port = url.port.map { ":\($0)" } ?? "" + logItems.log("Connecting to \(url.scheme ?? "wss")://\(url.host ?? "relay")\(port)") connectionStatusMessage = "Connecting..." currentURL = url + shouldReconnect = true backoff = 2 // Reset backoff reconnectWork?.cancel() reconnectWork = nil + connectionTimeoutWork?.cancel() + connectionTimeoutWork = nil + stopWatchdog() - connectionDelegate = RelayConnectionDelegate(manager: self) + let oldDelegate = connectionDelegate + connectionDelegate = nil + oldDelegate?.disconnect() + beginConnection(url) } func disconnect() { logItems.log("Disconnecting on request") connectionStatusMessage = "" + shouldReconnect = false currentURL = nil reconnectWork?.cancel() reconnectWork = nil + connectionTimeoutWork?.cancel() + connectionTimeoutWork = nil + stopWatchdog() - connectionDelegate?.disconnect() + let oldDelegate = connectionDelegate connectionDelegate = nil + oldDelegate?.disconnect() } - func triggerReconnect() { + func didConnect(_ delegate: RelayConnectionDelegate) { + guard connectionDelegate === delegate else { + return + } + + logItems.log("Websocket did connect") + connectionStatusMessage = "Connected" + connectionTimeoutWork?.cancel() + connectionTimeoutWork = nil + lastPongAt = Date() + startWatchdog() + } + + func didReceivePong(_ delegate: RelayConnectionDelegate) { + guard connectionDelegate === delegate else { + return + } + + lastPongAt = Date() + backoff = 2 + } + + func didDisconnect(_ delegate: RelayConnectionDelegate) { + guard connectionDelegate === delegate else { + return + } + + logItems.log("Websocket did disconnect", isError: true) + triggerReconnect() + } + + func startWatchdog() { + stopWatchdog() + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + watchdogInterval, repeating: watchdogInterval) + timer.setEventHandler { [weak self] in + guard let self, + self.shouldReconnect, + let lastPongAt = self.lastPongAt, + Date().timeIntervalSince(lastPongAt) > self.staleConnectionInterval else { + return + } + + self.logItems.log("Websocket heartbeat timed out", isError: true) + self.triggerReconnect() + } + watchdogTimer = timer + timer.resume() + } + + func stopWatchdog() { + watchdogTimer?.cancel() + watchdogTimer = nil + lastPongAt = nil + } + + func triggerReconnect(_ delegate: RelayConnectionDelegate? = nil) { + if let delegate, connectionDelegate !== delegate { + return + } + guard shouldReconnect, currentURL != nil else { + return + } + guard reconnectWork == nil else { + return + } + logItems.log("Triggering reconnect") connectionStatusMessage = "Reconnecting..." - // Delete the delegate so that more errors don't come in - connectionDelegate?.disconnect() + connectionTimeoutWork?.cancel() + connectionTimeoutWork = nil + stopWatchdog() + + // Clear the active delegate before disconnecting it so its delayed + // callbacks cannot schedule a second reconnect. + let oldDelegate = connectionDelegate connectionDelegate = nil + oldDelegate?.disconnect() print("Waiting for \(backoff) backoff seconds") - reconnectWork?.cancel() - reconnectWork = nil - - let work = DispatchWorkItem { - self.connectionDelegate = RelayConnectionDelegate(manager: self) + let work = DispatchWorkItem { [weak self] in + guard let self else { + return + } + self.reconnectWork = nil + guard self.shouldReconnect, let url = self.currentURL else { + return + } + self.beginConnection(url) } // Wait a bit then try again DispatchQueue.main.asyncAfter(deadline: .now() + DispatchTimeInterval.seconds(backoff), execute: work) @@ -100,13 +361,14 @@ class RelayConnectionManager: ObservableObject { class RelayConnectionDelegate: WebSocketConnectionDelegate, ObservableObject { var connection: WebSocketConnection - let manager: RelayConnectionManager + weak var manager: RelayConnectionManager? init( - manager: RelayConnectionManager + manager: RelayConnectionManager, + url: URL ) { self.manager = manager - connection = NWWebSocket(url: manager.currentURL!, connectAutomatically: true) + connection = NWWebSocket(url: url, connectAutomatically: true) connection.delegate = self connection.ping(interval: 30) } @@ -116,83 +378,146 @@ class RelayConnectionDelegate: WebSocketConnectionDelegate, ObservableObject { } func webSocketDidConnect(connection: WebSocketConnection) { - manager.logItems.log("Websocket did connect") - manager.connectionStatusMessage = "Connected" + guard let manager else { + return + } + manager.didConnect(self) var registerCommand = ["command": "register", "data": ["": ""]] as [String : Any] - if manager.currentURL?.absoluteString == manager.savedRegistrationURL { - print("Using saved registration code") - manager.logItems.log("Using saved registration code \(manager.savedRegistrationCode)") - manager.logItems.log("Using saved registration secret \(manager.savedRegistrationSecret)") - registerCommand["data"] = ["code": manager.savedRegistrationCode, "secret": manager.savedRegistrationSecret] + let savedSecret = manager.savedRegistrationSecret + if manager.currentURL?.absoluteString == manager.savedRegistrationURL, + !manager.savedRegistrationCode.isEmpty, + !savedSecret.isEmpty { + manager.logItems.log("Using saved registration credentials") + registerCommand["data"] = [ + "code": manager.savedRegistrationCode, + "secret": savedSecret + ] + } else if manager.currentURL?.absoluteString == manager.savedRegistrationURL { + manager.logItems.log( + "Saved registration credentials were incomplete; requesting a new code", + isError: true + ) + manager.savedRegistrationURL = "" + manager.savedRegistrationCode = "" + manager.savedRegistrationSecret = "" + manager.registrationCode = "None" + } + do { + let data = try JSONSerialization.data(withJSONObject: registerCommand) + guard let message = String(data: data, encoding: .utf8) else { + manager.logItems.log("Failed to encode registration request", isError: true) + return + } + connection.send(string: message) + } catch { + manager.logItems.log("Failed to create registration request", isError: true) } - let data = try! JSONSerialization.data(withJSONObject: registerCommand) - print(String(data: data, encoding: .utf8)!) - connection.send(string: String(data: data, encoding: .utf8)!) } func webSocketDidDisconnect(connection: WebSocketConnection, closeCode: NWProtocolWebSocket.CloseCode, reason: Data?) { - manager.logItems.log("Websocket did disconnect", isError: true) - print("Disconnected") + manager?.didDisconnect(self) } func webSocketViabilityDidChange(connection: WebSocketConnection, isViable: Bool) { - // Respond to a WebSocket connection viability change event - print("WebSocket connection viability changed to \(isViable), ignoring") + if !isViable { + manager?.logItems.log( + "Websocket path is temporarily unavailable; waiting for heartbeat recovery" + ) + } } func webSocketDidAttemptBetterPathMigration(result: Result) { // Respond to when a WebSocket connection migrates to a better network path // (e.g. A device moves from a cellular connection to a Wi-Fi connection) - print("WebSocket connection attempted better path migration, ignoring") + manager?.logItems.log("Websocket connection attempted network path migration") } func webSocketDidReceiveError(connection: WebSocketConnection, error: NWError) { - print(error) - manager.logItems.log("Websocket error: \(error)", isError: true) - self.manager.triggerReconnect() + manager?.logItems.log("Websocket error: \(error)", isError: true) + manager?.triggerReconnect(self) } func webSocketDidReceivePong(connection: WebSocketConnection) { - // Respond to a WebSocket connection receiving a Pong from the peer - //print("pong") + manager?.didReceivePong(self) } func webSocketDidReceiveMessage(connection: WebSocketConnection, string: String) { - print("Got string msg") - // Parse as JSON - let json = try! JSONSerialization.jsonObject(with: string.data(using: .utf8)!, options: []) - print(json) + guard let manager else { + return + } + guard let encodedMessage = string.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: encodedMessage, options: []) else { + manager.logItems.log("Received an invalid relay message", isError: true) + return + } + manager.didReceivePong(self) // Save registration code if let jsonDict = json as? [String: Any] { - if let data = jsonDict["data"] as? [String: Any] { - if let code = data["code"] as? String { + if jsonDict["command"] as? String == "response", + let data = jsonDict["data"] as? [String: Any] { + if let code = data["code"] as? String, + let secret = data["secret"] as? String, + code.range( + of: #"^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}(-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}){3}$"#, + options: .regularExpression + ) != nil, + secret.range( + of: #"^[A-Za-z0-9+/_-]{1,128}={0,2}$"#, + options: .regularExpression + ) != nil { manager.registrationCode = code if manager.savedRegistrationCode != code { - manager.logItems.log("New registration code \(code)") - manager.logItems.log("secret \(data["secret"] as? String ?? "")") + manager.logItems.log("Received new registration credentials") } manager.savedRegistrationCode = code - manager.savedRegistrationSecret = data["secret"] as? String ?? "" + manager.savedRegistrationSecret = secret manager.savedRegistrationURL = manager.currentURL?.absoluteString ?? "" + } else if data["code"] != nil || data["secret"] != nil { + manager.logItems.log("Rejected malformed registration credentials", isError: true) } } - if let command = jsonDict["command"] as? String { + if let command = jsonDict["command"] as? String, + command == "get-version-info" || command == "get-validation-data" { + guard let requestID = jsonDict["id"] else { + manager.logItems.log("Received a relay request without an id", isError: true) + return + } if command == "get-version-info" { - let versionInfo = ["command": "response", "data": ["versions": getIdentifiers()], "id": jsonDict["id"]!] as [String : Any] - print("Sending version info: \(versionInfo)") - manager.logItems.log("Sending version info: \(versionInfo)") - let data = try! JSONSerialization.data(withJSONObject: versionInfo) - connection.send(string: String(data: data, encoding: .utf8)!) + guard let identifiers = getIdentifiers() else { + manager.logItems.log("Failed to read device identifiers", isError: true) + return + } + let versionInfo = ["command": "response", "data": ["versions": identifiers], "id": requestID] as [String : Any] + manager.logItems.log("Sending device version information") + sendJSON(versionInfo, connection: connection) } if command == "get-validation-data" { - print("Sending val data") - let v = generateValidationData() - print("Validation data: \(v.base64EncodedString())") - manager.logItems.log("Generated validation data: \(v.base64EncodedString())") - let validationData = ["command": "response", "data": ["data": v.base64EncodedString()], "id": jsonDict["id"]!] as [String : Any] - let data = try! JSONSerialization.data(withJSONObject: validationData) - connection.send(string: String(data: data, encoding: .utf8)!) + manager.logItems.log("Generating validation data") + generateValidationData { [weak self, weak manager] result in + DispatchQueue.main.async { + guard let self, + let manager, + manager.connectionDelegate === self else { + return + } + switch result { + case .success(let validationData): + manager.logItems.log("Generated validation data") + let response = [ + "command": "response", + "data": ["data": validationData.base64EncodedString()], + "id": requestID + ] as [String : Any] + self.sendJSON(response, connection: connection) + case .failure(let error): + manager.logItems.log( + "Failed to generate validation data: \(error.localizedDescription)", + isError: true + ) + } + } + } } } } @@ -200,10 +525,20 @@ class RelayConnectionDelegate: WebSocketConnectionDelegate, ObservableObject { } func webSocketDidReceiveMessage(connection: WebSocketConnection, data: Data) { - // Respond to a WebSocket connection receiving a binary `Data` message - // let json = try! JSONSerialization.jsonObject(with: data, options: []) - print("got data msg") - print(data) + manager?.logItems.log("Received an unexpected binary relay message", isError: true) + } + + private func sendJSON(_ object: [String: Any], connection: WebSocketConnection) { + do { + let data = try JSONSerialization.data(withJSONObject: object) + guard let message = String(data: data, encoding: .utf8) else { + manager?.logItems.log("Failed to encode relay response", isError: true) + return + } + connection.send(string: message) + } catch { + manager?.logItems.log("Failed to create relay response", isError: true) + } } } diff --git a/ValidationRelay/ValidationData.swift b/ValidationRelay/ValidationData.swift index 283a4cb..9886c59 100644 --- a/ValidationRelay/ValidationData.swift +++ b/ValidationRelay/ValidationData.swift @@ -7,75 +7,230 @@ import Foundation -//struct ValidationSession { -// init() { -// // Setup session, make request -// } -// -// var expiry: Date { -// get { -// return Date() -// } -// } -// -// func sign(_ data: Data = Data()) -> Data { -// return Data() -// } -//} - -/// Makes an HTTP request to http://static.ess.apple.com/identity/validation/cert-1.0.plist -/// parses the plist and extracts the raw certificate data -func getCertificate() -> Data { - let url = URL(string: "http://static.ess.apple.com/identity/validation/cert-1.0.plist")! - let data = try! Data(contentsOf: url) - let plist = try! PropertyListSerialization.propertyList(from: data, options: [], format: nil) as! [String: Any] - let certData = plist["cert"] as! Data - return certData +enum ValidationDataError: LocalizedError { + case invalidResponse + case httpStatus(Int) + case missingPlistValue(String) + case nativeFailure(String, Int) + case nativeOutputMissing(String) + case requestTimedOut + case bundledCertificateInvalid + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Apple returned an invalid response." + case .httpStatus(let status): + return "Apple returned HTTP status \(status)." + case .missingPlistValue(let key): + return "Apple's response did not contain \(key)." + case .nativeFailure(let operation, let status): + return "\(operation) failed with status \(status)." + case .nativeOutputMissing(let operation): + return "\(operation) did not return data." + case .requestTimedOut: + return "The validation request timed out." + case .bundledCertificateInvalid: + return "The bundled Apple validation certificate is invalid." + } + } +} + +private let validationQueue = DispatchQueue(label: "app.openbubbles.validation-relay.validation") +private let validationRequestTimeout: TimeInterval = 30 +// SHA-256: 1B172B7411B63A03C6E220C11A5B7C6D08DA91A418B59E5B2794AAC0EC6EE4DA +private let bundledValidationCertificateBase64 = """ +AQIAAAQWMIIEEjCCAvqgAwIBAgIBHDANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBw +bGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJv +b3QgQ0EwHhcNMTEwMTI2MTkwMTM0WhcNMTkwMTI2MTkwMTM0WjCBhTELMAkGA1UEBhMCVVMxEzARBgNVBAoMCkFw +cGxlIEluYy4xJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MTkwNwYDVQQDDDBBcHBsZSBT +eXN0ZW0gSW50ZWdyYXRpb24gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQDa4A+Yl8tYKYYqC7ieGVoxwy0OaixSAe4dA/uCQWnNUCY2ercMbw45A7jUGFajCLI8w/s2QeTXyGdg +MgtOMn2H9/3NU7AauvwfbMlFB62COPOofMROwrFW2T6ybW0EQRrBmkfArBV8LXiRqweiZbF6g92YS3dA2O5Q68dr +WAgGl1dVfSf4Cua1Feenk/nxgOZCeT8W0zKdEXZBKQoxCe8PW/jzp6n3Ug27+C10rKZJHx/OewWnhT2+z6KnqiOF +Zv7FFhJ+W+Ixd5ECCd9+fuSK4OxBrBcsBOC8eaSJeEQGiztLoLyE4rCCtTK+BBwD7YI+dTcUz3WfgjFtzwkUhtEn +AgMBAAGjga4wgaswDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFPAwc2Py7x2szOYJ +MsH6eXqxaVBoMB8GA1UdIwQYMBaAFCvQaUeUdgn+9GuNLkCm90dNfwheMDYGA1UdHwQvMC0wK6ApoCeGJWh0dHA6 +Ly93d3cuYXBwbGUuY29tL2FwcGxlY2Evcm9vdC5jcmwwEAYKKoZIhvdjZAYCBAQCBQAwDQYJKoZIhvcNAQEFBQAD +ggEBAD17j60fDCKKm0ujz/grsB9o4Qz3nCSDFgMt07Ko0EPorzyXJsit1SzETFVTAUnQ4rT75tty0Zi7/JvITreP +zGWGf0S52icqTt/L39N930Fx+LPAHaIKM7nsK8Vzcvvhyl2OLzT0a8RPD8iKrA/7byVut66Ox+QCuCBOXVZMSZex +JHR+yZOTNIyZ0afAHNPUwq5p65+fV+Jox8rVxSKCZEFY/njRysH5NmprRPezhnJ6ZEAXMZ28rHXw+jNR5b0Balg/ +8ACumVwKwsnpXhyHAuygCFVBKpuMZIWOUAPN4BGvznIZ61Lzr5Ktky6Undav/8Am8d6UkhzZvD02zFX6ONsAAAUx +MIIFLTCCBBWgAwIBAgIISyyRSB2bfaAwDQYJKoZIhvcNAQEFBQAwgYUxCzAJBgNVBAYTAlVTMRMwEQYDVQQKDApB +cHBsZSBJbmMuMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTE5MDcGA1UEAwwwQXBwbGUg +U3lzdGVtIEludGVncmF0aW9uIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTExMDMyNTAxMTMzMloXDTE0MDMy +NDAxMTMzMlowaTEdMBsGA1UEAwwURFJNIFRlY2hub2xvZ2llcyBBMDExJjAkBgNVBAsMHUFwcGxlIENlcnRpZmlj +YXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBALQGbX5lc5fhv0mx+poiLqfTcYEga0lBFcLbYHrGordNei+OwWMHHATMk9jgDci48lvO +bfpCyxBAwiQKp+QdJoKKPjCGXe0XOO6Hq73oSEpJd4Uut5GEmyl9QQWgefWtjMEL2J1p55yyqUbQS/4JGFAkillH +KyJVR+1RIp1C6Z3ugcNHzeRvCio/TyvSBNC4jOhkmN/OYFObiBrP1MINdGW/84WHX0uHEKKHim0+QFUO+Z+ZzDKT +g1GIybn4XskZXxfna5t8Ot3/aN/U0TRVdOz3S+gckHWF8vxD/6VEI1I/+/Uh44MWP74KdPk8dJlq/j/SWqFQ4y6L +SA0iJjvVnkkCAwEAAaOCAbowggG2MB0GA1UdDgQWBBTSJCP76+iOj3GchO5icz3pXiQJLzAMBgNVHRMBAf8EAjAA +MB8GA1UdIwQYMBaAFPAwc2Py7x2szOYJMsH6eXqxaVBoMIIBDgYDVR0gBIIBBTCCAQEwgf4GCSqGSIb3Y2QFATCB +8DAoBggrBgEFBQcCARYcaHR0cDovL3d3dy5hcHBsZS5jb20vYXBwbGVjYTCBwwYIKwYBBQUHAgIwgbYMgbNSZWxp +YW5jZSBvbiB0aGlzIGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2YgdGhlIHRo +ZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9ucyBvZiB1c2UsIGNlcnRpZmljYXRlIHBv +bGljeSBhbmQgY2VydGlmaWNhdGlvbiBwcmFjdGljZSBzdGF0ZW1lbnRzLjAvBgNVHR8EKDAmMCSgIqAghh5odHRw +Oi8vY3JsLmFwcGxlLmNvbS9hc2ljYS5jcmwwDgYDVR0PAQH/BAQDAgWgMBMGCiqGSIb3Y2QGDAEBAf8EAgUAMA0G +CSqGSIb3DQEBBQUAA4IBAQB9eadjbkE7vsHOsYz6bTAguLpJMJI9HVXOucItS2LFykD2t7yx9tKm+q0Ba08czK7O +RiD/wrPALHdP0BNETIfHYS0Px8xDLjc6N/2umJoStkmwqnfTU4GWgM2E23OqR6ggVjbC2aXpDDwiHXnv57BPCX1e ++7Iio7b3IyUJg3moNFaE5kWtIqEcVZyiLx+2Ibn/2A/Jcwl28AMXGY/po/zmQstfZIaWjGg/wqBYQtSfdm2Vv8D3 +2xR0/Fqogsem/FaKN7fIcpy8m0TRRuKNJNl/J3nxdLnFsrDC4SYG5P+vpQvZox6V20SRzOlLAjID5lL2pypaIzTQ +HRfy6+rCeQrp +""" + +private func fetchValidationData(_ request: URLRequest) throws -> Data { + let semaphore = DispatchSemaphore(value: 0) + var requestResult: Result? + + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = validationRequestTimeout + configuration.timeoutIntervalForResource = validationRequestTimeout + let session = URLSession(configuration: configuration) + let task = session.dataTask(with: request) { data, response, error in + defer { + semaphore.signal() + } + + if let error { + requestResult = .failure(error) + return + } + guard let httpResponse = response as? HTTPURLResponse, + let data else { + requestResult = .failure(ValidationDataError.invalidResponse) + return + } + guard (200...299).contains(httpResponse.statusCode) else { + requestResult = .failure(ValidationDataError.httpStatus(httpResponse.statusCode)) + return + } + requestResult = .success(data) + } + task.resume() + + guard semaphore.wait(timeout: .now() + validationRequestTimeout + 5) == .success else { + task.cancel() + session.invalidateAndCancel() + throw ValidationDataError.requestTimedOut + } + session.finishTasksAndInvalidate() + guard let requestResult else { + throw ValidationDataError.invalidResponse + } + return try requestResult.get() +} + +private func certificateFromPlist(_ data: Data) throws -> Data { + guard let plist = try PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ) as? [String: Any], + let certificate = plist["cert"] as? Data else { + throw ValidationDataError.missingPlistValue("cert") + } + return certificate } -/// Makes an HTTPS POST to https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/initializeValidation -/// with a plist containing the session-info-request and returns the session-info -func initializeValidation(_ request: Data) -> Data { - // Encode body as session-info-request key in plist - let requestB = try! PropertyListSerialization.data(fromPropertyList: ["session-info-request": request], format: .xml, options: 0) - - let url = URL(string: "https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/initializeValidation")! - var req = URLRequest(url: url) - req.httpMethod = "POST" - req.httpBody = requestB - req.setValue("application/x-apple-plist", forHTTPHeaderField: "Content-Type") - NSLog("Making POST request to \(url) with body \(requestB)") - let data = try! NSURLConnection.sendSynchronousRequest(req, returning: nil) - // Parse the response - NSLog("Got response \(data)") - let plist = try! PropertyListSerialization.propertyList(from: data, options: [], format: nil) as! [String: Any] - NSLog("Got plist \(plist)") - let sessionInfo = plist["session-info"] as! Data - NSLog("Got session info \(sessionInfo)") +private func isCertificateTrustError(_ error: Error) -> Bool { + guard let urlError = error as? URLError else { + return false + } + return [ + .secureConnectionFailed, + .serverCertificateHasBadDate, + .serverCertificateUntrusted, + .serverCertificateNotYetValid, + .clientCertificateRejected + ].contains(urlError.code) +} + +private func getCertificate() throws -> Data { + guard let url = URL(string: "https://static.ess.apple.com/identity/validation/cert-1.0.plist") else { + throw ValidationDataError.invalidResponse + } + do { + return try certificateFromPlist( + fetchValidationData(URLRequest(url: url)) + ) + } catch { + guard isCertificateTrustError(error), + let bundledCertificate = Data( + base64Encoded: bundledValidationCertificateBase64, + options: .ignoreUnknownCharacters + ), + bundledCertificate.count == 2385 else { + throw error + } + return bundledCertificate + } +} + +private func initializeValidation(_ request: Data) throws -> Data { + let requestBody = try PropertyListSerialization.data( + fromPropertyList: ["session-info-request": request], + format: .xml, + options: 0 + ) + + guard let url = URL(string: "https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/initializeValidation") else { + throw ValidationDataError.invalidResponse + } + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.httpBody = requestBody + urlRequest.setValue("application/x-apple-plist", forHTTPHeaderField: "Content-Type") + + let data = try fetchValidationData(urlRequest) + guard let plist = try PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ) as? [String: Any], + let sessionInfo = plist["session-info"] as? Data else { + throw ValidationDataError.missingPlistValue("session-info") + } return sessionInfo } -func generateValidationData() -> Data { - let cert: Data = getCertificate() - var val_ctx: UInt64 = 0 - var session_req: NSData? = NSData() - var ret = NACInit(cert, &val_ctx, &session_req) - NSLog("NACInit returned \(ret)") - assert(ret == 0) - let sessionInfo = initializeValidation(session_req! as Data) - NSLog("Got session info \(sessionInfo)") - - ret = NACKeyEstablishment(val_ctx, sessionInfo) - NSLog("NACKeyEstablishment returned \(ret)") - assert(ret == 0) - - var signature: NSData? = NSData() - ret = NACSign(val_ctx, Data(), &signature) - NSLog("NACSign returned \(ret)") - assert(ret == 0) - - NSLog("VALIDATION DATA \(signature!.base64EncodedString())") - - return signature! as Data +private func generateValidationData() throws -> Data { + let certificate = try getCertificate() + var validationContext: UInt64 = 0 + var sessionRequest: NSData? + var status = NACInit(certificate, &validationContext, &sessionRequest) + guard status == 0 else { + throw ValidationDataError.nativeFailure("NACInit", Int(status)) + } + guard let sessionRequest else { + throw ValidationDataError.nativeOutputMissing("NACInit") + } + + let sessionInfo = try initializeValidation(sessionRequest as Data) + status = NACKeyEstablishment(validationContext, sessionInfo) + guard status == 0 else { + throw ValidationDataError.nativeFailure("NACKeyEstablishment", Int(status)) + } + + var signature: NSData? + status = NACSign(validationContext, Data(), &signature) + guard status == 0 else { + throw ValidationDataError.nativeFailure("NACSign", Int(status)) + } + guard let signature else { + throw ValidationDataError.nativeOutputMissing("NACSign") + } + return signature as Data } +func generateValidationData( + completion: @escaping (Result) -> Void +) { + validationQueue.async { + do { + completion(.success(try generateValidationData())) + } catch { + completion(.failure(error)) + } + } +} diff --git a/ValidationRelay/ValidationRelay-Bridging-Header.h b/ValidationRelay/ValidationRelay-Bridging-Header.h index 0623237..d46317f 100644 --- a/ValidationRelay/ValidationRelay-Bridging-Header.h +++ b/ValidationRelay/ValidationRelay-Bridging-Header.h @@ -13,6 +13,13 @@ mach_port_t ABSD_PORT = MACH_PORT_NULL; uint32_t NAC_MAGIC = 0x50936603; +void resetABSDPort() { + if (ABSD_PORT != MACH_PORT_NULL) { + mach_port_deallocate(mach_task_self(), ABSD_PORT); + ABSD_PORT = MACH_PORT_NULL; + } +} + int NACInit(NSData *certificate, uint64_t *out_val_ctx, NSData **out_session_request) { if (ABSD_PORT == MACH_PORT_NULL) { kern_return_t kret = bootstrap_look_up(bootstrap_port, "com.apple.absd", &ABSD_PORT); @@ -29,17 +36,25 @@ int NACInit(NSData *certificate, uint64_t *out_val_ctx, NSData **out_session_req int ret = rawNACInit(ABSD_PORT, NAC_MAGIC, (vm_offset_t)[certificate bytes], [certificate length], &val_ctx, &session_request, &session_requestCnt); if (ret != 0) { NSLog(@"remoteNACInit failed: %d", ret); + resetABSDPort(); return ret; } NSLog(@"done"); *out_val_ctx = val_ctx; *out_session_request = [NSData dataWithBytes:(void *)session_request length:session_requestCnt]; + if (session_request != 0) { + vm_deallocate(mach_task_self(), (vm_address_t)session_request, (vm_size_t)session_requestCnt); + } //rawNACInit( return 0; } int NACKeyEstablishment(uint64_t val_ctx, NSData *session_response) { - return rawNACKeyEstablishment(ABSD_PORT, NAC_MAGIC, val_ctx, (vm_offset_t)[session_response bytes], [session_response length]); + int ret = rawNACKeyEstablishment(ABSD_PORT, NAC_MAGIC, val_ctx, (vm_offset_t)[session_response bytes], [session_response length]); + if (ret != 0) { + resetABSDPort(); + } + return ret; } int NACSign(uint64_t val_ctx, NSData *data, NSData **out_signature) { @@ -48,19 +63,31 @@ int NACSign(uint64_t val_ctx, NSData *data, NSData **out_signature) { int ret = rawNACSign(ABSD_PORT, NAC_MAGIC, val_ctx, (vm_offset_t)[data bytes], [data length], &signature, &signatureCnt); if (ret != 0) { NSLog(@"remoteNACSign failed: %d", ret); + resetABSDPort(); return ret; } *out_signature = [NSData dataWithBytes:(void *)signature length:signatureCnt]; + if (signature != 0) { + vm_deallocate(mach_task_self(), (vm_address_t)signature, (vm_size_t)signatureCnt); + } return 0; } NSString* buildNumber() { - size_t malloc_size = 10; - char *buildNumberBuf = malloc(malloc_size); - sysctlbyname("kern.osversion\0", (void *)buildNumberBuf, &malloc_size, NULL, 0); - - // we don't need to free `buildNumberBuf` if we pass it into this method + size_t bufferSize = 0; + if (sysctlbyname("kern.osversion", NULL, &bufferSize, NULL, 0) != 0 || bufferSize == 0) { + return nil; + } + char *buildNumberBuf = calloc(bufferSize, sizeof(char)); + if (buildNumberBuf == NULL) { + return nil; + } + if (sysctlbyname("kern.osversion", buildNumberBuf, &bufferSize, NULL, 0) != 0) { + free(buildNumberBuf); + return nil; + } NSString *buildNumber = [NSString stringWithCString:buildNumberBuf encoding:NSUTF8StringEncoding]; + free(buildNumberBuf); return buildNumber; } diff --git a/ValidationRelay/ValidationRelay.entitlements b/ValidationRelay/ValidationRelay.entitlements index 951a702..b9d958a 100644 --- a/ValidationRelay/ValidationRelay.entitlements +++ b/ValidationRelay/ValidationRelay.entitlements @@ -2,6 +2,12 @@ + application-identifier + dev.jjtech.experiments.ValidationRelay + keychain-access-groups + + dev.jjtech.experiments.ValidationRelay + com.apple.private.MobileGestalt.AllowedProtectedKeys SerialNumber diff --git a/ValidationRelay/ValidationRelayApp.swift b/ValidationRelay/ValidationRelayApp.swift index f7b762c..e33f1e8 100644 --- a/ValidationRelay/ValidationRelayApp.swift +++ b/ValidationRelay/ValidationRelayApp.swift @@ -9,9 +9,11 @@ import SwiftUI @main struct ValidationRelayApp: App { + @StateObject private var relayConnectionManager = RelayConnectionManager() + var body: some Scene { WindowGroup { - ContentView(relayConnectionManager: RelayConnectionManager()) + ContentView(relayConnectionManager: relayConnectionManager) } } }