Initial commit

This commit is contained in:
Milan Stute
2021-03-03 14:35:18 +01:00
commit 52dde375e3
93 changed files with 8048 additions and 0 deletions
@@ -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
}
@@ -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>
@@ -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.