mirror of
https://github.com/seemoo-lab/openhaystack.git
synced 2026-02-18 19:49:55 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a88f5abeb4 | ||
|
|
cf0416e174 | ||
|
|
eb07546640 | ||
|
|
37de037986 | ||
|
|
5117674ac9 | ||
|
|
d5546e1fa8 | ||
|
|
1b6eadb301 | ||
|
|
2f32efef24 | ||
|
|
e7a6135d95 | ||
|
|
9406f817f3 | ||
|
|
ab1c3eb83a | ||
|
|
b56aa1faa7 |
@@ -51,6 +51,10 @@
|
||||
78EC226C25DBC2E40042B775 /* OpenHaystackMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */; };
|
||||
78EC227225DBC8CE0042B775 /* Accessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC227125DBC8CE0042B775 /* Accessory.swift */; };
|
||||
78EC227725DBDB7E0042B775 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC227625DBDB7E0042B775 /* KeychainController.swift */; };
|
||||
F12D5A5A25FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12D5A5925FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift */; };
|
||||
F12D5A6025FA79FA00CBBA09 /* Advertisement.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12D5A5F25FA79FA00CBBA09 /* Advertisement.swift */; };
|
||||
F1647C1625FF6C61004144D6 /* BluetoothTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1647C1525FF6C61004144D6 /* BluetoothTests.swift */; };
|
||||
F1647C1B25FF7954004144D6 /* AccessoryNearbyMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1647C1A25FF7954004144D6 /* AccessoryNearbyMonitor.swift */; };
|
||||
F16BA9E925E7DB2D00238183 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F16BA9E825E7DB2D00238183 /* NIOSSL */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -150,6 +154,10 @@
|
||||
78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHaystackMainView.swift; sourceTree = "<group>"; };
|
||||
78EC227125DBC8CE0042B775 /* Accessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessory.swift; sourceTree = "<group>"; };
|
||||
78EC227625DBDB7E0042B775 /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = "<group>"; };
|
||||
F12D5A5925FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothAccessoryScanner.swift; sourceTree = "<group>"; };
|
||||
F12D5A5F25FA79FA00CBBA09 /* Advertisement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Advertisement.swift; sourceTree = "<group>"; };
|
||||
F1647C1525FF6C61004144D6 /* BluetoothTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothTests.swift; sourceTree = "<group>"; };
|
||||
F1647C1A25FF7954004144D6 /* AccessoryNearbyMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessoryNearbyMonitor.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -315,6 +323,7 @@
|
||||
78EC226325DAE0BE0042B775 /* OpenHaystackTests.swift */,
|
||||
78EC226525DAE0BE0042B775 /* Info.plist */,
|
||||
78023CB025F7841F00B083EF /* MicrocontrollerTests.swift */,
|
||||
F1647C1525FF6C61004144D6 /* BluetoothTests.swift */,
|
||||
);
|
||||
path = OpenHaystackTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -322,6 +331,7 @@
|
||||
78EC226E25DBC2FC0042B775 /* HaystackApp */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F12D5A5E25FA79D600CBBA09 /* Bluetooth */,
|
||||
78023CAC25F7775300B083EF /* Firmwares */,
|
||||
78286D3A25E4017400F65511 /* Mail Plugin */,
|
||||
78EC227025DBC8BB0042B775 /* Views */,
|
||||
@@ -331,6 +341,7 @@
|
||||
787D8AC025DECD3C00148766 /* AccessoryController.swift */,
|
||||
78023CAA25F7767000B083EF /* ESP32Controller.swift */,
|
||||
7821DAD025F7B2C10054DC33 /* FileManager.swift */,
|
||||
F1647C1A25FF7954004144D6 /* AccessoryNearbyMonitor.swift */,
|
||||
);
|
||||
path = HaystackApp;
|
||||
sourceTree = "<group>";
|
||||
@@ -360,6 +371,15 @@
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F12D5A5E25FA79D600CBBA09 /* Bluetooth */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F12D5A5925FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift */,
|
||||
F12D5A5F25FA79FA00CBBA09 /* Advertisement.swift */,
|
||||
);
|
||||
path = Bluetooth;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -575,6 +595,7 @@
|
||||
78D9B80625F7CF60009B9CE8 /* ManageAccessoriesView.swift in Sources */,
|
||||
78486BEF25DD711E0007ED87 /* PopUpAlertView.swift in Sources */,
|
||||
78014A2925DC08580089F6D9 /* MicrobitController.swift in Sources */,
|
||||
F1647C1B25FF7954004144D6 /* AccessoryNearbyMonitor.swift in Sources */,
|
||||
78286D1F25E3D8B800F65511 /* ALTAnisetteData.m in Sources */,
|
||||
781EB3EC25DAD7EA00FEAA19 /* DecryptReports.swift in Sources */,
|
||||
78EC226C25DBC2E40042B775 /* OpenHaystackMainView.swift in Sources */,
|
||||
@@ -587,10 +608,12 @@
|
||||
781EB3F125DAD7EA00FEAA19 /* FindMyKeyDecoder.swift in Sources */,
|
||||
787D8AC125DECD3C00148766 /* AccessoryController.swift in Sources */,
|
||||
78023CAB25F7767000B083EF /* ESP32Controller.swift in Sources */,
|
||||
F12D5A6025FA79FA00CBBA09 /* Advertisement.swift in Sources */,
|
||||
781EB3F225DAD7EA00FEAA19 /* OpenHaystackApp.swift in Sources */,
|
||||
781EB3F325DAD7EA00FEAA19 /* Models.swift in Sources */,
|
||||
781EB3F425DAD7EA00FEAA19 /* FindMyController.swift in Sources */,
|
||||
781EB3F525DAD7EA00FEAA19 /* BoringSSL.m in Sources */,
|
||||
F12D5A5A25FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift in Sources */,
|
||||
78286D5625E401F000F65511 /* MailPluginManager.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -609,6 +632,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78023CB125F7841F00B083EF /* MicrocontrollerTests.swift in Sources */,
|
||||
F1647C1625FF6C61004144D6 /* BluetoothTests.swift in Sources */,
|
||||
78EC226425DAE0BE0042B775 /* OpenHaystackTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
ReferencedContainer = "container:OpenHaystack.xcodeproj">
|
||||
</BuildableReference>
|
||||
<SkippedTests>
|
||||
<Test
|
||||
Identifier = "MicrocontrollerTests/testESP32Deploy()">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "MicrocontrollerTests/testFindESP32Port()">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "MicrocontrollerTests/testMicrobitDeploy()">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OpenHaystackTests/testPluginInstallation()">
|
||||
</Test>
|
||||
|
||||
@@ -15,11 +15,6 @@ import SwiftUI
|
||||
class FindMyController: ObservableObject {
|
||||
@Published var error: Error?
|
||||
@Published var devices = [FindMyDevice]()
|
||||
var accessories: AccessoryController
|
||||
|
||||
init(accessories: AccessoryController) {
|
||||
self.accessories = accessories
|
||||
}
|
||||
|
||||
func loadPrivateKeys(from data: Data, with searchPartyToken: Data, completion: @escaping (Error?) -> Void) {
|
||||
do {
|
||||
@@ -102,11 +97,6 @@ class FindMyController: ObservableObject {
|
||||
|
||||
self.fetchReports(with: token) { error in
|
||||
|
||||
let reports = self.devices.compactMap({ $0.reports }).flatMap({ $0 })
|
||||
if reports.isEmpty == false {
|
||||
self.accessories.updateWithDecryptedReports(devices: self.devices)
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
os_log("Error: %@", String(describing: error))
|
||||
|
||||
@@ -9,21 +9,24 @@
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
class AccessoryController: ObservableObject {
|
||||
@Published var accessories: [Accessory]
|
||||
var selfObserver: AnyCancellable?
|
||||
var listElementsObserver = [AnyCancellable]()
|
||||
let findMyController: FindMyController
|
||||
|
||||
init(accessories: [Accessory]) {
|
||||
init(accessories: [Accessory], findMyController: FindMyController) {
|
||||
self.accessories = accessories
|
||||
self.findMyController = findMyController
|
||||
initAccessoryObserver()
|
||||
initObserver()
|
||||
}
|
||||
|
||||
convenience init() {
|
||||
self.init(accessories: KeychainController.loadAccessoriesFromKeychain())
|
||||
self.init(accessories: KeychainController.loadAccessoriesFromKeychain(), findMyController: FindMyController())
|
||||
}
|
||||
|
||||
func initAccessoryObserver() {
|
||||
@@ -88,6 +91,106 @@ class AccessoryController: ObservableObject {
|
||||
}
|
||||
return accessory
|
||||
}
|
||||
|
||||
/// Export the accessories property list so it can be imported at another location
|
||||
func export(accessories: [Accessory]) throws -> URL {
|
||||
let propertyList = try PropertyListEncoder().encode(accessories)
|
||||
|
||||
let savePanel = NSSavePanel()
|
||||
savePanel.allowedFileTypes = ["plist"]
|
||||
savePanel.canCreateDirectories = true
|
||||
savePanel.directoryURL = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
|
||||
savePanel.message = "This export contains all private keys! Keep the file save to protect your location data"
|
||||
savePanel.nameFieldLabel = "Filename"
|
||||
savePanel.nameFieldStringValue = "openhaystack_accessories.plist"
|
||||
savePanel.prompt = "Export"
|
||||
savePanel.title = "Export accessories & keys"
|
||||
|
||||
let result = savePanel.runModal()
|
||||
|
||||
if result == .OK,
|
||||
let url = savePanel.url
|
||||
{
|
||||
// Store the accessory file
|
||||
try propertyList.write(to: url)
|
||||
|
||||
return url
|
||||
}
|
||||
throw ImportError.cancelled
|
||||
}
|
||||
|
||||
/// Let the user select a file to import the accessories exported by another OpenHaystack instance
|
||||
func importAccessories() throws {
|
||||
let openPanel = NSOpenPanel()
|
||||
openPanel.allowedFileTypes = ["plist"]
|
||||
openPanel.canCreateDirectories = true
|
||||
openPanel.directoryURL = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
|
||||
openPanel.message = "Import an accessories file that includes the private keys"
|
||||
openPanel.prompt = "Import"
|
||||
openPanel.title = "Import accessories & keys"
|
||||
|
||||
let result = openPanel.runModal()
|
||||
if result == .OK,
|
||||
let url = openPanel.url
|
||||
{
|
||||
let propertyList = try Data(contentsOf: url)
|
||||
var importedAccessories = try PropertyListDecoder().decode([Accessory].self, from: propertyList)
|
||||
|
||||
var updatedAccessories = self.accessories
|
||||
// Filter out accessories with the same id (no duplicates)
|
||||
importedAccessories = importedAccessories.filter({ acc in !self.accessories.contains(where: { acc.id == $0.id }) })
|
||||
updatedAccessories.append(contentsOf: importedAccessories)
|
||||
updatedAccessories.sort(by: { $0.name < $1.name })
|
||||
|
||||
self.accessories = updatedAccessories
|
||||
|
||||
//Update reports automatically. Do not report errors from here
|
||||
self.downloadLocationReports { result in }
|
||||
}
|
||||
}
|
||||
|
||||
enum ImportError: Error {
|
||||
case cancelled
|
||||
}
|
||||
|
||||
//MARK: Location reports
|
||||
|
||||
/// Download the location reports from
|
||||
/// - Parameter completion: called when the reports have been succesfully downloaded or the request has failed
|
||||
func downloadLocationReports(completion: @escaping (Result<Void, OpenHaystackMainView.AlertType>) -> Void) {
|
||||
AnisetteDataManager.shared.requestAnisetteData { result in
|
||||
switch result {
|
||||
case .failure(_):
|
||||
completion(.failure(.activatePlugin))
|
||||
case .success(let accountData):
|
||||
|
||||
guard let token = accountData.searchPartyToken,
|
||||
token.isEmpty == false
|
||||
else {
|
||||
completion(.failure(.searchPartyToken))
|
||||
return
|
||||
}
|
||||
|
||||
self.findMyController.fetchReports(for: self.accessories, with: token) { result in
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
os_log(.error, "Downloading reports failed %@", error.localizedDescription)
|
||||
completion(.failure(.downloadingReportsFailed))
|
||||
case .success(let devices):
|
||||
let reports = devices.compactMap({ $0.reports }).flatMap({ $0 })
|
||||
if reports.isEmpty {
|
||||
completion(.failure(.noReportsFound))
|
||||
} else {
|
||||
self.updateWithDecryptedReports(devices: devices)
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class AccessoryControllerPreview: AccessoryController {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// 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 AccessoryNearbyMonitor: BluetoothAccessoryDelegate {
|
||||
|
||||
var accessoryController: AccessoryController
|
||||
var scanner: BluetoothAccessoryScanner
|
||||
|
||||
var cleanup: Timer?
|
||||
|
||||
init(accessoryController: AccessoryController) {
|
||||
self.accessoryController = accessoryController
|
||||
self.scanner = BluetoothAccessoryScanner()
|
||||
self.initScanner()
|
||||
self.initTimer()
|
||||
}
|
||||
|
||||
func initScanner() {
|
||||
self.scanner.delegate = self
|
||||
}
|
||||
|
||||
func initTimer() {
|
||||
self.cleanup = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
self.removeNearbyAccessories()
|
||||
}
|
||||
}
|
||||
|
||||
func received(_ advertisement: Advertisement) {
|
||||
guard let accessory = getAccessoryForAdvertisement(advertisement) else {
|
||||
return
|
||||
}
|
||||
updateNearbyAccessory(accessory)
|
||||
}
|
||||
|
||||
func updateNearbyAccessory(_ accessory: Accessory) {
|
||||
if !accessory.isNearby {
|
||||
// Only set on state change
|
||||
accessory.isNearby = true
|
||||
}
|
||||
accessory.lastAdvertisement = Date()
|
||||
}
|
||||
|
||||
func removeNearbyAccessories(now: Date = Date(), timeout: TimeInterval = 10.0) {
|
||||
let nearbyAccessories = self.accessoryController.accessories.filter({ $0.isNearby })
|
||||
for accessory in nearbyAccessories {
|
||||
guard let lastAdvertisement = accessory.lastAdvertisement else {
|
||||
continue
|
||||
}
|
||||
if lastAdvertisement + timeout < now {
|
||||
accessory.isNearby = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getAccessoryForAdvertisement(_ advertisement: Advertisement) -> Accessory? {
|
||||
let accessory =
|
||||
self.accessoryController.accessories.first {
|
||||
isAdvertisement(advertisement, from: $0)
|
||||
} ?? nil
|
||||
return accessory
|
||||
}
|
||||
|
||||
func isAdvertisement(_ advertisement: Advertisement, from: Accessory) -> Bool {
|
||||
do {
|
||||
let accessoryPublicKey = try from.getAdvertisementKey().advanced(by: 6)
|
||||
return accessoryPublicKey == advertisement.publicKeyPayload
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// 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 CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
struct Advertisement {
|
||||
|
||||
let publicKeyPayload: Data
|
||||
|
||||
init?(fromAdvertisementData: [String: Any]) {
|
||||
guard let manufacturerData = fromAdvertisementData[CBAdvertisementDataManufacturerDataKey] as? Data else {
|
||||
return nil
|
||||
}
|
||||
self.init(fromManufacturerData: manufacturerData)
|
||||
}
|
||||
|
||||
init?(fromManufacturerData: Data) {
|
||||
guard let publicKey = Advertisement.extractPublicKeyFromPayload(fromManufacturerData) else {
|
||||
return nil
|
||||
}
|
||||
self.publicKeyPayload = publicKey
|
||||
}
|
||||
|
||||
static let publicKeyPayloadLength = 22
|
||||
|
||||
static func extractPublicKeyFromPayload(_ payload: Data) -> Data? {
|
||||
guard payload.count == 29 else {
|
||||
return nil
|
||||
}
|
||||
// Apple company ID
|
||||
guard payload.subdata(in: 0..<2) == Data([0x4c, 0x00]) else {
|
||||
return nil
|
||||
}
|
||||
// Offline finding sub type
|
||||
guard payload.subdata(in: 2..<3) == Data([0x12]) else {
|
||||
return nil
|
||||
}
|
||||
// Offline finding sub type length
|
||||
guard payload.subdata(in: 3..<4) == Data([0x19]) else {
|
||||
return nil
|
||||
}
|
||||
let publicKey = payload.subdata(in: 5..<5 + publicKeyPayloadLength)
|
||||
guard publicKey.count == publicKeyPayloadLength else {
|
||||
return nil
|
||||
}
|
||||
return publicKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// 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 CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
protocol BluetoothAccessoryDelegate {
|
||||
func received(_ advertisement: Advertisement)
|
||||
}
|
||||
|
||||
public class BluetoothAccessoryScanner: NSObject, CBCentralManagerDelegate {
|
||||
|
||||
var scanner: CBCentralManager!
|
||||
var delegate: BluetoothAccessoryDelegate?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
scanner = CBCentralManager(delegate: self, queue: DispatchQueue.main)
|
||||
}
|
||||
|
||||
public func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
startScanning(central)
|
||||
}
|
||||
|
||||
private func startScanning(_ central: CBCentralManager) {
|
||||
guard central.state == .poweredOn else {
|
||||
return
|
||||
}
|
||||
let scanOptions = [
|
||||
CBCentralManagerScanOptionAllowDuplicatesKey: false
|
||||
]
|
||||
scanner.scanForPeripherals(withServices: nil, options: scanOptions)
|
||||
}
|
||||
|
||||
public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
guard let adv = Advertisement(fromAdvertisementData: advertisementData) else {
|
||||
return
|
||||
}
|
||||
self.delegate?.received(adv)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,25 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
|
||||
@Published var icon: String
|
||||
@Published var lastLocation: CLLocation?
|
||||
@Published var locationTimestamp: Date?
|
||||
@Published var isDeployed: Bool
|
||||
@Published var isDeployed: Bool {
|
||||
didSet(wasDeployed) {
|
||||
// Reset active status if deployed
|
||||
if !wasDeployed && isDeployed {
|
||||
self.isActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the accessory is correctly advertising.
|
||||
@Published var isActive: Bool = false
|
||||
/// Whether this accessory is currently nearby.
|
||||
@Published var isNearby: Bool = false {
|
||||
didSet {
|
||||
if isNearby {
|
||||
self.isActive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var lastAdvertisement: Date?
|
||||
|
||||
init(name: String = "New accessory", color: Color = randomColor(), iconName: String = randomIcon()) throws {
|
||||
self.name = name
|
||||
@@ -56,6 +74,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
|
||||
self.privateKey = try container.decode(Data.self, forKey: .privateKey)
|
||||
self.icon = (try? container.decode(String.self, forKey: .icon)) ?? ""
|
||||
self.isDeployed = (try? container.decode(Bool.self, forKey: .isDeployed)) ?? false
|
||||
self.isActive = (try? container.decode(Bool.self, forKey: .isActive)) ?? false
|
||||
|
||||
if var colorComponents = try? container.decode([CGFloat].self, forKey: .colorComponents),
|
||||
let spaceName = try? container.decode(String.self, forKey: .colorSpaceName),
|
||||
@@ -75,6 +94,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
|
||||
try container.encode(self.privateKey, forKey: .privateKey)
|
||||
try container.encode(self.icon, forKey: .icon)
|
||||
try container.encode(self.isDeployed, forKey: .isDeployed)
|
||||
try container.encode(self.isActive, forKey: .isActive)
|
||||
|
||||
if let colorComponents = self.color.cgColor?.components,
|
||||
let colorSpace = self.color.cgColor?.colorSpace?.name
|
||||
@@ -154,6 +174,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
|
||||
case colorSpaceName
|
||||
case icon
|
||||
case isDeployed
|
||||
case isActive
|
||||
}
|
||||
|
||||
static func == (lhs: Accessory, rhs: Accessory) -> Bool {
|
||||
|
||||
@@ -35,6 +35,8 @@ struct PreviewData {
|
||||
accessory.lastLocation = randomLocation()
|
||||
accessory.locationTimestamp = randomTimestamp()
|
||||
accessory.isDeployed = true
|
||||
accessory.isActive = true
|
||||
accessory.isNearby = Bool.random()
|
||||
return accessory
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ struct AccessoryListEntry: View {
|
||||
label: { Text("Deploy") }
|
||||
)
|
||||
}
|
||||
Circle()
|
||||
.fill(accessory.isNearby ? Color.green : accessory.isActive ? Color.orange : Color.red)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.padding(EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0))
|
||||
.contextMenu {
|
||||
|
||||
@@ -22,6 +22,8 @@ struct ManageAccessoriesView: View {
|
||||
@Binding var accessoryToDeploy: Accessory?
|
||||
@Binding var showESP32DeploySheet: Bool
|
||||
|
||||
@State var showMailPopup = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Your accessories")
|
||||
@@ -38,10 +40,7 @@ struct ManageAccessoriesView: View {
|
||||
}
|
||||
}
|
||||
.toolbar(content: {
|
||||
Spacer()
|
||||
Button(action: self.addAccessory) {
|
||||
Label("Add accessory", systemImage: "plus")
|
||||
}
|
||||
self.toolbarView
|
||||
})
|
||||
.sheet(
|
||||
isPresented: self.$showESP32DeploySheet,
|
||||
@@ -76,6 +75,34 @@ struct ManageAccessoriesView: View {
|
||||
|
||||
}
|
||||
|
||||
/// All toolbar buttons shown
|
||||
var toolbarView: some View {
|
||||
Group {
|
||||
Spacer()
|
||||
|
||||
Button(
|
||||
action: self.importAccessories,
|
||||
label: {
|
||||
Label("Import accessories", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
)
|
||||
.help("Import accessories from a file")
|
||||
|
||||
Button(
|
||||
action: self.exportAccessories,
|
||||
label: {
|
||||
Label("Export accessories", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
)
|
||||
.help("Export all accessories to a file")
|
||||
|
||||
Button(action: self.addAccessory) {
|
||||
Label("Add accessory", systemImage: "plus")
|
||||
}
|
||||
.help("Add a new accessory")
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an accessory from the list of accessories.
|
||||
func delete(accessory: Accessory) {
|
||||
do {
|
||||
@@ -99,6 +126,29 @@ struct ManageAccessoriesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func exportAccessories() {
|
||||
do {
|
||||
_ = try self.accessoryController.export(accessories: self.accessories)
|
||||
} catch {
|
||||
self.alertType = .exportFailed
|
||||
}
|
||||
}
|
||||
|
||||
func importAccessories() {
|
||||
do {
|
||||
try self.accessoryController.importAccessories()
|
||||
} catch {
|
||||
if let importError = error as? AccessoryController.ImportError,
|
||||
importError == .cancelled
|
||||
{
|
||||
//User cancelled the import. No error
|
||||
return
|
||||
}
|
||||
|
||||
self.alertType = .importFailed
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ManageAccessoriesView_Previews: PreviewProvider {
|
||||
|
||||
@@ -15,12 +15,11 @@ struct OpenHaystackMainView: View {
|
||||
|
||||
@State var loading = false
|
||||
@EnvironmentObject var accessoryController: AccessoryController
|
||||
@EnvironmentObject var findMyController: FindMyController
|
||||
|
||||
var accessories: [Accessory] {
|
||||
return self.accessoryController.accessories
|
||||
}
|
||||
|
||||
@State var showKeyError = false
|
||||
@State var alertType: AlertType?
|
||||
@State var popUpAlertType: PopUpAlertType?
|
||||
@State var errorDescription: String?
|
||||
@@ -30,6 +29,9 @@ struct OpenHaystackMainView: View {
|
||||
@State var isLoading = false
|
||||
@State var focusedAccessory: Accessory?
|
||||
@State var accessoryToDeploy: Accessory?
|
||||
@State var showMailPlugInPopover = false
|
||||
|
||||
@State var mailPluginIsActive = false
|
||||
|
||||
@State var showESP32DeploySheet = false
|
||||
|
||||
@@ -43,7 +45,7 @@ struct OpenHaystackMainView: View {
|
||||
accessoryToDeploy: self.$accessoryToDeploy,
|
||||
showESP32DeploySheet: self.$showESP32DeploySheet
|
||||
)
|
||||
.frame(minWidth: 200, idealWidth: 200, maxWidth: .infinity, minHeight: 300, idealHeight: 400, maxHeight: .infinity, alignment: .center)
|
||||
.frame(minWidth: 250, idealWidth: 280, maxWidth: .infinity, minHeight: 300, idealHeight: 400, maxHeight: .infinity, alignment: .center)
|
||||
|
||||
ZStack {
|
||||
AccessoryMapView(accessoryController: self.accessoryController, mapType: self.$mapType, focusedAccessory: self.focusedAccessory)
|
||||
@@ -59,15 +61,7 @@ struct OpenHaystackMainView: View {
|
||||
}
|
||||
.frame(minWidth: 500, idealWidth: 500, maxWidth: .infinity, minHeight: 300, idealHeight: 400, maxHeight: .infinity, alignment: .center)
|
||||
.toolbar(content: {
|
||||
Picker("", selection: self.$mapType) {
|
||||
Text("Satellite").tag(MKMapType.hybrid)
|
||||
Text("Standard").tag(MKMapType.standard)
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
Button(action: self.downloadLocationReports) {
|
||||
Label("Reload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(self.accessories.isEmpty)
|
||||
self.toolbarView
|
||||
})
|
||||
.alert(
|
||||
item: self.$alertType,
|
||||
@@ -111,6 +105,45 @@ struct OpenHaystackMainView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// All toolbar items shown
|
||||
var toolbarView: some View {
|
||||
Group {
|
||||
|
||||
Picker("", selection: self.$mapType) {
|
||||
Text("Satellite").tag(MKMapType.hybrid)
|
||||
Text("Standard").tag(MKMapType.standard)
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
|
||||
Button(
|
||||
action: {
|
||||
if !self.mailPluginIsActive {
|
||||
self.showMailPlugInPopover.toggle()
|
||||
} else {
|
||||
self.downloadLocationReports()
|
||||
}
|
||||
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(self.mailPluginIsActive ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Label("Reload", systemImage: "arrow.clockwise")
|
||||
.disabled(!self.mailPluginIsActive)
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
.disabled(self.accessories.isEmpty)
|
||||
.popover(
|
||||
isPresented: $showMailPlugInPopover,
|
||||
content: {
|
||||
self.mailStatePopover
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -135,42 +168,37 @@ struct OpenHaystackMainView: View {
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
findMyController.fetchReports(for: accessories, with: tokenData) { result in
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
os_log(.error, "Downloading reports failed %@", error.localizedDescription)
|
||||
case .success(let devices):
|
||||
let reports = devices.compactMap({ $0.reports }).flatMap({ $0 })
|
||||
if reports.isEmpty {
|
||||
withAnimation {
|
||||
self.popUpAlertType = .noReportsFound
|
||||
}
|
||||
}
|
||||
}
|
||||
withAnimation {
|
||||
self.isLoading = false
|
||||
self.isLoading = true
|
||||
self.accessoryController.downloadLocationReports { result in
|
||||
self.isLoading = false
|
||||
switch result {
|
||||
case .failure(let alert):
|
||||
if alert == .noReportsFound {
|
||||
self.popUpAlertType = .noReportsFound
|
||||
} else {
|
||||
self.alertType = alert
|
||||
}
|
||||
case .success(_):
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mailStatePopover: some View {
|
||||
HStack {
|
||||
Image(systemName: "envelope")
|
||||
.foregroundColor(self.mailPluginIsActive ? .green : .red)
|
||||
|
||||
if self.mailPluginIsActive {
|
||||
Text("The mail plug-in is up and running")
|
||||
} else {
|
||||
Text("Cannot connect to the mail plug-in. Open Apple Mail and make sure the plug-in is enabled")
|
||||
.lineLimit(10)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 250)
|
||||
.padding()
|
||||
}
|
||||
|
||||
func deploy(accessory: Accessory) {
|
||||
@@ -189,7 +217,7 @@ struct OpenHaystackMainView: View {
|
||||
}
|
||||
|
||||
self.alertType = .deployedSuccessfully
|
||||
|
||||
accessory.isDeployed = true
|
||||
self.accessoryToDeploy = nil
|
||||
}
|
||||
|
||||
@@ -210,7 +238,7 @@ struct OpenHaystackMainView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func checkPluginIsRunning(_ completion: ((Bool) -> Void)?) {
|
||||
func checkPluginIsRunning(silent: Bool = false, _ completion: ((Bool) -> Void)?) {
|
||||
// Check if Mail plugin is active
|
||||
AnisetteDataManager.shared.requestAnisetteData { (result) in
|
||||
DispatchQueue.main.async {
|
||||
@@ -218,14 +246,18 @@ struct OpenHaystackMainView: View {
|
||||
case .success(let accountData):
|
||||
|
||||
withAnimation {
|
||||
self.searchPartyToken = String(data: accountData.searchPartyToken, encoding: .ascii) ?? ""
|
||||
if self.searchPartyToken.isEmpty == false {
|
||||
self.searchPartyTokenLoaded = true
|
||||
if let token = accountData.searchPartyToken {
|
||||
self.searchPartyToken = String(data: token, encoding: .ascii) ?? ""
|
||||
if self.searchPartyToken.isEmpty == false {
|
||||
self.searchPartyTokenLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mailPluginIsActive = true
|
||||
self.showMailPlugInPopover = false
|
||||
completion?(true)
|
||||
case .failure(let error):
|
||||
if let error = error as? AnisetteDataError {
|
||||
if let error = error as? AnisetteDataError, silent == false {
|
||||
switch error {
|
||||
case .pluginNotFound:
|
||||
self.alertType = .activatePlugin
|
||||
@@ -233,7 +265,15 @@ struct OpenHaystackMainView: View {
|
||||
self.alertType = .activatePlugin
|
||||
}
|
||||
}
|
||||
self.mailPluginIsActive = false
|
||||
completion?(false)
|
||||
|
||||
//Check again in 5s
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: .now() + 5,
|
||||
execute: {
|
||||
self.checkPluginIsRunning(silent: true, nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,10 +367,25 @@ struct OpenHaystackMainView: View {
|
||||
message: Text("Please select to which device you want to deploy"),
|
||||
primaryButton: microbitButton,
|
||||
secondaryButton: esp32Button)
|
||||
case .downloadingReportsFailed:
|
||||
return Alert(
|
||||
title: Text("Downloading locations failed"),
|
||||
message: Text("We could not download any locations from Apple. Please try again later"),
|
||||
dismissButton: Alert.Button.okay())
|
||||
case .exportFailed:
|
||||
return Alert(
|
||||
title: Text("Export failed"),
|
||||
message: Text("Please check that no the folder is writable and that you have the most current version of the app"),
|
||||
dismissButton: .okay())
|
||||
case .importFailed:
|
||||
return Alert(
|
||||
title: Text("Import failed"),
|
||||
message: Text("Could not import the selected file. Please make sure it has not been modified and that you have the current version of the app."),
|
||||
dismissButton: .okay())
|
||||
}
|
||||
}
|
||||
|
||||
enum AlertType: Int, Identifiable {
|
||||
enum AlertType: Int, Identifiable, Error {
|
||||
var id: Int {
|
||||
return self.rawValue
|
||||
}
|
||||
@@ -341,19 +396,22 @@ struct OpenHaystackMainView: View {
|
||||
case deployedSuccessfully
|
||||
case deletionFailed
|
||||
case noReportsFound
|
||||
case downloadingReportsFailed
|
||||
case activatePlugin
|
||||
case pluginInstallFailed
|
||||
case selectDepoyTarget
|
||||
case exportFailed
|
||||
case importFailed
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct OpenHaystackMainView_Previews: PreviewProvider {
|
||||
static var accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories) as AccessoryController
|
||||
static var accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories, findMyController: FindMyController()) as AccessoryController
|
||||
|
||||
static var previews: some View {
|
||||
OpenHaystackMainView()
|
||||
.environmentObject(accessoryController)
|
||||
.environmentObject(self.accessoryController)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,5 +28,7 @@
|
||||
<true/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>OpenHaystack uses Bluetooth to detect the presence of nearby accessories.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -12,28 +12,27 @@ import SwiftUI
|
||||
@main
|
||||
struct OpenHaystackApp: App {
|
||||
@StateObject var accessoryController: AccessoryController
|
||||
@StateObject var findMyController: FindMyController
|
||||
var accessoryNearbyMonitor: AccessoryNearbyMonitor?
|
||||
|
||||
init() {
|
||||
var accessoryController: AccessoryController
|
||||
let accessoryController: AccessoryController
|
||||
if ProcessInfo().arguments.contains("-preview") {
|
||||
accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories)
|
||||
accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories, findMyController: FindMyController())
|
||||
self.accessoryNearbyMonitor = nil
|
||||
} else {
|
||||
accessoryController = AccessoryController()
|
||||
self.accessoryNearbyMonitor = AccessoryNearbyMonitor(accessoryController: accessoryController)
|
||||
}
|
||||
self._accessoryController = StateObject(wrappedValue: accessoryController)
|
||||
self._findMyController = StateObject(wrappedValue: FindMyController(accessories: accessoryController))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
OpenHaystackMainView()
|
||||
.environmentObject(accessoryController)
|
||||
.environmentObject(findMyController)
|
||||
.environmentObject(self.accessoryController)
|
||||
}
|
||||
.commands {
|
||||
SidebarCommands()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
@property(nonatomic, copy) NSLocale *locale;
|
||||
@property(nonatomic, copy) NSTimeZone *timeZone;
|
||||
|
||||
@property(nonatomic, copy) NSData *searchPartyToken;
|
||||
@property(nonatomic, copy) NSData *_Nullable searchPartyToken;
|
||||
|
||||
- (instancetype)initWithMachineID:(NSString *)machineID
|
||||
oneTimePassword:(NSString *)oneTimePassword
|
||||
|
||||
61
OpenHaystack/OpenHaystackTests/BluetoothTests.swift
Normal file
61
OpenHaystack/OpenHaystackTests/BluetoothTests.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// 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 CoreBluetooth
|
||||
import XCTest
|
||||
|
||||
@testable import OpenHaystack
|
||||
|
||||
class BluetoothTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
func testNoManufacturerData() throws {
|
||||
let data: [String: Any] = [
|
||||
"": Data()
|
||||
]
|
||||
let adv = Advertisement(fromAdvertisementData: data)
|
||||
XCTAssertNil(adv)
|
||||
}
|
||||
|
||||
func testEmptyManufacturerData() throws {
|
||||
let data: [String: Any] = [
|
||||
CBAdvertisementDataManufacturerDataKey: Data()
|
||||
]
|
||||
let adv = Advertisement(fromAdvertisementData: data)
|
||||
XCTAssertNil(adv)
|
||||
}
|
||||
|
||||
func testCorrectAdvertisement() throws {
|
||||
let publicKey = "11111111111111111111111111111111111111111111".hexaData
|
||||
let data = "4c00121900111111111111111111111111111111111111111111110100".hexaData
|
||||
let adv = Advertisement(fromManufacturerData: data)
|
||||
XCTAssertNotNil(adv)
|
||||
XCTAssertEqual(adv?.publicKeyPayload, publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
extension StringProtocol {
|
||||
var hexaData: Data { .init(hexa) }
|
||||
var hexaBytes: [UInt8] { .init(hexa) }
|
||||
private var hexa: UnfoldSequence<UInt8, Index> {
|
||||
sequence(state: startIndex) { startIndex in
|
||||
guard startIndex < self.endIndex else { return nil }
|
||||
let endIndex = self.index(startIndex, offsetBy: 2, limitedBy: self.endIndex) ?? self.endIndex
|
||||
defer { startIndex = endIndex }
|
||||
return UInt8(self[startIndex..<endIndex], radix: 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user