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)
}
}
}