mirror of
https://github.com/seemoo-lab/openhaystack.git
synced 2026-08-21 04:26:16 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Foundation
|
||||
|
||||
class AccessoryController: ObservableObject {
|
||||
static let shared = AccessoryController()
|
||||
|
||||
@Published var accessories: [Accessory]
|
||||
|
||||
init() {
|
||||
self.accessories = KeychainController.loadAccessoriesFromKeychain()
|
||||
}
|
||||
|
||||
init(accessories: [Accessory]) {
|
||||
self.accessories = accessories
|
||||
}
|
||||
|
||||
func save() throws {
|
||||
try KeychainController.storeInKeychain(accessories: self.accessories)
|
||||
}
|
||||
|
||||
func load() {
|
||||
self.accessories = KeychainController.loadAccessoriesFromKeychain()
|
||||
}
|
||||
|
||||
func updateWithDecryptedReports(devices: [FindMyDevice]) {
|
||||
// Assign last locations
|
||||
for device in FindMyController.shared.devices {
|
||||
if let idx = self.accessories.firstIndex(where: {$0.id == Int(device.deviceId)}) {
|
||||
self.objectWillChange.send()
|
||||
let accessory = self.accessories[idx]
|
||||
|
||||
let report = device.decryptedReports?
|
||||
.sorted(by: {$0.timestamp ?? Date.distantPast > $1.timestamp ?? Date.distantPast })
|
||||
.first
|
||||
|
||||
accessory.lastLocation = report?.location
|
||||
accessory.locationTimestamp = report?.timestamp
|
||||
|
||||
self.accessories[idx] = accessory
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
import OSLog
|
||||
|
||||
struct KeychainController {
|
||||
|
||||
static func loadAccessoriesFromKeychain(test: Bool=false) -> [Accessory] {
|
||||
var query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrLabel: "FindMyAccessories",
|
||||
kSecAttrService: "SEEMOO-FINDMY",
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
kSecReturnData: true
|
||||
]
|
||||
|
||||
if test {
|
||||
query[kSecAttrService] = "SEEMOO-Test"
|
||||
}
|
||||
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess,
|
||||
let resultData = result as? Data else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Convert from PropertyList to an array of accessories
|
||||
do {
|
||||
let accessories = try PropertyListDecoder().decode([Accessory].self, from: resultData)
|
||||
return accessories
|
||||
} catch {
|
||||
os_log("Could not decode accessories %@", String(describing: error))
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
static func storeInKeychain(accessories: [Accessory], test: Bool=false) throws {
|
||||
// Store or update
|
||||
var attributes: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrLabel: "FindMyAccessories",
|
||||
kSecAttrService: "SEEMOO-FINDMY",
|
||||
kSecValueData: try PropertyListEncoder().encode(accessories)
|
||||
]
|
||||
|
||||
if test {
|
||||
attributes[kSecAttrService] = "SEEMOO-Test"
|
||||
}
|
||||
|
||||
// Try to store the item
|
||||
let storeStatus = SecItemAdd(attributes as CFDictionary, nil)
|
||||
|
||||
if storeStatus == errSecDuplicateItem {
|
||||
var query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrLabel: "FindMyAccessories",
|
||||
kSecAttrService: "SEEMOO-FINDMY"
|
||||
]
|
||||
|
||||
if test {
|
||||
query[kSecAttrService] = "SEEMOO-Test"
|
||||
}
|
||||
|
||||
// Update the existing item
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw KeychainError.updatingItemFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainError: Error {
|
||||
case updatingItemFailed
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildMachineOSBuild</key>
|
||||
<string>20C69</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>HaystackMail</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>de.tu-darmstadt.seemoo.HaystackMail</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>HaystackMail</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>DTCompiler</key>
|
||||
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||
<key>DTPlatformBuild</key>
|
||||
<string>12D4e</string>
|
||||
<key>DTPlatformName</key>
|
||||
<string>macosx</string>
|
||||
<key>DTPlatformVersion</key>
|
||||
<string>11.1</string>
|
||||
<key>DTSDKBuild</key>
|
||||
<string>20C63</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>macosx11.1</string>
|
||||
<key>DTXcode</key>
|
||||
<string>1240</string>
|
||||
<key>DTXcodeBuild</key>
|
||||
<string>12D4e</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2021 SEEMOO - TU Darmstadt. All rights reserved.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>HaystackPluginService</string>
|
||||
<key>Supported10.15PluginCompatibilityUUIDs</key>
|
||||
<array>
|
||||
<string># UUIDs for versions from 10.12 to 99.99.99</string>
|
||||
<string># For mail version 10.0 (3226) on OS X Version 10.12 (build 16A319)</string>
|
||||
<string>36CCB8BB-2207-455E-89BC-B9D6E47ABB5B</string>
|
||||
<string># For mail version 10.1 (3251) on OS X Version 10.12.1 (build 16B2553a)</string>
|
||||
<string>9054AFD9-2607-489E-8E63-8B09A749BC61</string>
|
||||
<string># For mail version 10.2 (3259) on OS X Version 10.12.2 (build 16D12b)</string>
|
||||
<string>1CD3B36A-0E3B-4A26-8F7E-5BDF96AAC97E</string>
|
||||
<string># For mail version 10.3 (3273) on OS X Version 10.12.4 (build 16G1036)</string>
|
||||
<string>21560BD9-A3CC-482E-9B99-95B7BF61EDC1</string>
|
||||
<string># For mail version 11.0 (3441.0.1) on OS X Version 10.13 (build 17A315i)</string>
|
||||
<string>C86CD990-4660-4E36-8CDA-7454DEB2E199</string>
|
||||
<string># For mail version 12.0 (3445.100.39) on OS X Version 10.14.1 (build 18B45d)</string>
|
||||
<string>A4343FAF-AE18-40D0-8A16-DFAE481AF9C1</string>
|
||||
<string># For mail version 13.0 (3594.4.2) on OS X Version 10.15 (build 19A558d)</string>
|
||||
<string>6EEA38FB-1A0B-469B-BB35-4C2E0EEA9053</string>
|
||||
</array>
|
||||
<key>Supported11.0PluginCompatibilityUUIDs</key>
|
||||
<array>
|
||||
<string>D985F0E4-3BBC-4B95-BBA1-12056AC4A531</string>
|
||||
</array>
|
||||
<key>Supported11.1PluginCompatibilityUUIDs</key>
|
||||
<array>
|
||||
<string>D985F0E4-3BBC-4B95-BBA1-12056AC4A531</string>
|
||||
</array>
|
||||
<key>Supported11.2PluginCompatibilityUUIDs</key>
|
||||
<array>
|
||||
<string>D985F0E4-3BBC-4B95-BBA1-12056AC4A531</string>
|
||||
</array>
|
||||
<key>Supported11.3PluginCompatibilityUUIDs</key>
|
||||
<array>
|
||||
<string>D985F0E4-3BBC-4B95-BBA1-12056AC4A531</string>
|
||||
</array>
|
||||
<key>Supported11.4PluginCompatibilityUUIDs</key>
|
||||
<array>
|
||||
<string>D985F0E4-3BBC-4B95-BBA1-12056AC4A531</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
Binary file not shown.
+115
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict/>
|
||||
<key>files2</key>
|
||||
<dict/>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^Resources/</key>
|
||||
<true/>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^version.plist$</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>rules2</key>
|
||||
<dict>
|
||||
<key>.*\.dSYM($|/)</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>11</real>
|
||||
</dict>
|
||||
<key>^(.*/)?\.DS_Store$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>2000</real>
|
||||
</dict>
|
||||
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^Info\.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^PkgInfo$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^[^/]+$</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^embedded\.provisionprofile$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^version\.plist$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,124 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import AppKit
|
||||
|
||||
let mailBundleName = "OpenHaystackMail"
|
||||
|
||||
/// Manages plugin installation
|
||||
struct MailPluginManager {
|
||||
|
||||
let pluginsFolderURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mail/Bundles")
|
||||
|
||||
let pluginURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mail/Bundles").appendingPathComponent(mailBundleName + ".mailbundle")
|
||||
|
||||
var isMailPluginInstalled: Bool {
|
||||
return FileManager.default.fileExists(atPath: pluginURL.path)
|
||||
}
|
||||
|
||||
/// Shows a NSSavePanel to install the mail plugin at the required place
|
||||
func askForPermission() -> Bool {
|
||||
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Install Mail Plugin"
|
||||
panel.prompt = "Install"
|
||||
panel.canCreateDirectories = true
|
||||
panel.showsTagField = false
|
||||
panel.message = "OpenHaystack has no right to access the directory to install the plug-in automatically. By clicking install you grant the persmission."
|
||||
|
||||
if FileManager.default.fileExists(atPath: self.pluginsFolderURL.path) {
|
||||
panel.directoryURL = self.pluginsFolderURL
|
||||
panel.nameFieldLabel = "OpenHaystackMail Plugin"
|
||||
panel.nameFieldStringValue = mailBundleName + ".mailbundle"
|
||||
} else {
|
||||
panel.directoryURL = self.pluginsFolderURL.deletingLastPathComponent()
|
||||
panel.nameFieldLabel = "OpenHaystackMail Plugin"
|
||||
panel.nameFieldStringValue = "Bundles"
|
||||
}
|
||||
|
||||
panel.center()
|
||||
|
||||
let result = panel.runModal()
|
||||
|
||||
return result == .OK && (panel.nameFieldStringValue == "Bundles" || panel.nameFieldStringValue == mailBundleName + ".mailbundle")
|
||||
}
|
||||
|
||||
/// Install the mail plug-in to the correct location
|
||||
/// - Throws: An error if copying the fails fail. Due to permission or other errors
|
||||
func installMailPlugin() throws {
|
||||
guard self.askForPermission() else {
|
||||
throw PluginError.permissionNotGranted
|
||||
}
|
||||
|
||||
let localPluginURL = Bundle.main.url(forResource: mailBundleName, withExtension: "mailbundle")!
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: pluginsFolderURL, withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
try self.copyFolder(from: localPluginURL, to: pluginURL)
|
||||
|
||||
self.openAppleMail()
|
||||
}
|
||||
|
||||
fileprivate func openAppleMail() {
|
||||
NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: "/System/Applications/Mail.app"), configuration: NSWorkspace.OpenConfiguration(), completionHandler: nil)
|
||||
|
||||
}
|
||||
|
||||
/// Copy a folder recursively
|
||||
/// - Parameters:
|
||||
/// - from: Folder source
|
||||
/// - to: Folder destination
|
||||
/// - Throws: An error if copying or acessing files fails
|
||||
func copyFolder(from: URL, to: URL) throws {
|
||||
// Create the folder
|
||||
try? FileManager.default.createDirectory(at: to, withIntermediateDirectories: false, attributes: nil)
|
||||
|
||||
let files = try FileManager.default.contentsOfDirectory(atPath: from.path)
|
||||
for file in files {
|
||||
// Check if file is a folder
|
||||
var isDir: ObjCBool = .init(booleanLiteral: false)
|
||||
let fileURL = from.appendingPathComponent(file)
|
||||
FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDir)
|
||||
|
||||
if isDir.boolValue == true {
|
||||
try self.copyFolder(from: fileURL, to: to.appendingPathComponent(file))
|
||||
} else {
|
||||
// Copy file
|
||||
try FileManager.default.copyItem(at: fileURL, to: to.appendingPathComponent(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uninstallMailPlugin() throws {
|
||||
try FileManager.default.removeItem(at: pluginURL)
|
||||
}
|
||||
|
||||
/// Copy plugin to downloads folder
|
||||
/// - Throws: An error if the copy fails, because of missing permissions
|
||||
func pluginDownload() throws {
|
||||
guard let localPluginURL = Bundle.main.url(forResource: mailBundleName, withExtension: "mailbundle"),
|
||||
let downloadsFolder = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first else {
|
||||
throw PluginError.downloadFailed
|
||||
}
|
||||
|
||||
let downloadsPluginURL = downloadsFolder.appendingPathComponent(mailBundleName + ".mailbundle")
|
||||
|
||||
try self.copyFolder(from: localPluginURL, to: downloadsPluginURL)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum PluginError: Error {
|
||||
case installationFailed
|
||||
case downloadFailed
|
||||
case permissionNotGranted
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Foundation
|
||||
|
||||
struct MicrobitController {
|
||||
|
||||
/// Find all microbits connected to this mac
|
||||
/// - Throws: If a volume is inaccessible
|
||||
/// - Returns: an array of urls
|
||||
static func findMicrobits() throws -> [URL] {
|
||||
let fm = FileManager.default
|
||||
let volumes = try fm.contentsOfDirectory(atPath: "/Volumes")
|
||||
|
||||
let microbits: [URL] = volumes.filter({$0.lowercased().contains("microbit")}).map({URL(fileURLWithPath: "/Volumes").appendingPathComponent($0)})
|
||||
|
||||
return microbits
|
||||
}
|
||||
|
||||
/// Deploy the firmware to a USB connected microbit at the given URL
|
||||
/// - Parameters:
|
||||
/// - microbitURL: URL to the microbit
|
||||
/// - firmwareFile: Firmware file as binary data
|
||||
/// - Throws: An error if the write fails
|
||||
static func deployToMicrobit(_ microbitURL: URL, firmwareFile: Data) throws {
|
||||
let firmwareURL = microbitURL.appendingPathComponent("firware.bin")
|
||||
try firmwareFile.write(to: firmwareURL, options: .atomicWrite)
|
||||
}
|
||||
|
||||
/// Patch the given firmware.
|
||||
/// This will replace the pattern data (the place for the key) with the actual key
|
||||
/// - Parameters:
|
||||
/// - firmware: The firmware data that should be patched
|
||||
/// - pattern: The pattern that should be replaced
|
||||
/// - key: The key that should be added
|
||||
/// - returns: The patched firmware file
|
||||
static func patchFirmware(_ firmware: Data, pattern: Data, with key: Data) throws -> Data {
|
||||
guard pattern.count == key.count else {
|
||||
throw PatchingError.inequalLength
|
||||
}
|
||||
|
||||
var patchedFirmware = Data(firmware)
|
||||
var patchingSuccessful = false
|
||||
// Find the position of the pattern
|
||||
for bytePosition in firmware.startIndex...firmware.endIndex {
|
||||
// Use a sliding window to look for the pattern
|
||||
|
||||
// Check if the firmware is long enough
|
||||
guard bytePosition.advanced(by: pattern.count) <= firmware.endIndex else { break }
|
||||
|
||||
let range = bytePosition..<bytePosition.advanced(by: pattern.count)
|
||||
let potentialPattern = firmware[range]
|
||||
assert(potentialPattern.count == pattern.count)
|
||||
if Array(potentialPattern) == Array(pattern) {
|
||||
// Found pattern. Replace in binary
|
||||
patchedFirmware.replaceSubrange(range, with: key)
|
||||
patchingSuccessful = true
|
||||
}
|
||||
}
|
||||
|
||||
guard patchingSuccessful else {
|
||||
throw PatchingError.patternNotFound
|
||||
}
|
||||
|
||||
return patchedFirmware
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum PatchingError: Error {
|
||||
case inequalLength
|
||||
case patternNotFound
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Security
|
||||
import SwiftUI
|
||||
import CoreLocation
|
||||
|
||||
class Accessory: ObservableObject, Codable, Identifiable, Equatable {
|
||||
let name: String
|
||||
let id: Int
|
||||
let privateKey: Data
|
||||
let color: Color
|
||||
let icon: String
|
||||
|
||||
@Published var lastLocation: CLLocation?
|
||||
@Published var locationTimestamp: Date?
|
||||
|
||||
init(name: String, color: Color = Color.white, iconName: String = "briefcase.fill") throws {
|
||||
self.name = name
|
||||
guard let key = BoringSSL.generateNewPrivateKey() else {
|
||||
throw KeyError.keyGenerationFailed
|
||||
}
|
||||
self.id = key.hashValue
|
||||
self.privateKey = key
|
||||
self.color = color
|
||||
self.icon = iconName
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.name = try container.decode(String.self, forKey: .name)
|
||||
self.id = try container.decode(Int.self, forKey: .id)
|
||||
self.privateKey = try container.decode(Data.self, forKey: .privateKey)
|
||||
self.icon = (try? container.decode(String.self, forKey: .icon)) ?? "briefcase.fill"
|
||||
|
||||
if var colorComponents = try? container.decode([CGFloat].self, forKey: .colorComponents),
|
||||
let spaceName = try? container.decode(String.self, forKey: .colorSpaceName),
|
||||
let cgColor = CGColor(colorSpace: CGColorSpace(name: spaceName as CFString)!, components: &colorComponents) {
|
||||
self.color = Color(cgColor)
|
||||
} else {
|
||||
self.color = Color.white
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self.name, forKey: .name)
|
||||
try container.encode(self.id, forKey: .id)
|
||||
try container.encode(self.privateKey, forKey: .privateKey)
|
||||
try container.encode(self.icon, forKey: .icon)
|
||||
|
||||
if let colorComponents = self.color.cgColor?.components,
|
||||
let colorSpace = self.color.cgColor?.colorSpace?.name {
|
||||
try container.encode(colorComponents, forKey: .colorComponents)
|
||||
try container.encode(colorSpace as String, forKey: .colorSpaceName)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// The public key in the format used for Offline finding. It is 28 bytes long and can be transferred to a microbit
|
||||
func getActualPublicKey() throws -> Data {
|
||||
guard let publicKey = BoringSSL.derivePublicKey(fromPrivateKey: self.privateKey) else {
|
||||
throw KeyError.keyDerivationFailed
|
||||
}
|
||||
return publicKey
|
||||
}
|
||||
|
||||
func getAdvertisementKey() throws -> Data {
|
||||
guard var publicKey = BoringSSL.derivePublicKey(fromPrivateKey: self.privateKey) else {
|
||||
throw KeyError.keyDerivationFailed
|
||||
}
|
||||
// Drop the first byte to just have the 28 bytes version
|
||||
publicKey = publicKey.dropFirst()
|
||||
assert(publicKey.count == 28)
|
||||
guard publicKey.count == 28 else {throw KeyError.keyDerivationFailed}
|
||||
|
||||
return publicKey
|
||||
}
|
||||
|
||||
/// Offline finding uses an id for each key to identify a device / location report.
|
||||
/// The key is a SHA256 hash of the public key bytes formatted as Base64
|
||||
/// - Throws: An error if the key derivation or hashing fails
|
||||
/// - Returns: A base64 id of the current key
|
||||
func getKeyId() throws -> String {
|
||||
try self.hashedPublicKey().base64EncodedString()
|
||||
}
|
||||
|
||||
private func hashedPublicKey() throws -> Data {
|
||||
let publicKey = try self.getAdvertisementKey()
|
||||
var sha = SHA256()
|
||||
sha.update(data: publicKey)
|
||||
let digest = sha.finalize()
|
||||
|
||||
return Data(digest)
|
||||
}
|
||||
|
||||
func toFindMyDevice() throws -> FindMyDevice {
|
||||
|
||||
let findMyKey = FindMyKey(advertisedKey: try self.getAdvertisementKey(),
|
||||
hashedKey: try self.hashedPublicKey(),
|
||||
privateKey: self.privateKey,
|
||||
startTime: nil,
|
||||
duration: nil,
|
||||
pu: nil,
|
||||
yCoordinate: nil,
|
||||
fullKey: nil)
|
||||
|
||||
return FindMyDevice(deviceId: String(self.id),
|
||||
keys: [findMyKey],
|
||||
catalinaBigSurKeyFiles: nil,
|
||||
reports: nil,
|
||||
decryptedReports: nil)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case id
|
||||
case privateKey
|
||||
case colorComponents
|
||||
case colorSpaceName
|
||||
case icon
|
||||
}
|
||||
|
||||
static func == (lhs: Accessory, rhs: Accessory) -> Bool {
|
||||
return lhs.id == rhs.id && lhs.name == rhs.name && lhs.privateKey == rhs.privateKey && lhs.icon == rhs.icon
|
||||
}
|
||||
}
|
||||
|
||||
enum KeyError: Error {
|
||||
case keyGenerationFailed
|
||||
case keyDerivationFailed
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// swiftlint:disable force_try
|
||||
struct PreviewData {
|
||||
static let accessories: [Accessory] = {
|
||||
return accessoryList()
|
||||
}()
|
||||
|
||||
static func accessoryList() -> [Accessory] {
|
||||
|
||||
let latitude: Double = 52.5219814
|
||||
let longitude: Double = 13.413306
|
||||
|
||||
let backpack = try! Accessory(name: "Backpack", color: Color.green, iconName: "briefcase.fill")
|
||||
backpack.lastLocation = CLLocation(latitude: latitude + (Double(arc4random() % 1000))/100000, longitude: longitude + (Double(arc4random() % 1000))/100000)
|
||||
|
||||
let bag = try! Accessory(name: "Bag", color: Color.blue, iconName: "latch.2.case.fill")
|
||||
bag.lastLocation = CLLocation(latitude: latitude + (Double(arc4random() % 1000))/100000, longitude: longitude + (Double(arc4random() % 1000))/100000)
|
||||
|
||||
let car = try! Accessory(name: "Car", color: Color.red, iconName: "car.fill")
|
||||
car.lastLocation = CLLocation(latitude: latitude + (Double(arc4random() % 1000))/100000, longitude: longitude + (Double(arc4random() % 1000))/100000)
|
||||
|
||||
let keys = try! Accessory(name: "Keys", color: Color.orange, iconName: "key.fill")
|
||||
keys.lastLocation = CLLocation(latitude: latitude + (Double(arc4random() % 1000))/100000, longitude: longitude + (Double(arc4random() % 1000))/100000)
|
||||
|
||||
let items = try! Accessory(name: "Items", color: Color.gray, iconName: "mappin")
|
||||
items.lastLocation = CLLocation(latitude: latitude + (Double(arc4random() % 1000))/100000, longitude: longitude + (Double(arc4random() % 1000))/100000)
|
||||
|
||||
return [backpack, bag, car, keys, items]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
|
||||
struct AccessoryListEntry: View {
|
||||
var accessory: Accessory
|
||||
@Binding var alertType: OpenHaystackMainView.AlertType?
|
||||
var delete: (Accessory) -> Void
|
||||
var deployAccessoryToMicrobit: (Accessory) -> Void
|
||||
var zoomOn: (Accessory) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: {
|
||||
self.zoomOn(self.accessory)
|
||||
}, label: {
|
||||
HStack {
|
||||
Text(accessory.name)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
HStack(alignment: .center) {
|
||||
|
||||
Button(action: {self.zoomOn(self.accessory)}, label: {
|
||||
Circle()
|
||||
.strokeBorder(accessory.color, lineWidth: 2.0)
|
||||
.background(
|
||||
ZStack {
|
||||
Circle().fill(Color("PinColor"))
|
||||
Image(systemName: accessory.icon)
|
||||
.padding(3)
|
||||
}
|
||||
)
|
||||
|
||||
.frame(width: 30, height: 30)
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
Button(action: {
|
||||
self.deployAccessoryToMicrobit(accessory)
|
||||
}, label: {
|
||||
Text("Deploy")
|
||||
})
|
||||
|
||||
}
|
||||
.padding(.trailing)
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.contextMenu {
|
||||
Button("Delete", action: {self.delete(accessory)})
|
||||
Divider()
|
||||
Button("Copy advertisment key (Base64)", action: {self.copyPublicKey(of: accessory)})
|
||||
Button("Copy key id (Base64)", action: {self.copyPublicKeyHash(of: accessory)})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func copyPublicKey(of accessory: Accessory) {
|
||||
do {
|
||||
let publicKey = try accessory.getAdvertisementKey()
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.prepareForNewContents(with: .currentHostOnly)
|
||||
pasteboard.setString(publicKey.base64EncodedString(), forType: .string)
|
||||
} catch {
|
||||
os_log("Failed extracing public key %@", String(describing: error))
|
||||
assert(false)
|
||||
}
|
||||
}
|
||||
|
||||
func copyPublicKeyHash(of accessory: Accessory) {
|
||||
do {
|
||||
let keyID = try accessory.getKeyId()
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.prepareForNewContents(with: .currentHostOnly)
|
||||
pasteboard.setString(keyID, forType: .string)
|
||||
} catch {
|
||||
os_log("Failed extracing public key %@", String(describing: error))
|
||||
assert(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// struct AccessoryListEntry_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// AccessoryListEntry()
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,137 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Foundation
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
|
||||
class AccessoryAnnotationView: MKAnnotationView {
|
||||
|
||||
var pinView: NSHostingView<AccessoryPinView>?
|
||||
|
||||
var myAnnotation: MKAnnotation? {
|
||||
didSet {
|
||||
self.updateView()
|
||||
}
|
||||
}
|
||||
|
||||
override var annotation: MKAnnotation? {
|
||||
get {
|
||||
self.myAnnotation
|
||||
}
|
||||
set(a) {
|
||||
self.myAnnotation = a
|
||||
}
|
||||
}
|
||||
|
||||
override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
|
||||
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
frame = CGRect(x: 0, y: 0, width: 30, height: 30)
|
||||
self.image = nil
|
||||
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func updateView() {
|
||||
guard let accessory = (self.annotation as? AccessoryAnnotation)?.accessory else {return}
|
||||
self.pinView?.removeFromSuperview()
|
||||
self.pinView = NSHostingView(rootView: AccessoryPinView(accessory: accessory))
|
||||
|
||||
self.addSubview(pinView!)
|
||||
|
||||
self.leftCalloutOffset = CGPoint(x: -13, y: -15)
|
||||
self.rightCalloutOffset = CGPoint(x: -13, y: -15)
|
||||
|
||||
let calloutView = NSTextView()
|
||||
calloutView.string = accessory.name
|
||||
calloutView.frame = NSRect(x: 0, y: 0, width: 150, height: 30)
|
||||
|
||||
if let date = accessory.locationTimestamp {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .short
|
||||
dateFormatter.timeStyle = .short
|
||||
|
||||
let dateString = dateFormatter.string(from: date)
|
||||
|
||||
calloutView.string = "\(accessory.name)\n\(dateString)"
|
||||
calloutView.frame = NSRect(x: 0, y: 0, width: 150, height: 40)
|
||||
}
|
||||
|
||||
calloutView.sizeToFit()
|
||||
calloutView.backgroundColor = NSColor.clear
|
||||
self.detailCalloutAccessoryView = calloutView
|
||||
self.canShowCallout = true
|
||||
}
|
||||
|
||||
// override func draw(_ dirtyRect: NSRect) {
|
||||
// guard let accessoryAnnotation = self.annotation as? AccessoryAnnotation else {
|
||||
// super.draw(dirtyRect)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// let path = NSBezierPath(ovalIn: dirtyRect)
|
||||
// path.lineWidth = 2.0
|
||||
//
|
||||
// guard let cgColor = accessoryAnnotation.accessory.color.cgColor,
|
||||
// let strokeColor = NSColor(cgColor: cgColor)?.withAlphaComponent(0.8) else {return}
|
||||
//
|
||||
// NSColor(named: NSColor.Name("PinColor"))?.setFill()
|
||||
//
|
||||
// path.fill()
|
||||
//
|
||||
// strokeColor.setStroke()
|
||||
// path.stroke()
|
||||
//
|
||||
// let accessory = accessoryAnnotation.accessory
|
||||
//
|
||||
// guard let image = NSImage(systemSymbolName: accessory.icon, accessibilityDescription: accessory.name) else {return}
|
||||
//
|
||||
// let ratio = image.size.width / image.size.height
|
||||
// let imageWidth: CGFloat = 20
|
||||
// let imageHeight = imageWidth / ratio
|
||||
// let imageRect = NSRect(
|
||||
// x: dirtyRect.width/2 - imageWidth/2,
|
||||
// y: dirtyRect.height/2 - imageHeight/2,
|
||||
// width: imageWidth, height: imageHeight)
|
||||
//
|
||||
// image.draw(in: imageRect)
|
||||
// }
|
||||
|
||||
struct AccessoryPinView: View {
|
||||
var accessory: Accessory
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.strokeBorder(accessory.color, lineWidth: 2.0)
|
||||
.background(
|
||||
ZStack {
|
||||
Circle().fill(Color("PinColor"))
|
||||
Image(systemName: accessory.icon)
|
||||
.padding(3)
|
||||
}
|
||||
)
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class AccessoryAnnotation: NSObject, MKAnnotation {
|
||||
let accessory: Accessory
|
||||
|
||||
var coordinate: CLLocationCoordinate2D {
|
||||
return accessory.lastLocation!.coordinate
|
||||
}
|
||||
|
||||
init(accessory: Accessory) {
|
||||
self.accessory = accessory
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// AccessoryMapView.swift
|
||||
// OpenHaystack
|
||||
//
|
||||
// Created by Alex - SEEMOO on 02.03.21.
|
||||
// Copyright © 2021 SEEMOO - TU Darmstadt. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
|
||||
struct AccessoryMapView: NSViewControllerRepresentable {
|
||||
@ObservedObject var accessoryController: AccessoryController
|
||||
@Binding var mapType: MKMapType
|
||||
var focusedAccessory: Accessory?
|
||||
|
||||
func makeNSViewController(context: Context) -> MapViewController {
|
||||
return MapViewController(nibName: NSNib.Name("MapViewController"), bundle: nil)
|
||||
}
|
||||
|
||||
func updateNSViewController(_ nsViewController: MapViewController, context: Context) {
|
||||
let accessories = self.accessoryController.accessories
|
||||
|
||||
nsViewController.zoom(on: focusedAccessory)
|
||||
nsViewController.addLastLocations(from: accessories)
|
||||
|
||||
nsViewController.changeMapType(mapType)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
final class ActivityIndicator: NSViewRepresentable {
|
||||
|
||||
init(size: NSControl.ControlSize) {
|
||||
self.size = size
|
||||
}
|
||||
|
||||
let size: NSControl.ControlSize
|
||||
|
||||
typealias NSViewType = NSProgressIndicator
|
||||
|
||||
func makeNSView(context: Context) -> NSProgressIndicator {
|
||||
let indicator = NSProgressIndicator()
|
||||
indicator.style = .spinning
|
||||
indicator.controlSize = self.size
|
||||
indicator.startAnimation(nil)
|
||||
return indicator
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSProgressIndicator, context: Context) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct IconSelectionView: View {
|
||||
|
||||
@State var showImagePicker = false
|
||||
@State var color: Color = .red
|
||||
@Binding var selectedImageName: String
|
||||
|
||||
var body: some View {
|
||||
|
||||
ZStack {
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
self.showImagePicker.toggle()
|
||||
}
|
||||
}, label: {
|
||||
Circle()
|
||||
.strokeBorder(Color.gray, lineWidth: 0.5)
|
||||
.background(
|
||||
Image(systemName: self.selectedImageName)
|
||||
)
|
||||
.frame(width: 30, height: 30)
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.popover(isPresented: self.$showImagePicker, content: {
|
||||
ImageSelectionList(selectedImageName: self.$selectedImageName) {
|
||||
self.showImagePicker = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorSelectionView_Previews: PreviewProvider {
|
||||
@State static var selectedImageName: String = "briefcase.fill"
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
IconSelectionView(selectedImageName: self.$selectedImageName)
|
||||
ImageSelectionList(selectedImageName: self.$selectedImageName, dismiss: {})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageSelectionList: View {
|
||||
let selectableIcons = ["briefcase.fill", "case.fill", "latch.2.case.fill", "key.fill", "mappin", "crown.fill", "gift.fill", "car.fill"]
|
||||
|
||||
@Binding var selectedImageName: String
|
||||
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
List(self.selectableIcons, id: \.self) { iconName in
|
||||
Button(action: {
|
||||
self.selectedImageName = iconName
|
||||
self.dismiss()
|
||||
}, label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Image(systemName: iconName)
|
||||
Spacer()
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.frame(width: 100)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
import MapKit
|
||||
|
||||
struct OpenHaystackMainView: View {
|
||||
|
||||
@State var keyName: String = ""
|
||||
@State var accessoryColor: Color = Color.white
|
||||
@State var selectedIcon: String = "briefcase.fill"
|
||||
|
||||
@State var loading = false
|
||||
@ObservedObject var accessoryController = AccessoryController.shared
|
||||
var accessories: [Accessory] {
|
||||
return self.accessoryController.accessories
|
||||
}
|
||||
|
||||
@State var showKeyError = false
|
||||
@State var alertType: AlertType?
|
||||
@State var popUpAlertType: PopUpAlertType?
|
||||
@State var errorDescription: String?
|
||||
@State var searchPartyToken: String = ""
|
||||
@State var searchPartyTokenLoaded = false
|
||||
@State var mapType: MKMapType = .standard
|
||||
@State var isLoading = false
|
||||
@State var focusedAccessory: Accessory?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
VStack {
|
||||
HStack {
|
||||
self.accessoryView
|
||||
.frame(width: geo.size.width * 0.5)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack {
|
||||
self.mapView
|
||||
}.frame(width: geo.size.width * 0.5, alignment: .trailing)
|
||||
|
||||
}
|
||||
|
||||
if searchPartyTokenLoaded == false {
|
||||
TextField("Search Party token", text: self.$searchPartyToken)
|
||||
}
|
||||
}
|
||||
|
||||
if self.popUpAlertType != nil {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
PopUpAlertView(alertType: self.popUpAlertType!)
|
||||
.transition(AnyTransition.move(edge: .bottom))
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
.alert(item: self.$alertType, content: { alertType in
|
||||
return self.alert(for: alertType)
|
||||
})
|
||||
.onChange(of: self.searchPartyToken) { (searchPartyToken) in
|
||||
guard !searchPartyToken.isEmpty, self.accessories.isEmpty == false else {return}
|
||||
self.downloadLocationReports()
|
||||
}
|
||||
.onChange(of: self.popUpAlertType, perform: { popUpAlert in
|
||||
guard popUpAlert != nil else {return}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
self.popUpAlertType = nil
|
||||
}
|
||||
})
|
||||
.onAppear {
|
||||
self.onAppear()
|
||||
}
|
||||
}
|
||||
.padding([.leading, .trailing, .bottom])
|
||||
.frame(minWidth: 720, maxWidth: .infinity, minHeight: 480, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: Subviews
|
||||
|
||||
/// Left side of the view. Shows a list of accessories and the possibility to add accessories
|
||||
var accessoryView: some View {
|
||||
VStack {
|
||||
Text("Create a new tracking accessory")
|
||||
.font(.title2)
|
||||
.padding(.top)
|
||||
|
||||
Text("A BBC Microbit can be used to track anything you care about. Connect it over USB, name the accessory (e.g. Backpack) generate the key and deploy it")
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
|
||||
HStack {
|
||||
TextField("Name", text: self.$keyName)
|
||||
ColorPicker("", selection: self.$accessoryColor)
|
||||
.frame(maxWidth: 50, maxHeight: 20)
|
||||
IconSelectionView(selectedImageName: self.$selectedIcon)
|
||||
}
|
||||
|
||||
Button(action: self.addAccessory, label: {
|
||||
Text("Generate key and deploy")
|
||||
})
|
||||
.disabled(self.keyName.isEmpty)
|
||||
.padding(.bottom)
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Your accessories")
|
||||
.font(.title2)
|
||||
.padding(.top)
|
||||
|
||||
if self.accessories.isEmpty {
|
||||
Spacer()
|
||||
Text("No accessories have been added yet. Go ahead and add one above")
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
self.accessoryList
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/// Accessory List view
|
||||
var accessoryList: some View {
|
||||
List(self.accessories) { accessory in
|
||||
AccessoryListEntry(accessory: accessory,
|
||||
alertType: self.$alertType,
|
||||
delete: self.delete(accessory:),
|
||||
deployAccessoryToMicrobit: self.deployAccessoryToMicrobit(accessory:),
|
||||
zoomOn: {self.focusedAccessory = $0})
|
||||
}
|
||||
.background(Color.clear)
|
||||
.cornerRadius(15.0)
|
||||
}
|
||||
|
||||
/// Overlay for the map that is gray and shows an activity indicator when loading
|
||||
var mapOverlay: some View {
|
||||
ZStack {
|
||||
if self.isLoading {
|
||||
Rectangle()
|
||||
.fill(Color.gray)
|
||||
.opacity(0.5)
|
||||
|
||||
ActivityIndicator(size: .large)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Right side of the view showing a map with all items presented.
|
||||
var mapView: some View {
|
||||
ZStack {
|
||||
|
||||
AccessoryMapView(accessoryController: self.accessoryController, mapType: self.$mapType, focusedAccessory: self.focusedAccessory)
|
||||
.overlay(self.mapOverlay)
|
||||
.cornerRadius(15.0)
|
||||
.clipped()
|
||||
.padding([.top, .bottom], 15)
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
|
||||
Picker("", selection: self.$mapType) {
|
||||
Text("Satellite").tag(MKMapType.hybrid)
|
||||
Text("Standard").tag(MKMapType.standard)
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
.frame(width: 150, alignment: .center)
|
||||
|
||||
Button(action: self.downloadLocationReports, label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Text("Reload")
|
||||
})
|
||||
.opacity(1.0)
|
||||
.disabled(self.accessories.isEmpty)
|
||||
}
|
||||
.padding(.bottom, 25)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an accessory with the provided details
|
||||
func addAccessory() {
|
||||
let keyName = self.keyName
|
||||
self.keyName = ""
|
||||
|
||||
do {
|
||||
let accessory = try Accessory(name: keyName, color: self.accessoryColor, iconName: self.selectedIcon)
|
||||
|
||||
let accessories = self.accessories + [accessory]
|
||||
|
||||
withAnimation {
|
||||
self.accessoryController.accessories = accessories
|
||||
}
|
||||
try self.accessoryController.save()
|
||||
|
||||
self.deployAccessoryToMicrobit(accessory: accessory)
|
||||
|
||||
} catch {
|
||||
self.errorDescription = String(describing: error)
|
||||
self.showKeyError = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Download the location reports for all current accessories. Shows an error if something fails, like plug-in is missing
|
||||
func downloadLocationReports() {
|
||||
|
||||
self.checkPluginIsRunning { (running) in
|
||||
guard running else {
|
||||
self.alertType = .activatePlugin
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.searchPartyToken.isEmpty,
|
||||
let tokenData = self.searchPartyToken.data(using: .utf8) else {
|
||||
self.alertType = .searchPartyToken
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation {
|
||||
self.isLoading = true
|
||||
}
|
||||
|
||||
let findMyDevices = self.accessories.compactMap({ acc -> FindMyDevice? in
|
||||
do {
|
||||
return try acc.toFindMyDevice()
|
||||
} catch {
|
||||
os_log("Failed getting id for key %@", String(describing: error))
|
||||
return nil
|
||||
}
|
||||
})
|
||||
|
||||
FindMyController.shared.devices = findMyDevices
|
||||
FindMyController.shared.fetchReports(with: tokenData) { error in
|
||||
|
||||
let reports = FindMyController.shared.devices.compactMap({$0.reports}).flatMap({$0})
|
||||
if reports.isEmpty {
|
||||
withAnimation {
|
||||
self.popUpAlertType = .noReportsFound
|
||||
}
|
||||
} else {
|
||||
self.accessoryController.updateWithDecryptedReports(devices: FindMyController.shared.devices)
|
||||
}
|
||||
|
||||
withAnimation {
|
||||
self.isLoading = false
|
||||
}
|
||||
|
||||
guard error != nil else {return}
|
||||
os_log("Error: %@", String(describing: error))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Delete an accessory from the list of accessories
|
||||
func delete(accessory: Accessory) {
|
||||
do {
|
||||
var accessories = self.accessories
|
||||
guard let idx = accessories.firstIndex(of: accessory) else {return}
|
||||
|
||||
accessories.remove(at: idx)
|
||||
|
||||
withAnimation {
|
||||
self.accessoryController.accessories = accessories
|
||||
}
|
||||
try self.accessoryController.save()
|
||||
|
||||
} catch {
|
||||
self.alertType = .deletionFailed
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Deploy the public key of the accessory to a BBC microbit
|
||||
func deployAccessoryToMicrobit(accessory: Accessory) {
|
||||
do {
|
||||
let microbits = try MicrobitController.findMicrobits()
|
||||
guard let microBitURL = microbits.first,
|
||||
let firmwareURL = Bundle.main.url(forResource: "firmware", withExtension: "bin") else {
|
||||
self.alertType = .deployFailed
|
||||
return
|
||||
}
|
||||
let firmware = try Data(contentsOf: firmwareURL)
|
||||
let pattern = "OFFLINEFINDINGPUBLICKEYHERE!".data(using: .ascii)!
|
||||
let publicKey = try accessory.getAdvertisementKey()
|
||||
let patchedFirmware = try MicrobitController.patchFirmware(firmware, pattern: pattern, with: publicKey)
|
||||
|
||||
try MicrobitController.deployToMicrobit(microBitURL, firmwareFile: patchedFirmware)
|
||||
|
||||
} catch {
|
||||
os_log("Error occurred %@", String(describing: error))
|
||||
self.alertType = .deployFailed
|
||||
return
|
||||
}
|
||||
|
||||
self.alertType = .deployedSuccessfully
|
||||
}
|
||||
|
||||
func onAppear() {
|
||||
|
||||
/// Checks if the search party token can be fetched without the Mail Plugin. If true the plugin is not needed for this environment. (e.g. when SIP is disabled)
|
||||
let reportsFetcher = ReportsFetcher()
|
||||
if let token = reportsFetcher.fetchSearchpartyToken(),
|
||||
let tokenString = String(data: token, encoding: .ascii) {
|
||||
self.searchPartyToken = tokenString
|
||||
return
|
||||
}
|
||||
|
||||
let pluginManager = MailPluginManager()
|
||||
|
||||
// Check if the plugin is installed
|
||||
if pluginManager.isMailPluginInstalled == false {
|
||||
// Install the mail plugin
|
||||
self.alertType = .activatePlugin
|
||||
} else {
|
||||
self.checkPluginIsRunning(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask to install and activate the mail plugin
|
||||
func installMailPlugin() {
|
||||
let pluginManager = MailPluginManager()
|
||||
guard pluginManager.isMailPluginInstalled == false else {
|
||||
|
||||
return
|
||||
}
|
||||
do {
|
||||
try pluginManager.installMailPlugin()
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.alertType = .pluginInstallFailed
|
||||
os_log(.error, "Could not install mail plugin\n %@", String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkPluginIsRunning(_ completion: ((Bool) -> Void)?) {
|
||||
// Check if Mail plugin is active
|
||||
AnisetteDataManager.shared.requestAnisetteData { (result) in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let accountData):
|
||||
|
||||
withAnimation {
|
||||
self.searchPartyToken = String(data: accountData.searchPartyToken, encoding: .ascii) ?? ""
|
||||
if self.searchPartyToken.isEmpty == false {
|
||||
self.searchPartyTokenLoaded = true
|
||||
}
|
||||
}
|
||||
completion?(true)
|
||||
case .failure(let error):
|
||||
if let error = error as? AnisetteDataError {
|
||||
switch error {
|
||||
case .pluginNotFound:
|
||||
self.alertType = .activatePlugin
|
||||
default:
|
||||
self.alertType = .activatePlugin
|
||||
}
|
||||
}
|
||||
completion?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadPlugin() {
|
||||
do {
|
||||
try MailPluginManager().pluginDownload()
|
||||
} catch {
|
||||
self.alertType = .pluginInstallFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Alerts
|
||||
|
||||
/// Create an alert for the given alert type
|
||||
/// - Parameter alertType: current alert type
|
||||
/// - Returns: A SwiftUI Alert
|
||||
func alert(for alertType: AlertType) -> Alert {
|
||||
switch alertType {
|
||||
case .keyError:
|
||||
return Alert(title: Text("Could not create accessory"), message: Text(String(describing: self.errorDescription)), dismissButton: Alert.Button.cancel())
|
||||
case .searchPartyToken:
|
||||
return Alert(title: Text("Add the search party token"),
|
||||
message: Text(
|
||||
"""
|
||||
Please paste the search party token below after copying itfrom the macOS Keychain.
|
||||
The item that contains the key can be found by searching for:
|
||||
com.apple.account.DeviceLocator.search-party-token
|
||||
"""
|
||||
),
|
||||
dismissButton: Alert.Button.okay())
|
||||
case .deployFailed:
|
||||
return Alert(title: Text("Could not deploy"),
|
||||
message: Text("Deploying to microbit failed. Please reconnect the device over USB"),
|
||||
dismissButton: Alert.Button.okay())
|
||||
case .deployedSuccessfully:
|
||||
return Alert(title: Text("Deploy successfull"),
|
||||
message: Text("This device will now be tracked by all iPhones and you can use this app to find its last reported location"),
|
||||
dismissButton: Alert.Button.okay())
|
||||
case .deletionFailed:
|
||||
return Alert(title: Text("Could not delete accessory"), dismissButton: Alert.Button.okay())
|
||||
|
||||
case .noReportsFound:
|
||||
return Alert(title: Text("No reports found"),
|
||||
message: Text("Your accessory might have not been found yet or it is not powered. Make sure it has enough power to be found by nearby iPhones"),
|
||||
dismissButton: Alert.Button.okay())
|
||||
case .activatePlugin:
|
||||
let message =
|
||||
"""
|
||||
To access your Apple ID for downloading location reports we need to use a plugin in Apple Mail.
|
||||
Please make sure Apple Mail is running.
|
||||
Open Mail -> Preferences -> General -> Manage Plug-Ins... -> Select Haystack
|
||||
|
||||
We do not access any of your e-mail data. This is just necessary, because Apple blocks access to certain iCloud tokens otherwise.
|
||||
"""
|
||||
|
||||
return Alert(title: Text("Install & Activate Mail Plugin"), message: Text(message),
|
||||
primaryButton: .default(Text("Okay"), action: {self.installMailPlugin()}),
|
||||
secondaryButton: .cancel())
|
||||
|
||||
case .pluginInstallFailed:
|
||||
return Alert(title: Text("Mail Plugin installation failed"),
|
||||
message: Text("To access the location reports of your devices an Apple Mail plugin is necessary" +
|
||||
"\nThe installtion of this plugin has failed.\n\n Please download it manually unzip it and move it to /Library/Mail/Bundles"),
|
||||
primaryButton: .default(Text("Download plug-in"), action: {
|
||||
self.downloadPlugin()
|
||||
}), secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
|
||||
enum AlertType: Int, Identifiable {
|
||||
var id: Int {
|
||||
return self.rawValue
|
||||
}
|
||||
|
||||
case keyError
|
||||
case searchPartyToken
|
||||
case deployFailed
|
||||
case deployedSuccessfully
|
||||
case deletionFailed
|
||||
case noReportsFound
|
||||
case activatePlugin
|
||||
case pluginInstallFailed
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct OpenHaystackMainView_Previews: PreviewProvider {
|
||||
|
||||
static var accessories: [Accessory] = PreviewData.accessories
|
||||
|
||||
static var previews: some View {
|
||||
OpenHaystackMainView(accessoryController: AccessoryController(accessories: accessories))
|
||||
.frame(width: 640, height: 480, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
extension Alert.Button {
|
||||
static func okay() -> Alert.Button {
|
||||
Alert.Button.default(Text("Okay"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||
//
|
||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||
// Copyright © 2021 The Open Wireless Link Project
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PopUpAlertView: View {
|
||||
|
||||
let alertType: PopUpAlertType
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
switch self.alertType {
|
||||
case .noReportsFound:
|
||||
VStack {
|
||||
Text("No reports found")
|
||||
.font(.title2)
|
||||
|
||||
Text("Your accessory might have not been found yet or it is not powered. Make sure it has enough power to be found by nearby iPhones")
|
||||
.font(.caption)
|
||||
}.padding()
|
||||
}
|
||||
|
||||
}
|
||||
.background(RoundedRectangle(cornerRadius: 7.5)
|
||||
.fill(Color.gray))
|
||||
}
|
||||
}
|
||||
|
||||
struct PopUpAlertView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PopUpAlertView(alertType: .noReportsFound)
|
||||
}
|
||||
}
|
||||
|
||||
enum PopUpAlertType: Int, Identifiable {
|
||||
var id: Int {
|
||||
return self.rawValue
|
||||
}
|
||||
|
||||
case noReportsFound
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user