50 Commits

Author SHA1 Message Date
Alexander Heinrich
3a8e543491 Using the ESP32 v4.3 branch and not the release since the release is still failing to build 2021-11-04 12:50:10 +01:00
Alexander Heinrich
c6600b0555 Updating esp-idf version 2021-11-04 11:16:41 +01:00
Howard
9363dc0534 Clarify the nature of the key parameter used by flash_esp32.sh
The OpenHaystack UI does not expose the concept of "public key". The script `flash_esp.32` is in fact expecting the base-64 encoded advertisement key.
2021-11-04 09:40:54 +01:00
yoution
e95fe0cc32 Update README.md
fix: fix board type
2021-11-04 09:40:29 +01:00
Alexander Heinrich
599c24042d Fixing an issue with swift-format. It has changed the command line arguments and was throwing an error otherwise 2021-11-04 09:20:54 +01:00
Alexander Heinrich
204473c1cf Fixing issues with the Mail plug-in update processs 2021-11-03 18:10:34 +01:00
Sascha Mowtschan
e55aa25d9c Fixes #85
Add PluginCompatibilityUUID for MacOS 12.0.1, Mail.app 15.0
2021-11-03 18:10:34 +01:00
Alexander Heinrich
e39e328a89 Updating UUIDs for macOS 11.6 2021-09-21 17:27:34 +02:00
Alexander Heinrich
f9149cdc74 nrf52832 pin layout 2021-08-25 15:53:03 +02:00
Alexander Heinrich
206a2e7004 Copying public to clipboard as Byte array or escaped string 2021-08-25 14:39:58 +02:00
Alexander Heinrich
78fba7391c Checking if the Mail plug-in is installed in the correct version. Otherwise the new mail plug-in will be installed 2021-08-06 11:46:56 +02:00
Alexander Heinrich
aa7c0a50af Updating workflows to macOS 11 2021-08-06 11:23:47 +02:00
Alexander Heinrich
48ceb9550c Small icon changes 2021-08-06 11:19:19 +02:00
Alexander Heinrich
6105a9454a Updating preview for better control of Screenshots 2021-08-06 11:19:19 +02:00
VladutLP
71fb26da56 Added a bunch of ID's into the plist for Mail app version 14 2021-08-06 11:16:10 +02:00
Milan Stute
c7a15fe0e4 Add WiSec demo 2021-06-02 14:09:57 +02:00
Alexander Heinrich
ffc5170ea4 Added a fix for the cropped rows on macOS 11.3
This is clearly a SwiftUI bug and it has been reported with FB9092071
2021-04-29 11:16:01 +02:00
Alexander Heinrich
f73c1ac636 Fixing memory leaks in ReportsFetcher 2021-04-29 11:08:41 +02:00
Alexander Heinrich
5dc6158da7 Fixing leaks in boring ssl usage 2021-04-29 11:08:41 +02:00
Alexander Heinrich
ba174196c0 Calling the completion handler in the case of a nil self 2021-04-29 11:08:41 +02:00
Tomas Harkema
c618aab843 make it a todo 2021-04-29 11:08:41 +02:00
Tomas Harkema
f8fb99cc41 burn some leaks 2021-04-29 11:08:41 +02:00
Frank Hessel
9f41994380 ESP32 Firmware: Consider Port and De-Duplicate Flashing Script 2021-04-29 09:05:31 +02:00
Sascha Mowtschan
b5a577ec4e Add "cleanup" to the deployment script #44 (enhancement) 2021-04-19 09:31:37 +02:00
Alexander Heinrich
b513d47ddc Updated Readme with info for missing Manage Plug-Ins button 2021-04-15 09:15:33 +02:00
Alexander Heinrich
acdae59b39 Updating ESP32 firmware to sending rate of 1-2s
This is done to save energy
2021-04-13 09:44:17 +02:00
Alexander Heinrich
880f1356de Reducing sending frequency of micro:bit firmware to 2s to reduce power consumption 2021-04-13 09:44:17 +02:00
Alexander Heinrich
edf2b59754 Export the created firmware file (instead of flashing directly)
Running swift-format
2021-04-13 09:44:17 +02:00
Alexander Heinrich
cf5103f62f Updating the mail state indicator when closing the mail app and reloading
Updating the mail state pop-up to make sure all text is shown and not clipped
2021-03-23 10:40:52 +01:00
Milan Stute
21eacc6c5c "tag" -> "accessory" (consistent with app UI) 2021-03-16 13:38:45 +01:00
Milan Stute
bdb8e8047b Consolidate infos about supported devices in README 2021-03-16 13:32:50 +01:00
Milan Stute
d1731c608a Fix swift-format complaints 2021-03-16 12:47:06 +01:00
Milan Stute
9f8352b022 Add logarithmic slider 2021-03-16 12:47:06 +01:00
Milan Stute
0e126e7882 Make update delay reusable and include call to zoomInOnAll 2021-03-16 12:20:56 +01:00
Alexander Heinrich
c7696b6687 Resolving the UI glitch when moving the slider quickly by delaying the map updates for a split second 2021-03-16 12:20:56 +01:00
Milan Stute
1883d47ac9 Add time slider 2021-03-16 12:20:56 +01:00
Milan Stute
76a01c187b Add history view (shows all location reports for a single accessory) 2021-03-16 12:20:56 +01:00
Milan Stute
2db31902d4 Update issue templates 2021-03-16 11:58:05 +01:00
Milan Stute
a88f5abeb4 Move nearby marker to the right 2021-03-15 17:16:01 +01:00
Milan Stute
cf0416e174 Unmark devices as nearby when they stop sending advertisements 2021-03-15 17:16:01 +01:00
Milan Stute
eb07546640 Update preview mode 2021-03-15 17:16:01 +01:00
Milan Stute
37de037986 Mark devices as active (orange) if they have been active in the past 2021-03-15 17:16:01 +01:00
Milan Stute
5117674ac9 Mark accessories as online when receiving Bluetooth advertisements 2021-03-15 17:16:01 +01:00
Milan Stute
d5546e1fa8 Disable deploy tests (will hang if no accessory is connected) 2021-03-15 12:56:26 +01:00
Milan Stute
1b6eadb301 Run autoformat 2021-03-15 12:56:08 +01:00
Milan Stute
2f32efef24 Mark accessory as deployed when deploy was successful 2021-03-15 12:51:07 +01:00
Alexander Heinrich
e7a6135d95 Showing error messages when the import fails 2021-03-15 10:36:28 +01:00
Alexander Heinrich
9406f817f3 Instead of showing a mail button a small circle is shown next to the reload button.
The circle is orange if the mail plug-in is disabled
2021-03-15 10:36:28 +01:00
Alexander Heinrich
ab1c3eb83a Adding a button that shows if the mail plug-in is active. The button turns red if the plug-in is not active.
Architectural changes discussed with @schmittner: Moving the FindMyController out of the environment and using the AccessoryController as the main entry point, also for downloading reports
The AccessoryController is now passed as an Environment Object again
2021-03-15 10:36:28 +01:00
Alexander Heinrich
b56aa1faa7 Added import and export options
Added the AccessoryController and the FindMyController to the SwiftUI Environment
2021-03-15 10:36:28 +01:00
67 changed files with 2491 additions and 1448 deletions

33
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,33 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**OpenHaystack version:**
[e.g. 0.3.4] (copy from _OpenHaystack → About OpenHaystack_)
**macOS version:**
[e.g. 11.3]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,10 @@
---
name: General question
about: Ask a question
title: ''
labels: question
assignees: ''
---

View File

@@ -20,7 +20,7 @@ runs:
sudo apt install git wget flex bison gperf python3 python3-pip python3-setuptools cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0
mkdir -p /opt/esp
cd /opt/esp
git clone --recursive --depth 1 --branch v4.2 https://github.com/espressif/esp-idf.git
git clone --recursive --depth 1 --branch release/v4.3 https://github.com/espressif/esp-idf.git
cd /opt/esp/esp-idf
./install.sh
- name: Build firmware

View File

@@ -18,14 +18,14 @@ defaults:
jobs:
format-swift:
runs-on: macos-latest
runs-on: macos-11
steps:
- name: "Checkout code"
uses: actions/checkout@v2
- name: "Install swift-format"
run: brew install swift-format
- name: "Run swift-format"
run: swift-format --recursive --mode lint .
run: swift-format lint --recursive .
format-objc:
runs-on: macos-latest

View File

@@ -16,7 +16,7 @@ defaults:
jobs:
lint-swiftlint:
runs-on: macos-latest
runs-on: macos-11
steps:
- name: "Checkout code"
uses: actions/checkout@v2

View File

@@ -16,7 +16,7 @@ defaults:
jobs:
build-firmware:
runs-on: macos-latest
runs-on: macos-11
steps:
- uses: actions/checkout@v2

View File

@@ -30,7 +30,7 @@ jobs:
build-and-release:
name: "Create release on GitHub"
runs-on: macos-latest
runs-on: macos-11
env:
APP: OpenHaystack
PROJECT_DIR: OpenHaystack

View File

@@ -13,30 +13,30 @@ import SwiftUI
@main
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
var window: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the window contents.
let contentView = OFFetchReportsMainView()
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the window contents.
let contentView = OFFetchReportsMainView()
// Create the window and set the content view.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.center()
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
}
// Create the window and set the content view.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.center()
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}

View File

@@ -10,14 +10,14 @@
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
var body: some View {
Text("Hello, World!")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
static var previews: some View {
ContentView()
}
}

View File

@@ -7,93 +7,100 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import CryptoKit
import Foundation
struct DecryptReports {
/// Decrypt a find my report with the according key
/// - Parameters:
/// - report: An encrypted FindMy Report
/// - key: A FindMyKey
/// - Throws: Errors if the decryption fails
/// - Returns: An decrypted location report
static func decrypt(report: FindMyReport, with key: FindMyKey) throws -> FindMyLocationReport {
let payloadData = report.payload
let keyData = key.privateKey
/// Decrypt a find my report with the according key
/// - Parameters:
/// - report: An encrypted FindMy Report
/// - key: A FindMyKey
/// - Throws: Errors if the decryption fails
/// - Returns: An decrypted location report
static func decrypt(report: FindMyReport, with key: FindMyKey) throws -> FindMyLocationReport {
let payloadData = report.payload
let keyData = key.privateKey
let privateKey = keyData
let ephemeralKey = payloadData.subdata(in: 5..<62)
let privateKey = keyData
let ephemeralKey = payloadData.subdata(in: 5..<62)
guard let sharedKey = BoringSSL.deriveSharedKey(
fromPrivateKey: privateKey,
andEphemeralKey: ephemeralKey) else {
throw FindMyError.decryptionError(description: "Failed generating shared key")
}
let derivedKey = self.kdf(fromSharedSecret: sharedKey, andEphemeralKey: ephemeralKey)
print("Derived key \(derivedKey.base64EncodedString())")
let encData = payloadData.subdata(in: 62..<72)
let tag = payloadData.subdata(in: 72..<payloadData.endIndex)
let decryptedContent = try self.decryptPayload(payload: encData, symmetricKey: derivedKey, tag: tag)
let locationReport = self.decode(content: decryptedContent, report: report)
print(locationReport)
return locationReport
guard
let sharedKey = BoringSSL.deriveSharedKey(
fromPrivateKey: privateKey,
andEphemeralKey: ephemeralKey)
else {
throw FindMyError.decryptionError(description: "Failed generating shared key")
}
/// Decrypt the payload
/// - Parameters:
/// - payload: Encrypted payload part
/// - symmetricKey: Symmetric key
/// - tag: AES GCM tag
/// - Throws: AES GCM error
/// - Returns: Decrypted error
static func decryptPayload(payload: Data, symmetricKey: Data, tag: Data) throws -> Data {
let decryptionKey = symmetricKey.subdata(in: 0..<16)
let iv = symmetricKey.subdata(in: 16..<symmetricKey.endIndex)
let derivedKey = self.kdf(fromSharedSecret: sharedKey, andEphemeralKey: ephemeralKey)
print("Decryption Key \(decryptionKey.base64EncodedString())")
print("IV \(iv.base64EncodedString())")
print("Derived key \(derivedKey.base64EncodedString())")
let sealedBox = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: iv), ciphertext: payload, tag: tag)
let symKey = SymmetricKey(data: decryptionKey)
let decrypted = try AES.GCM.open(sealedBox, using: symKey)
let encData = payloadData.subdata(in: 62..<72)
let tag = payloadData.subdata(in: 72..<payloadData.endIndex)
return decrypted
}
let decryptedContent = try self.decryptPayload(
payload: encData, symmetricKey: derivedKey, tag: tag)
let locationReport = self.decode(content: decryptedContent, report: report)
print(locationReport)
return locationReport
}
static func decode(content: Data, report: FindMyReport) -> FindMyLocationReport {
var longitude: Int32 = 0
_ = withUnsafeMutableBytes(of: &longitude, {content.subdata(in: 4..<8).copyBytes(to: $0)})
longitude = Int32(bigEndian: longitude)
/// Decrypt the payload
/// - Parameters:
/// - payload: Encrypted payload part
/// - symmetricKey: Symmetric key
/// - tag: AES GCM tag
/// - Throws: AES GCM error
/// - Returns: Decrypted error
static func decryptPayload(payload: Data, symmetricKey: Data, tag: Data) throws -> Data {
let decryptionKey = symmetricKey.subdata(in: 0..<16)
let iv = symmetricKey.subdata(in: 16..<symmetricKey.endIndex)
var latitude: Int32 = 0
_ = withUnsafeMutableBytes(of: &latitude, {content.subdata(in: 0..<4).copyBytes(to: $0)})
latitude = Int32(bigEndian: latitude)
print("Decryption Key \(decryptionKey.base64EncodedString())")
print("IV \(iv.base64EncodedString())")
var accuracy: UInt8 = 0
_ = withUnsafeMutableBytes(of: &accuracy, {content.subdata(in: 8..<9).copyBytes(to: $0)})
let sealedBox = try AES.GCM.SealedBox(
nonce: AES.GCM.Nonce(data: iv), ciphertext: payload, tag: tag)
let symKey = SymmetricKey(data: decryptionKey)
let decrypted = try AES.GCM.open(sealedBox, using: symKey)
let latitudeDec = Double(latitude)/10000000.0
let longitudeDec = Double(longitude)/10000000.0
return decrypted
}
return FindMyLocationReport(lat: latitudeDec, lng: longitudeDec, acc: accuracy, dP: report.datePublished, t: report.timestamp, c: report.confidence)
}
static func decode(content: Data, report: FindMyReport) -> FindMyLocationReport {
var longitude: Int32 = 0
_ = withUnsafeMutableBytes(of: &longitude, { content.subdata(in: 4..<8).copyBytes(to: $0) })
longitude = Int32(bigEndian: longitude)
static func kdf(fromSharedSecret secret: Data, andEphemeralKey ephKey: Data) -> Data {
var latitude: Int32 = 0
_ = withUnsafeMutableBytes(of: &latitude, { content.subdata(in: 0..<4).copyBytes(to: $0) })
latitude = Int32(bigEndian: latitude)
var shaDigest = SHA256()
shaDigest.update(data: secret)
var counter: Int32 = 1
let counterData = Data(Data(bytes: &counter, count: MemoryLayout.size(ofValue: counter)).reversed())
shaDigest.update(data: counterData)
shaDigest.update(data: ephKey)
var accuracy: UInt8 = 0
_ = withUnsafeMutableBytes(of: &accuracy, { content.subdata(in: 8..<9).copyBytes(to: $0) })
let derivedKey = shaDigest.finalize()
let latitudeDec = Double(latitude) / 10000000.0
let longitudeDec = Double(longitude) / 10000000.0
return Data(derivedKey)
}
return FindMyLocationReport(
lat: latitudeDec, lng: longitudeDec, acc: accuracy, dP: report.datePublished,
t: report.timestamp, c: report.confidence)
}
static func kdf(fromSharedSecret secret: Data, andEphemeralKey ephKey: Data) -> Data {
var shaDigest = SHA256()
shaDigest.update(data: secret)
var counter: Int32 = 1
let counterData = Data(
Data(bytes: &counter, count: MemoryLayout.size(ofValue: counter)).reversed())
shaDigest.update(data: counterData)
shaDigest.update(data: ephKey)
let derivedKey = shaDigest.finalize()
return Data(derivedKey)
}
}

View File

@@ -7,218 +7,232 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import Combine
import Foundation
import SwiftUI
import Combine
class FindMyController: ObservableObject {
static let shared = FindMyController()
static let shared = FindMyController()
@Published var error: Error?
@Published var devices = [FindMyDevice]()
@Published var error: Error?
@Published var devices = [FindMyDevice]()
func loadPrivateKeys(from data: Data, with searchPartyToken: Data, completion: @escaping (Error?) -> Void) {
func loadPrivateKeys(
from data: Data, with searchPartyToken: Data, completion: @escaping (Error?) -> Void
) {
do {
let devices = try PropertyListDecoder().decode([FindMyDevice].self, from: data)
self.devices.append(contentsOf: devices)
self.fetchReports(with: searchPartyToken, completion: completion)
} catch {
self.error = FindMyErrors.decodingPlistFailed(message: String(describing: error))
}
}
func importReports(reports: [FindMyReport], and keys: Data, completion: @escaping () -> Void)
throws
{
var devices = try PropertyListDecoder().decode([FindMyDevice].self, from: keys)
// Decrypt the reports with the imported keys
DispatchQueue.global(qos: .background).async {
// Add the reports to the according device by finding the right key for the report
for report in reports {
guard
let deviceIndex = devices.firstIndex(where: { (device) -> Bool in
device.keys.contains { (key) -> Bool in
key.hashedKey.base64EncodedString() == report.id
}
})
else {
print("No device found for id")
continue
}
if var reports = devices[deviceIndex].reports {
reports.append(report)
devices[deviceIndex].reports = reports
} else {
devices[deviceIndex].reports = [report]
}
}
self.devices = devices
// Decrypt the reports
self.decryptReports {
self.exportDevices()
DispatchQueue.main.async {
completion()
}
}
}
}
func importDevices(devices: Data) throws {
var devices = try PropertyListDecoder().decode([FindMyDevice].self, from: devices)
// Delete the decrypted reports
for idx in devices.startIndex..<devices.endIndex {
devices[idx].decryptedReports = nil
}
self.devices = devices
// Decrypt reports again with additional information
self.decryptReports {
}
}
func fetchReports(with searchPartyToken: Data, completion: @escaping (Error?) -> Void) {
DispatchQueue.global(qos: .background).async {
let fetchReportGroup = DispatchGroup()
let fetcher = ReportsFetcher()
var devices = self.devices
for deviceIndex in 0..<devices.count {
fetchReportGroup.enter()
devices[deviceIndex].reports = []
// Only use the newest keys for testing
let keys = devices[deviceIndex].keys
let keyHashes = keys.map({ $0.hashedKey.base64EncodedString() })
// 21 days
let duration: Double = (24 * 60 * 60) * 21
let startDate = Date() - duration
fetcher.query(
forHashes: keyHashes,
start: startDate,
duration: duration,
searchPartyToken: searchPartyToken
) { jd in
guard let jsonData = jd else {
fetchReportGroup.leave()
return
}
do {
// Decode the report
let report = try JSONDecoder().decode(FindMyReportResults.self, from: jsonData)
devices[deviceIndex].reports = report.results
} catch {
print("Failed with error \(error)")
devices[deviceIndex].reports = []
}
fetchReportGroup.leave()
}
}
// Completion Handler
fetchReportGroup.notify(queue: .main) {
print("Finished loading the reports. Now decrypt them")
// Export the reports to the desktop
var reports = [FindMyReport]()
for device in devices {
for report in device.reports! {
reports.append(report)
}
}
#if EXPORT
if let encoded = try? JSONEncoder().encode(reports) {
let outputDirectory = FileManager.default.urls(
for: .desktopDirectory, in: .userDomainMask
).first!
try? encoded.write(to: outputDirectory.appendingPathComponent("reports.json"))
}
#endif
DispatchQueue.main.async {
self.devices = devices
self.decryptReports {
completion(nil)
}
}
}
}
}
func decryptReports(completion: () -> Void) {
print("Decrypting reports")
// Iterate over all devices
for deviceIdx in 0..<devices.count {
devices[deviceIdx].decryptedReports = []
let device = devices[deviceIdx]
// Map the keys in a dictionary for faster access
guard let reports = device.reports else { continue }
let keyMap = device.keys.reduce(
into: [String: FindMyKey](), { $0[$1.hashedKey.base64EncodedString()] = $1 })
let accessQueue = DispatchQueue(
label: "threadSafeAccess",
qos: .userInitiated,
attributes: .concurrent,
autoreleaseFrequency: .workItem, target: nil)
var decryptedReports = [FindMyLocationReport](
repeating:
FindMyLocationReport(lat: 0, lng: 0, acc: 0, dP: Date(), t: Date(), c: 0),
count: reports.count)
DispatchQueue.concurrentPerform(iterations: reports.count) { (reportIdx) in
let report = reports[reportIdx]
guard let key = keyMap[report.id] else { return }
do {
let devices = try PropertyListDecoder().decode([FindMyDevice].self, from: data)
self.devices.append(contentsOf: devices)
self.fetchReports(with: searchPartyToken, completion: completion)
// Decrypt the report
let locationReport = try DecryptReports.decrypt(report: report, with: key)
accessQueue.async(flags: .barrier) {
decryptedReports[reportIdx] = locationReport
}
} catch {
self.error = FindMyErrors.decodingPlistFailed(message: String(describing: error))
return
}
}
accessQueue.sync {
devices[deviceIdx].decryptedReports = decryptedReports
}
}
func importReports(reports: [FindMyReport], and keys: Data, completion:@escaping () -> Void) throws {
var devices = try PropertyListDecoder().decode([FindMyDevice].self, from: keys)
completion()
// Decrypt the reports with the imported keys
DispatchQueue.global(qos: .background).async {
// Add the reports to the according device by finding the right key for the report
for report in reports {
}
guard let deviceIndex = devices.firstIndex(where: { (device) -> Bool in
device.keys.contains { (key) -> Bool in
key.hashedKey.base64EncodedString() == report.id
}
}) else {
print("No device found for id")
continue
}
if var reports = devices[deviceIndex].reports {
reports.append(report)
devices[deviceIndex].reports = reports
} else {
devices[deviceIndex].reports = [report]
}
}
self.devices = devices
func exportDevices() {
// Decrypt the reports
self.decryptReports {
self.exportDevices()
DispatchQueue.main.async {
completion()
}
}
}
}
func importDevices(devices: Data) throws {
var devices = try PropertyListDecoder().decode([FindMyDevice].self, from: devices)
// Delete the decrypted reports
for idx in devices.startIndex..<devices.endIndex {
devices[idx].decryptedReports = nil
}
self.devices = devices
// Decrypt reports again with additional information
self.decryptReports {
}
}
func fetchReports(with searchPartyToken: Data, completion: @escaping (Error?) -> Void) {
DispatchQueue.global(qos: .background).async {
let fetchReportGroup = DispatchGroup()
let fetcher = ReportsFetcher()
var devices = self.devices
for deviceIndex in 0..<devices.count {
fetchReportGroup.enter()
devices[deviceIndex].reports = []
// Only use the newest keys for testing
let keys = devices[deviceIndex].keys
let keyHashes = keys.map({$0.hashedKey.base64EncodedString()})
// 21 days
let duration: Double = (24 * 60 * 60) * 21
let startDate = Date() - duration
fetcher.query(forHashes: keyHashes,
start: startDate,
duration: duration,
searchPartyToken: searchPartyToken) { jd in
guard let jsonData = jd else {
fetchReportGroup.leave()
return
}
do {
// Decode the report
let report = try JSONDecoder().decode(FindMyReportResults.self, from: jsonData)
devices[deviceIndex].reports = report.results
} catch {
print("Failed with error \(error)")
devices[deviceIndex].reports = []
}
fetchReportGroup.leave()
}
}
// Completion Handler
fetchReportGroup.notify(queue: .main) {
print("Finished loading the reports. Now decrypt them")
// Export the reports to the desktop
var reports = [FindMyReport]()
for device in devices {
for report in device.reports! {
reports.append(report)
}
}
#if EXPORT
if let encoded = try? JSONEncoder().encode(reports) {
let outputDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first!
try? encoded.write(to: outputDirectory.appendingPathComponent("reports.json"))
}
#endif
DispatchQueue.main.async {
self.devices = devices
self.decryptReports {
completion(nil)
}
}
}
}
}
func decryptReports(completion: () -> Void) {
print("Decrypting reports")
// Iterate over all devices
for deviceIdx in 0..<devices.count {
devices[deviceIdx].decryptedReports = []
let device = devices[deviceIdx]
// Map the keys in a dictionary for faster access
guard let reports = device.reports else {continue}
let keyMap = device.keys.reduce(into: [String: FindMyKey](), {$0[$1.hashedKey.base64EncodedString()] = $1})
let accessQueue = DispatchQueue(label: "threadSafeAccess",
qos: .userInitiated,
attributes: .concurrent,
autoreleaseFrequency: .workItem, target: nil)
var decryptedReports = [FindMyLocationReport](repeating:
FindMyLocationReport(lat: 0, lng: 0, acc: 0, dP: Date(), t: Date(), c: 0),
count: reports.count)
DispatchQueue.concurrentPerform(iterations: reports.count) { (reportIdx) in
let report = reports[reportIdx]
guard let key = keyMap[report.id] else {return}
do {
// Decrypt the report
let locationReport = try DecryptReports.decrypt(report: report, with: key)
accessQueue.async(flags: .barrier) {
decryptedReports[reportIdx] = locationReport
}
} catch {
return
}
}
accessQueue.sync {
devices[deviceIdx].decryptedReports = decryptedReports
}
}
completion()
}
func exportDevices() {
if let encoded = try? PropertyListEncoder().encode(self.devices) {
let outputDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first!
try? encoded.write(to: outputDirectory.appendingPathComponent("devices-\(Date()).plist"))
}
if let encoded = try? PropertyListEncoder().encode(self.devices) {
let outputDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask)
.first!
try? encoded.write(to: outputDirectory.appendingPathComponent("devices-\(Date()).plist"))
}
}
}
struct FindMyControllerKey: EnvironmentKey {
static var defaultValue: FindMyController = .shared
static var defaultValue: FindMyController = .shared
}
extension EnvironmentValues {
var findMyController: FindMyController {
get {self[FindMyControllerKey.self]}
set {self[FindMyControllerKey.self] = newValue}
}
var findMyController: FindMyController {
get { self[FindMyControllerKey.self] }
set { self[FindMyControllerKey.self] = newValue }
}
}
enum FindMyErrors: Error {
case decodingPlistFailed(message: String)
case decodingPlistFailed(message: String)
}

View File

@@ -7,109 +7,110 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import CryptoKit
import Foundation
/// Decode key files found in newer macOS versions.
class FindMyKeyDecoder {
/// Key files can be in different format.
/// The old <= 10.15.3 have been using normal plists.
/// Newer once use a binary format which needs different parsing
enum KeyFileFormat {
// swiftlint:disable identifier_name
/// Catalina > 10.15.4 key file format | Big Sur 11.0 Beta 1 uses a similar key
/// file format that can be parsed identically.
/// macOS 10.15.7 uses a new key file format that has not been reversed yet.
/// (The key files are protected by sandboxing and only usable from a SIP disabled)
case catalina_10_15_4
/// Key files can be in different format.
/// The old <= 10.15.3 have been using normal plists.
/// Newer once use a binary format which needs different parsing
enum KeyFileFormat {
// swiftlint:disable identifier_name
/// Catalina > 10.15.4 key file format | Big Sur 11.0 Beta 1 uses a similar key
/// file format that can be parsed identically.
/// macOS 10.15.7 uses a new key file format that has not been reversed yet.
/// (The key files are protected by sandboxing and only usable from a SIP disabled)
case catalina_10_15_4
}
var fileFormat: KeyFileFormat?
func parse(keyFile: Data) throws -> [FindMyKey] {
// Detect the format at first
if fileFormat == nil {
try self.checkFormat(for: keyFile)
}
guard let format = self.fileFormat else {
throw ParsingError.unsupportedFormat
}
var fileFormat: KeyFileFormat?
switch format {
case .catalina_10_15_4:
let keys = try self.parseBinaryKeyFiles(from: keyFile)
return keys
}
}
func parse(keyFile: Data) throws -> [FindMyKey] {
// Detect the format at first
if fileFormat == nil {
try self.checkFormat(for: keyFile)
}
guard let format = self.fileFormat else {
throw ParsingError.unsupportedFormat
}
switch format {
case .catalina_10_15_4:
let keys = try self.parseBinaryKeyFiles(from: keyFile)
return keys
}
func checkFormat(for keyFile: Data) throws {
// Key files need to start with KEY = 0x4B 45 59
let magicBytes = keyFile.subdata(in: 0..<3)
guard magicBytes == Data([0x4b, 0x45, 0x59]) else {
throw ParsingError.wrongMagicBytes
}
func checkFormat(for keyFile: Data) throws {
// Key files need to start with KEY = 0x4B 45 59
let magicBytes = keyFile.subdata(in: 0..<3)
guard magicBytes == Data([0x4b, 0x45, 0x59]) else {
throw ParsingError.wrongMagicBytes
}
// Detect zeros
let potentialZeros = keyFile[15..<31]
guard potentialZeros == Data(repeating: 0x00, count: 16) else {
throw ParsingError.wrongFormat
}
// Should be big sur
self.fileFormat = .catalina_10_15_4
}
// Detect zeros
let potentialZeros = keyFile[15..<31]
guard potentialZeros == Data(repeating: 0x00, count: 16) else {
throw ParsingError.wrongFormat
}
// Should be big sur
self.fileFormat = .catalina_10_15_4
fileprivate func parseBinaryKeyFiles(from keyFile: Data) throws -> [FindMyKey] {
var keys = [FindMyKey]()
// First key starts at 32
var i = 32
while i + 117 < keyFile.count {
// We could not identify what those keys were
_ = keyFile.subdata(in: i..<i + 32)
i += 32
if keyFile[i] == 0x00 {
// Public key only.
// No need to parse it. Just skip to the next key
i += 86
continue
}
guard keyFile[i] == 0x01 else {
throw ParsingError.wrongFormat
}
// Step over 0x01
i += 1
// Read the key (starting with 0x04)
let fullKey = keyFile.subdata(in: i..<i + 85)
i += 85
// Create the sub keys. No actual need,
// but we do that to put them into a similar format as used before 10.15.4
let advertisedKey = fullKey.subdata(in: 1..<29)
let yCoordinate = fullKey.subdata(in: 29..<57)
var shaDigest = SHA256()
shaDigest.update(data: advertisedKey)
let hashedKey = Data(shaDigest.finalize())
let fmKey = FindMyKey(
advertisedKey: advertisedKey,
hashedKey: hashedKey,
privateKey: fullKey,
startTime: nil,
duration: nil,
pu: nil,
yCoordinate: yCoordinate,
fullKey: fullKey)
keys.append(fmKey)
}
fileprivate func parseBinaryKeyFiles(from keyFile: Data) throws -> [FindMyKey] {
var keys = [FindMyKey]()
// First key starts at 32
var i = 32
return keys
}
while i + 117 < keyFile.count {
// We could not identify what those keys were
_ = keyFile.subdata(in: i..<i+32)
i += 32
if keyFile[i] == 0x00 {
// Public key only.
// No need to parse it. Just skip to the next key
i += 86
continue
}
guard keyFile[i] == 0x01 else {
throw ParsingError.wrongFormat
}
// Step over 0x01
i+=1
// Read the key (starting with 0x04)
let fullKey = keyFile.subdata(in: i..<i+85)
i += 85
// Create the sub keys. No actual need,
// but we do that to put them into a similar format as used before 10.15.4
let advertisedKey = fullKey.subdata(in: 1..<29)
let yCoordinate = fullKey.subdata(in: 29..<57)
var shaDigest = SHA256()
shaDigest.update(data: advertisedKey)
let hashedKey = Data(shaDigest.finalize())
let fmKey = FindMyKey(advertisedKey: advertisedKey,
hashedKey: hashedKey,
privateKey: fullKey,
startTime: nil,
duration: nil,
pu: nil,
yCoordinate: yCoordinate,
fullKey: fullKey)
keys.append(fmKey)
}
return keys
}
enum ParsingError: Error {
case wrongMagicBytes
case wrongFormat
case unsupportedFormat
}
enum ParsingError: Error {
case wrongMagicBytes
case wrongFormat
case unsupportedFormat
}
}

View File

@@ -9,191 +9,195 @@
// swiftlint:disable identifier_name
import Foundation
import CoreLocation
import Foundation
struct FindMyDevice: Codable, Hashable {
let deviceId: String
var keys = [FindMyKey]()
let deviceId: String
var keys = [FindMyKey]()
var catalinaBigSurKeyFiles: [Data]?
var catalinaBigSurKeyFiles: [Data]?
/// KeyHash: Report results
var reports: [FindMyReport]?
/// KeyHash: Report results
var reports: [FindMyReport]?
var decryptedReports: [FindMyLocationReport]?
var decryptedReports: [FindMyLocationReport]?
func hash(into hasher: inout Hasher) {
hasher.combine(deviceId)
}
func hash(into hasher: inout Hasher) {
hasher.combine(deviceId)
}
static func == (lhs: FindMyDevice, rhs: FindMyDevice) -> Bool {
lhs.deviceId == rhs.deviceId
}
static func == (lhs: FindMyDevice, rhs: FindMyDevice) -> Bool {
lhs.deviceId == rhs.deviceId
}
}
struct FindMyKey: Codable {
internal init(advertisedKey: Data, hashedKey: Data, privateKey: Data, startTime: Date?, duration: Double?, pu: Data?, yCoordinate: Data?, fullKey: Data?) {
self.advertisedKey = advertisedKey
self.hashedKey = hashedKey
// The private key should only be 28 bytes long. If a 85 bytes full private public key is entered we truncate it here
if privateKey.count == 85 {
self.privateKey = privateKey.subdata(in: 57..<privateKey.endIndex)
} else {
self.privateKey = privateKey
}
self.startTime = startTime
self.duration = duration
self.pu = pu
self.yCoordinate = yCoordinate
self.fullKey = fullKey
internal init(
advertisedKey: Data, hashedKey: Data, privateKey: Data, startTime: Date?, duration: Double?,
pu: Data?, yCoordinate: Data?, fullKey: Data?
) {
self.advertisedKey = advertisedKey
self.hashedKey = hashedKey
// The private key should only be 28 bytes long. If a 85 bytes full private public key is entered we truncate it here
if privateKey.count == 85 {
self.privateKey = privateKey.subdata(in: 57..<privateKey.endIndex)
} else {
self.privateKey = privateKey
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.advertisedKey = try container.decode(Data.self, forKey: .advertisedKey)
self.hashedKey = try container.decode(Data.self, forKey: .hashedKey)
let privateKey = try container.decode(Data.self, forKey: .privateKey)
if privateKey.count == 85 {
self.privateKey = privateKey.subdata(in: 57..<privateKey.endIndex)
} else {
self.privateKey = privateKey
}
self.startTime = startTime
self.duration = duration
self.pu = pu
self.yCoordinate = yCoordinate
self.fullKey = fullKey
}
self.startTime = try? container.decode(Date.self, forKey: .startTime)
self.duration = try? container.decode(Double.self, forKey: .duration)
self.pu = try? container.decode(Data.self, forKey: .pu)
self.yCoordinate = try? container.decode(Data.self, forKey: .yCoordinate)
self.fullKey = try? container.decode(Data.self, forKey: .fullKey)
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.advertisedKey = try container.decode(Data.self, forKey: .advertisedKey)
self.hashedKey = try container.decode(Data.self, forKey: .hashedKey)
let privateKey = try container.decode(Data.self, forKey: .privateKey)
if privateKey.count == 85 {
self.privateKey = privateKey.subdata(in: 57..<privateKey.endIndex)
} else {
self.privateKey = privateKey
}
/// The advertising key
let advertisedKey: Data
/// Hashed advertisement key using SHA256
let hashedKey: Data
/// The private key from which the advertisement keys can be derived
let privateKey: Data
/// When this key was used to send out BLE advertisements
let startTime: Date?
/// Duration from start time how long the key has been used to send out BLE advertisements
let duration: Double?
/// ?
let pu: Data?
self.startTime = try? container.decode(Date.self, forKey: .startTime)
self.duration = try? container.decode(Double.self, forKey: .duration)
self.pu = try? container.decode(Data.self, forKey: .pu)
self.yCoordinate = try? container.decode(Data.self, forKey: .yCoordinate)
self.fullKey = try? container.decode(Data.self, forKey: .fullKey)
}
/// As exported from Big Sur
let yCoordinate: Data?
/// As exported from BigSur
let fullKey: Data?
/// The advertising key
let advertisedKey: Data
/// Hashed advertisement key using SHA256
let hashedKey: Data
/// The private key from which the advertisement keys can be derived
let privateKey: Data
/// When this key was used to send out BLE advertisements
let startTime: Date?
/// Duration from start time how long the key has been used to send out BLE advertisements
let duration: Double?
/// ?
let pu: Data?
/// As exported from Big Sur
let yCoordinate: Data?
/// As exported from BigSur
let fullKey: Data?
}
struct FindMyReportResults: Codable {
let results: [FindMyReport]
let results: [FindMyReport]
}
struct FindMyReport: Codable {
let datePublished: Date
let payload: Data
let id: String
let statusCode: Int
let datePublished: Date
let payload: Data
let id: String
let statusCode: Int
let confidence: UInt8
let timestamp: Date
let confidence: UInt8
let timestamp: Date
enum CodingKeys: CodingKey {
case datePublished
case payload
case id
case statusCode
enum CodingKeys: CodingKey {
case datePublished
case payload
case id
case statusCode
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
let dateTimestamp = try values.decode(Double.self, forKey: .datePublished)
// Convert from milis to time interval
let dP = Date(timeIntervalSince1970: dateTimestamp / 1000)
let df = DateFormatter()
df.dateFormat = "YYYY-MM-dd"
if dP < df.date(from: "2020-01-01")! {
self.datePublished = Date(timeIntervalSince1970: dateTimestamp)
} else {
self.datePublished = dP
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
let dateTimestamp = try values.decode(Double.self, forKey: .datePublished)
// Convert from milis to time interval
let dP = Date(timeIntervalSince1970: dateTimestamp/1000)
let df = DateFormatter()
df.dateFormat = "YYYY-MM-dd"
self.statusCode = try values.decode(Int.self, forKey: .statusCode)
let payloadBase64 = try values.decode(String.self, forKey: .payload)
if dP < df.date(from: "2020-01-01")! {
self.datePublished = Date(timeIntervalSince1970: dateTimestamp)
} else {
self.datePublished = dP
}
guard let payload = Data(base64Encoded: payloadBase64) else {
throw DecodingError.dataCorruptedError(
forKey: CodingKeys.payload, in: values, debugDescription: "")
}
self.payload = payload
self.statusCode = try values.decode(Int.self, forKey: .statusCode)
let payloadBase64 = try values.decode(String.self, forKey: .payload)
guard let payload = Data(base64Encoded: payloadBase64) else {
throw DecodingError.dataCorruptedError(forKey: CodingKeys.payload, in: values, debugDescription: "")
}
self.payload = payload
var timestampData = payload.subdata(in: 0..<4)
let timestamp: Int32 = withUnsafeBytes(of: &timestampData) { (pointer) -> Int32 in
// Convert the endianness
pointer.load(as: Int32.self).bigEndian
}
// It's a cocoa time stamp (counting from 2001)
self.timestamp = Date(timeIntervalSinceReferenceDate: TimeInterval(timestamp))
self.confidence = payload[4]
self.id = try values.decode(String.self, forKey: .id)
var timestampData = payload.subdata(in: 0..<4)
let timestamp: Int32 = withUnsafeBytes(of: &timestampData) { (pointer) -> Int32 in
// Convert the endianness
pointer.load(as: Int32.self).bigEndian
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.datePublished.timeIntervalSince1970 * 1000, forKey: .datePublished)
try container.encode(self.payload.base64EncodedString(), forKey: .payload)
try container.encode(self.id, forKey: .id)
try container.encode(self.statusCode, forKey: .statusCode)
}
// It's a cocoa time stamp (counting from 2001)
self.timestamp = Date(timeIntervalSinceReferenceDate: TimeInterval(timestamp))
self.confidence = payload[4]
self.id = try values.decode(String.self, forKey: .id)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.datePublished.timeIntervalSince1970 * 1000, forKey: .datePublished)
try container.encode(self.payload.base64EncodedString(), forKey: .payload)
try container.encode(self.id, forKey: .id)
try container.encode(self.statusCode, forKey: .statusCode)
}
}
struct FindMyLocationReport: Codable {
let latitude: Double
let longitude: Double
let accuracy: UInt8
let datePublished: Date
let timestamp: Date?
let confidence: UInt8?
let latitude: Double
let longitude: Double
let accuracy: UInt8
let datePublished: Date
let timestamp: Date?
let confidence: UInt8?
var location: CLLocation {
return CLLocation(latitude: latitude, longitude: longitude)
var location: CLLocation {
return CLLocation(latitude: latitude, longitude: longitude)
}
init(lat: Double, lng: Double, acc: UInt8, dP: Date, t: Date, c: UInt8) {
self.latitude = lat
self.longitude = lng
self.accuracy = acc
self.datePublished = dP
self.timestamp = t
self.confidence = c
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.latitude = try values.decode(Double.self, forKey: .latitude)
self.longitude = try values.decode(Double.self, forKey: .longitude)
do {
let uAcc = try values.decode(UInt8.self, forKey: .accuracy)
self.accuracy = uAcc
} catch {
let iAcc = try values.decode(Int8.self, forKey: .accuracy)
self.accuracy = UInt8(bitPattern: iAcc)
}
init(lat: Double, lng: Double, acc: UInt8, dP: Date, t: Date, c: UInt8) {
self.latitude = lat
self.longitude = lng
self.accuracy = acc
self.datePublished = dP
self.timestamp = t
self.confidence = c
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.latitude = try values.decode(Double.self, forKey: .latitude)
self.longitude = try values.decode(Double.self, forKey: .longitude)
do {
let uAcc = try values.decode(UInt8.self, forKey: .accuracy)
self.accuracy = uAcc
} catch {
let iAcc = try values.decode(Int8.self, forKey: .accuracy)
self.accuracy = UInt8(bitPattern: iAcc)
}
self.datePublished = try values.decode(Date.self, forKey: .datePublished)
self.timestamp = try? values.decode(Date.self, forKey: .timestamp)
self.confidence = try? values.decode(UInt8.self, forKey: .confidence)
}
self.datePublished = try values.decode(Date.self, forKey: .datePublished)
self.timestamp = try? values.decode(Date.self, forKey: .timestamp)
self.confidence = try? values.decode(UInt8.self, forKey: .confidence)
}
}
enum FindMyError: Error {
case decryptionError(description: String)
case decryptionError(description: String)
}

View File

@@ -7,19 +7,19 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import SwiftUI
import Cocoa
import MapKit
import SwiftUI
struct MapView: NSViewControllerRepresentable {
@Environment(\.findMyController) var findMyController
@Environment(\.findMyController) var findMyController
func makeNSViewController(context: Context) -> MapViewController {
return MapViewController(nibName: NSNib.Name("MapViewController"), bundle: nil)
}
func makeNSViewController(context: Context) -> MapViewController {
return MapViewController(nibName: NSNib.Name("MapViewController"), bundle: nil)
}
func updateNSViewController(_ nsViewController: MapViewController, context: Context) {
nsViewController.addLocationsReports(from: findMyController.devices)
}
func updateNSViewController(_ nsViewController: MapViewController, context: Context) {
nsViewController.addLocationsReports(from: findMyController.devices)
}
}

View File

@@ -11,43 +11,45 @@ import Cocoa
import MapKit
final class MapViewController: NSViewController, MKMapViewDelegate {
@IBOutlet weak var mapView: MKMapView!
var pinsShown = false
@IBOutlet weak var mapView: MKMapView!
var pinsShown = false
override func viewDidLoad() {
super.viewDidLoad()
self.mapView.delegate = self
override func viewDidLoad() {
super.viewDidLoad()
self.mapView.delegate = self
}
func addLocationsReports(from devices: [FindMyDevice]) {
if !self.mapView.annotations.isEmpty {
self.mapView.removeAnnotations(self.mapView.annotations)
}
func addLocationsReports(from devices: [FindMyDevice]) {
if !self.mapView.annotations.isEmpty {
self.mapView.removeAnnotations(self.mapView.annotations)
}
// Zoom to first location
if let location = devices.first?.decryptedReports?.first {
let coordinate = CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude)
let span = MKCoordinateSpan(latitudeDelta: 5.0, longitudeDelta: 5.0)
let region = MKCoordinateRegion(center: coordinate, span: span)
self.mapView.setRegion(region, animated: true)
}
// Add pins
for device in devices {
guard let reports = device.decryptedReports else {continue}
for report in reports {
let pin = MKPointAnnotation()
pin.title = device.deviceId
pin.coordinate = CLLocationCoordinate2D(latitude: report.latitude, longitude: report.longitude)
self.mapView.addAnnotation(pin)
}
}
// Zoom to first location
if let location = devices.first?.decryptedReports?.first {
let coordinate = CLLocationCoordinate2D(
latitude: location.latitude, longitude: location.longitude)
let span = MKCoordinateSpan(latitudeDelta: 5.0, longitudeDelta: 5.0)
let region = MKCoordinateRegion(center: coordinate, span: span)
self.mapView.setRegion(region, animated: true)
}
func changeMapType(_ mapType: MKMapType) {
self.mapView.mapType = mapType
// Add pins
for device in devices {
guard let reports = device.decryptedReports else { continue }
for report in reports {
let pin = MKPointAnnotation()
pin.title = device.deviceId
pin.coordinate = CLLocationCoordinate2D(
latitude: report.latitude, longitude: report.longitude)
self.mapView.addAnnotation(pin)
}
}
}
func changeMapType(_ mapType: MKMapType) {
self.mapView.mapType = mapType
}
}

View File

@@ -11,188 +11,200 @@ import SwiftUI
struct OFFetchReportsMainView: View {
@Environment(\.findMyController) var findMyController
@Environment(\.findMyController) var findMyController
@State var targetedDrop: Bool = false
@State var error: Error?
@State var showMap = false
@State var loading = false
@State var targetedDrop: Bool = false
@State var error: Error?
@State var showMap = false
@State var loading = false
@State var searchPartyToken: Data?
@State var searchPartyTokenString: String = ""
@State var keyPlistFile: Data?
@State var searchPartyToken: Data?
@State var searchPartyTokenString: String = ""
@State var keyPlistFile: Data?
@State var showTokenPrompt = false
@State var showTokenPrompt = false
var dropView: some View {
ZStack(alignment: .center) {
HStack {
Spacer()
Spacer()
}
var dropView: some View {
ZStack(alignment: .center) {
HStack {
Spacer()
Spacer()
}
VStack {
Spacer()
Text("Drop exported keys here")
.font(Font.system(size: 44, weight: .bold, design: .default))
.padding()
VStack {
Spacer()
Text("Drop exported keys here")
.font(Font.system(size: 44, weight: .bold, design: .default))
.padding()
Text("The keys can be exported into the right format using the Read FindMy Keys App.")
.font(.body)
.multilineTextAlignment(.center)
.padding()
Text("The keys can be exported into the right format using the Read FindMy Keys App.")
.font(.body)
.multilineTextAlignment(.center)
.padding()
Spacer()
}
}
.background(
RoundedRectangle(cornerRadius: 20.0)
.stroke(Color.gray, style: StrokeStyle(lineWidth: 5.0, lineCap: .round, lineJoin: .round, miterLimit: 10, dash: [15]))
)
Spacer()
}
}
.background(
RoundedRectangle(cornerRadius: 20.0)
.stroke(
Color.gray,
style: StrokeStyle(
lineWidth: 5.0, lineCap: .round, lineJoin: .round, miterLimit: 10, dash: [15]))
)
.padding()
.onDrop(of: ["public.file-url"], isTargeted: self.$targetedDrop) { (droppedData) -> Bool in
return self.droppedData(data: droppedData)
}
}
var loadingView: some View {
VStack {
Text("Downloading locations and decrypting...")
.font(Font.system(size: 44, weight: .bold, design: .default))
.padding()
.onDrop(of: ["public.file-url"], isTargeted: self.$targetedDrop) { (droppedData) -> Bool in
return self.droppedData(data: droppedData)
}
}
}
var loadingView: some View {
VStack {
Text("Downloading locations and decrypting...")
.font(Font.system(size: 44, weight: .bold, design: .default))
.padding()
}
}
/// This view is shown if the search party token cannot be accessed from keychain
var missingSearchPartyTokenView: some View {
VStack {
Text("Search Party token could not be fetched")
Text("Please paste the search party token below after copying it from the macOS Keychain.")
Text("The item that contains the key can be found by searching for: ")
Text("com.apple.account.DeviceLocator.search-party-token")
.font(.system(Font.TextStyle.body, design: Font.Design.monospaced))
/// This view is shown if the search party token cannot be accessed from keychain
var missingSearchPartyTokenView: some View {
VStack {
Text("Search Party token could not be fetched")
Text("Please paste the search party token below after copying it from the macOS Keychain.")
Text("The item that contains the key can be found by searching for: ")
Text("com.apple.account.DeviceLocator.search-party-token")
.font(.system(Font.TextStyle.body, design: Font.Design.monospaced))
TextField("Search Party Token", text: self.$searchPartyTokenString)
Button(action: {
if !self.searchPartyTokenString.isEmpty,
let file = self.keyPlistFile,
let searchPartyToken = self.searchPartyTokenString.data(using: .utf8) {
self.searchPartyToken = searchPartyToken
self.downloadAndDecryptLocations(with: file, searchPartyToken: searchPartyToken)
}
}, label: {
Text("Download reports")
})
}
}
var mapView: some View {
ZStack {
MapView()
VStack {
HStack {
Spacer()
Button(action: {
self.showMap = false
self.showTokenPrompt = false
}, label: {
Text("Import other tokens")
})
Button(action: {
self.exportDecryptedLocations()
}, label: {
Text("Export")
})
}
.padding()
Spacer()
}
}
}
var body: some View {
GeometryReader { geo in
if self.loading {
self.loadingView
} else if self.showMap {
self.mapView
} else if self.showTokenPrompt {
self.missingSearchPartyTokenView
} else {
self.dropView
.frame(width: geo.size.width, height: geo.size.height)
}
}
}
// swiftlint:disable identifier_name
func droppedData(data: [NSItemProvider]) -> Bool {
guard let itemProvider = data.first else {return false}
itemProvider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { (u, _) in
guard let urlData = u as? Data,
let fileURL = URL(dataRepresentation: urlData, relativeTo: nil),
// Only plist supported
fileURL.pathExtension == "plist",
// Load the file
let file = try? Data(contentsOf: fileURL)
else {return}
print("Received data \(fileURL)")
self.keyPlistFile = file
let reportsFetcher = ReportsFetcher()
self.searchPartyToken = reportsFetcher.fetchSearchpartyToken()
if let searchPartyToken = self.searchPartyToken {
self.downloadAndDecryptLocations(with: file, searchPartyToken: searchPartyToken)
} else {
self.showTokenPrompt = true
}
}
return true
}
func downloadAndDecryptLocations(with keyFile: Data, searchPartyToken: Data) {
self.loading = true
self.findMyController.loadPrivateKeys(from: keyFile, with: searchPartyToken, completion: { error in
// Check if an error occurred
guard error == nil else {
self.error = error
return
}
// Show map view
self.loading = false
self.showMap = true
TextField("Search Party Token", text: self.$searchPartyTokenString)
Button(
action: {
if !self.searchPartyTokenString.isEmpty,
let file = self.keyPlistFile,
let searchPartyToken = self.searchPartyTokenString.data(using: .utf8)
{
self.searchPartyToken = searchPartyToken
self.downloadAndDecryptLocations(with: file, searchPartyToken: searchPartyToken)
}
},
label: {
Text("Download reports")
})
}
}
func exportDecryptedLocations() {
do {
let devices = self.findMyController.devices
let deviceData = try PropertyListEncoder().encode(devices)
var mapView: some View {
ZStack {
MapView()
VStack {
HStack {
Spacer()
Button(
action: {
self.showMap = false
self.showTokenPrompt = false
},
label: {
Text("Import other tokens")
})
SavePanel().saveFile(file: deviceData, fileExtension: "plist")
Button(
action: {
self.exportDecryptedLocations()
},
label: {
Text("Export")
})
} catch {
print("Error: \(error)")
}
.padding()
Spacer()
}
}
}
var body: some View {
GeometryReader { geo in
if self.loading {
self.loadingView
} else if self.showMap {
self.mapView
} else if self.showTokenPrompt {
self.missingSearchPartyTokenView
} else {
self.dropView
.frame(width: geo.size.width, height: geo.size.height)
}
}
}
// swiftlint:disable identifier_name
func droppedData(data: [NSItemProvider]) -> Bool {
guard let itemProvider = data.first else { return false }
itemProvider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { (u, _) in
guard let urlData = u as? Data,
let fileURL = URL(dataRepresentation: urlData, relativeTo: nil),
// Only plist supported
fileURL.pathExtension == "plist",
// Load the file
let file = try? Data(contentsOf: fileURL)
else { return }
print("Received data \(fileURL)")
self.keyPlistFile = file
let reportsFetcher = ReportsFetcher()
self.searchPartyToken = reportsFetcher.fetchSearchpartyToken()
if let searchPartyToken = self.searchPartyToken {
self.downloadAndDecryptLocations(with: file, searchPartyToken: searchPartyToken)
} else {
self.showTokenPrompt = true
}
}
return true
}
func downloadAndDecryptLocations(with keyFile: Data, searchPartyToken: Data) {
self.loading = true
self.findMyController.loadPrivateKeys(
from: keyFile, with: searchPartyToken,
completion: { error in
// Check if an error occurred
guard error == nil else {
self.error = error
return
}
// Show map view
self.loading = false
self.showMap = true
})
}
func exportDecryptedLocations() {
do {
let devices = self.findMyController.devices
let deviceData = try PropertyListEncoder().encode(devices)
SavePanel().saveFile(file: deviceData, fileExtension: "plist")
} catch {
print("Error: \(error)")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
OFFetchReportsMainView()
}
static var previews: some View {
OFFetchReportsMainView()
}
}

View File

@@ -7,43 +7,44 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import AppKit
import Foundation
class SavePanel: NSObject, NSOpenSavePanelDelegate {
static let shared = SavePanel()
static let shared = SavePanel()
var fileToSave: Data?
var fileExtension: String?
var panel: NSSavePanel?
var fileToSave: Data?
var fileExtension: String?
var panel: NSSavePanel?
func saveFile(file: Data, fileExtension: String) {
self.fileToSave = file
self.fileExtension = fileExtension
func saveFile(file: Data, fileExtension: String) {
self.fileToSave = file
self.fileExtension = fileExtension
self.panel = NSSavePanel()
self.panel?.delegate = self
self.panel?.title = "Export Find My Locations"
self.panel?.prompt = "Export"
self.panel?.nameFieldLabel = "Find My Locations"
self.panel?.nameFieldStringValue = "findMyLocations.plist"
self.panel?.allowedFileTypes = ["plist"]
self.panel = NSSavePanel()
self.panel?.delegate = self
self.panel?.title = "Export Find My Locations"
self.panel?.prompt = "Export"
self.panel?.nameFieldLabel = "Find My Locations"
self.panel?.nameFieldStringValue = "findMyLocations.plist"
self.panel?.allowedFileTypes = ["plist"]
let result = self.panel?.runModal()
if result == NSApplication.ModalResponse.OK {
// Save file
let fileURL = self.panel?.url
try! self.fileToSave?.write(to: fileURL!)
}
let result = self.panel?.runModal()
if result == NSApplication.ModalResponse.OK {
// Save file
let fileURL = self.panel?.url
try! self.fileToSave?.write(to: fileURL!)
}
func panel(_ sender: Any, userEnteredFilename filename: String, confirmed okFlag: Bool) -> String? {
guard okFlag else {return nil}
}
return filename
}
func panel(_ sender: Any, userEnteredFilename filename: String, confirmed okFlag: Bool) -> String?
{
guard okFlag else { return nil }
return filename
}
}

View File

@@ -8,34 +8,34 @@
//
import Cocoa
import SwiftUI
import CoreLocation
import SwiftUI
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
var window: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
// Create the window and set the content view.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.center()
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
}
// Create the window and set the content view.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.center()
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}

View File

@@ -7,84 +7,89 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import SwiftUI
import OSLog
import SwiftUI
struct ContentView: View {
@State var keysInfo: String?
@State var keysInfo: String?
var body: some View {
ZStack {
VStack {
Spacer()
var body: some View {
ZStack {
VStack {
Spacer()
self.infoText
.padding()
self.infoText
.padding()
Button(action: {
self.readPrivateKeys()
}, label: {
Text("Read private offline finding keys")
.font(.headline)
.foregroundColor(Color.black)
.padding()
.background(
RoundedRectangle(cornerRadius: 7.0)
.fill(Color(white: 7.0).opacity(0.7))
.shadow(color: Color.black, radius: 10.0, x: 0, y: 0)
)
Button(
action: {
self.readPrivateKeys()
},
label: {
Text("Read private offline finding keys")
.font(.headline)
.foregroundColor(Color.black)
.padding()
.background(
RoundedRectangle(cornerRadius: 7.0)
.fill(Color(white: 7.0).opacity(0.7))
.shadow(color: Color.black, radius: 10.0, x: 0, y: 0)
)
})
.buttonStyle(PlainButtonStyle())
self.keysInfo.map { (keysInfo) in
Text(keysInfo)
.padding()
}
Spacer()
}
}
)
.buttonStyle(PlainButtonStyle())
self.keysInfo.map { (keysInfo) in
Text(keysInfo)
.padding()
}
.frame(width: 800, height: 600)
Spacer()
}
}
.frame(width: 800, height: 600)
var infoText: some View {
// swiftlint:disable line_length
Text("This application demonstrates an exploit in macOS 10.15.0 - 10.15.6. It reads unprotected private key files that are used to locate lost devices using Apple's Offline Finding (Find My network). The application exports these key files for a demonstrative purpose. Used in the wild, an adversary would be able to download accurate location data of") +
Text(" all ").bold() +
Text("Apple devices of the current user.\n\n") +
Text("To download the location reports for the exported key files, please use the OFFetchReports app. In our adversary model this app would be placed on an adversary owned Mac while the OFReadKeys might be a benign looking app installed by any user.")
// swiftlint:enable line_length
}
var infoText: some View {
// swiftlint:disable line_length
Text(
"This application demonstrates an exploit in macOS 10.15.0 - 10.15.6. It reads unprotected private key files that are used to locate lost devices using Apple's Offline Finding (Find My network). The application exports these key files for a demonstrative purpose. Used in the wild, an adversary would be able to download accurate location data of"
) + Text(" all ").bold() + Text("Apple devices of the current user.\n\n")
+ Text(
"To download the location reports for the exported key files, please use the OFFetchReports app. In our adversary model this app would be placed on an adversary owned Mac while the OFReadKeys might be a benign looking app installed by any user."
)
// swiftlint:enable line_length
}
func readPrivateKeys() {
do {
let devices = try FindMyKeyExtractor.readPrivateKeys()
let numberOfKeys = devices.reduce(0, { $0 + $1.keys.count })
self.keysInfo = "Found \(numberOfKeys) key files from \(devices.count) devices."
self.saveExportedKeys(keys: devices)
} catch {
os_log(.error, "Could not load keys %@", error.localizedDescription)
}
}
func readPrivateKeys() {
do {
let devices = try FindMyKeyExtractor.readPrivateKeys()
let numberOfKeys = devices.reduce(0, {$0 + $1.keys.count})
self.keysInfo = "Found \(numberOfKeys) key files from \(devices.count) devices."
self.saveExportedKeys(keys: devices)
} catch {
os_log(.error, "Could not load keys %@", error.localizedDescription)
}
}
func saveExportedKeys(keys: [FindMyDevice]) {
do {
let keysPlist = try PropertyListEncoder().encode(keys)
SavePanel().saveFile(file: keysPlist, fileExtension: "plist")
} catch {
os_log(.error, "Property list encoding failed %@", error.localizedDescription)
}
func saveExportedKeys(keys: [FindMyDevice]) {
do {
let keysPlist = try PropertyListEncoder().encode(keys)
SavePanel().saveFile(file: keysPlist, fileExtension: "plist")
} catch {
os_log(.error, "Property list encoding failed %@", error.localizedDescription)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
static var previews: some View {
ContentView()
}
}

View File

@@ -7,219 +7,227 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import CryptoKit
import Foundation
import OSLog
struct FindMyKeyExtractor {
// swiftlint:disable identifier_name
// swiftlint:disable identifier_name
/// This function reads the private keys of the Offline Finding Location system. They will
/// - Throws: Error when accessing files fails
/// - Returns: Devices and their respective keys
static func readPrivateKeys() throws -> [FindMyDevice] {
var devices = [FindMyDevice]()
os_log(.debug, "Looking for keys")
/// This function reads the private keys of the Offline Finding Location system. They will
/// - Throws: Error when accessing files fails
/// - Returns: Devices and their respective keys
static func readPrivateKeys() throws -> [FindMyDevice] {
var devices = [FindMyDevice]()
os_log(.debug, "Looking for keys")
do {
// The key files have moved with macOS 10.15.4
let macOS10_15_3Devices = try self.readFromOldLocation()
devices.append(contentsOf: macOS10_15_3Devices)
} catch {
os_log(.error, "Did not find keys for 10.15.3\n%@", String(describing: error))
}
do {
// Tries to discover the new location of the keys
let macOS10_15_4Devices = try self.findKeyFilesInNewLocation()
devices.append(contentsOf: macOS10_15_4Devices)
} catch {
os_log(.error, "Did not find keys for 10.15.4\n%@", String(describing: error))
}
return devices
}
// MARK: - macOS 10.15.0 - 10.15.3
/// Reads the find my keys from the location used until macOS 10.15.3
/// - Throws: An error if the location is no longer available (e.g. in macOS 10.15.4)
/// - Returns: An array of find my devices including their keys
static func readFromOldLocation() throws -> [FindMyDevice] {
// Access the find my directory where the private advertisement keys are stored unencrypted
let directoryPath = "com.apple.icloud.searchpartyd/PrivateAdvertisementKeys/"
let fm = FileManager.default
let privateKeysPath = fm.urls(for: .libraryDirectory, in: .userDomainMask)
.first?.appendingPathComponent(directoryPath)
let folders = try fm.contentsOfDirectory(
at: privateKeysPath!,
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
guard folders.isEmpty == false else { throw FindMyError.noFoldersFound }
print("Found \(folders.count) folders")
var devices = [FindMyDevice]()
for folderURL in folders {
let keyFiles = try fm.contentsOfDirectory(
at: folderURL,
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
// Check if keys are available
print("Found \(keyFiles.count) in folder \(folderURL.lastPathComponent)")
guard keyFiles.isEmpty == false else { continue }
var device = FindMyDevice(deviceId: folderURL.lastPathComponent)
for url in keyFiles {
do {
// The key files have moved with macOS 10.15.4
let macOS10_15_3Devices = try self.readFromOldLocation()
devices.append(contentsOf: macOS10_15_3Devices)
if url.pathExtension == "keys" {
let keyPlist = try Data(contentsOf: url)
let keyInfo = try self.parseKeyFile(keyFile: keyPlist)
device.keys.append(keyInfo)
}
} catch {
os_log(.error, "Did not find keys for 10.15.3\n%@", String(describing: error))
print("Could not load key file ", error)
}
do {
// Tries to discover the new location of the keys
let macOS10_15_4Devices = try self.findKeyFilesInNewLocation()
devices.append(contentsOf: macOS10_15_4Devices)
} catch {
os_log(.error, "Did not find keys for 10.15.4\n%@", String(describing: error))
}
}
return devices
devices.append(device)
}
// MARK: - macOS 10.15.0 - 10.15.3
return devices
}
/// Reads the find my keys from the location used until macOS 10.15.3
/// - Throws: An error if the location is no longer available (e.g. in macOS 10.15.4)
/// - Returns: An array of find my devices including their keys
static func readFromOldLocation() throws -> [FindMyDevice] {
// Access the find my directory where the private advertisement keys are stored unencrypted
let directoryPath = "com.apple.icloud.searchpartyd/PrivateAdvertisementKeys/"
let fm = FileManager.default
let privateKeysPath = fm.urls(for: .libraryDirectory, in: .userDomainMask)
.first?.appendingPathComponent(directoryPath)
let folders = try fm.contentsOfDirectory(at: privateKeysPath!,
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
guard folders.isEmpty == false else {throw FindMyError.noFoldersFound}
print("Found \(folders.count) folders")
var devices = [FindMyDevice]()
for folderURL in folders {
let keyFiles = try fm.contentsOfDirectory(at: folderURL,
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
// Check if keys are available
print("Found \(keyFiles.count) in folder \(folderURL.lastPathComponent)")
guard keyFiles.isEmpty == false else {continue}
var device = FindMyDevice(deviceId: folderURL.lastPathComponent)
for url in keyFiles {
do {
if url.pathExtension == "keys" {
let keyPlist = try Data(contentsOf: url)
let keyInfo = try self.parseKeyFile(keyFile: keyPlist)
device.keys.append(keyInfo)
}
} catch {
print("Could not load key file ", error)
}
}
devices.append(device)
}
return devices
/// Parses the key plist file used until macOS 10.15.3
/// - Parameter keyFile: Propery list data
/// - Returns: Find My private Key
static func parseKeyFile(keyFile: Data) throws -> FindMyKey {
guard
let keyDict = try PropertyListSerialization.propertyList(
from: keyFile,
options: .init(), format: nil) as? [String: Any],
let advertisedKey = keyDict["A"] as? Data,
let privateKey = keyDict["PR"] as? Data,
let timeValues = keyDict["D"] as? [Double],
let pu = keyDict["PU"] as? Data
else {
throw FindMyError.parsingFailed
}
/// Parses the key plist file used until macOS 10.15.3
/// - Parameter keyFile: Propery list data
/// - Returns: Find My private Key
static func parseKeyFile(keyFile: Data) throws -> FindMyKey {
guard let keyDict = try PropertyListSerialization.propertyList(from: keyFile,
options: .init(), format: nil) as? [String: Any],
let advertisedKey = keyDict["A"] as? Data,
let privateKey = keyDict["PR"] as? Data,
let timeValues = keyDict["D"] as? [Double],
let pu = keyDict["PU"] as? Data
else {
throw FindMyError.parsingFailed
}
let hashedKeyDigest = SHA256.hash(data: advertisedKey)
let hashedKey = Data(hashedKeyDigest)
let time = Date(timeIntervalSinceReferenceDate: timeValues[0])
let duration = timeValues[1]
let hashedKeyDigest = SHA256.hash(data: advertisedKey)
let hashedKey = Data(hashedKeyDigest)
let time = Date(timeIntervalSinceReferenceDate: timeValues[0])
let duration = timeValues[1]
return FindMyKey(
advertisedKey: advertisedKey,
hashedKey: hashedKey,
privateKey: privateKey,
startTime: time,
duration: duration,
pu: pu,
yCoordinate: nil,
fullKey: nil)
}
return FindMyKey(advertisedKey: advertisedKey,
hashedKey: hashedKey,
privateKey: privateKey,
startTime: time,
duration: duration,
pu: pu,
yCoordinate: nil,
fullKey: nil)
}
// MARK: - macOS 10.15.4 - 10.15.6 (+ Big Sur 11.0 Betas)
// MARK: - macOS 10.15.4 - 10.15.6 (+ Big Sur 11.0 Betas)
/// Find the randomized key folder which is used since macOS 10.15.4
/// - Returns: Returns an array of urls that contain keys. Multiple folders are found if the mac has multiple users
static func findRamdomKeyFolder() -> [URL] {
os_log(.debug, "Searching for cached keys folder")
var folderURLs = [URL]()
let foldersPath = "/private/var/folders/"
let fm = FileManager.default
/// Find the randomized key folder which is used since macOS 10.15.4
/// - Returns: Returns an array of urls that contain keys. Multiple folders are found if the mac has multiple users
static func findRamdomKeyFolder() -> [URL] {
os_log(.debug, "Searching for cached keys folder")
var folderURLs = [URL]()
let foldersPath = "/private/var/folders/"
let fm = FileManager.default
func recursiveSearch(from url: URL, urlArray: inout [URL]) {
do {
let randomSubfolders = try fm.contentsOfDirectory(at: url,
includingPropertiesForKeys: nil,
options: .includesDirectoriesPostOrder)
for folder in randomSubfolders {
if folder.lastPathComponent == "com.apple.icloud.searchpartyd" {
urlArray.append(folder.appendingPathComponent("Keys"))
os_log(.debug, "Found folder at: %@", folder.path)
break
} else {
recursiveSearch(from: folder, urlArray: &urlArray)
}
}
} catch {
}
func recursiveSearch(from url: URL, urlArray: inout [URL]) {
do {
let randomSubfolders = try fm.contentsOfDirectory(
at: url,
includingPropertiesForKeys: nil,
options: .includesDirectoriesPostOrder)
for folder in randomSubfolders {
if folder.lastPathComponent == "com.apple.icloud.searchpartyd" {
urlArray.append(folder.appendingPathComponent("Keys"))
os_log(.debug, "Found folder at: %@", folder.path)
break
} else {
recursiveSearch(from: folder, urlArray: &urlArray)
}
}
recursiveSearch(from: URL(fileURLWithPath: foldersPath), urlArray: &folderURLs)
} catch {
return folderURLs
}
}
/// Find the key files in macOS 10.15.4 and newer (not working with fixed version 10.15.6)
/// - Throws: An error if the key folder cannot be fould
/// - Returns: An array of devices including their keys
static func findKeyFilesInNewLocation() throws -> [FindMyDevice] {
let keysFolders = self.findRamdomKeyFolder()
guard keysFolders.isEmpty == false else {
throw NSError(domain: "error", code: NSNotFound, userInfo: nil)
}
recursiveSearch(from: URL(fileURLWithPath: foldersPath), urlArray: &folderURLs)
var devices = [FindMyDevice]()
for folder in keysFolders {
if let deviceKeys = try? self.loadNewKeyFilesIn(directory: folder) {
devices.append(contentsOf: deviceKeys)
}
}
return folderURLs
return devices
}
/// Find the key files in macOS 10.15.4 and newer (not working with fixed version 10.15.6)
/// - Throws: An error if the key folder cannot be fould
/// - Returns: An array of devices including their keys
static func findKeyFilesInNewLocation() throws -> [FindMyDevice] {
let keysFolders = self.findRamdomKeyFolder()
guard keysFolders.isEmpty == false else {
throw NSError(domain: "error", code: NSNotFound, userInfo: nil)
}
/// Load the keys fils in the passed directory
/// - Parameter directory: Pass a directory url to a location with key files
/// - Throws: An error if the keys could not be found
/// - Returns: An array of devices including their keys
static func loadNewKeyFilesIn(directory: URL) throws -> [FindMyDevice] {
os_log(.debug, "Loading key files from %@", directory.path)
let fm = FileManager.default
let subDirectories = try fm.contentsOfDirectory(at: directory,
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
var devices = [FindMyDevice]()
for folder in keysFolders {
if let deviceKeys = try? self.loadNewKeyFilesIn(directory: folder) {
devices.append(contentsOf: deviceKeys)
}
}
var devices = [FindMyDevice]()
return devices
}
for deviceDirectory in subDirectories {
do {
var keyFiles = [Data]()
let keyDirectory = deviceDirectory.appendingPathComponent("Primary")
let keyFileURLs = try fm.contentsOfDirectory(at: keyDirectory,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles)
for keyfileURL in keyFileURLs {
// Read the key files
let keyFile = try Data(contentsOf: keyfileURL)
if keyFile.isEmpty == false {
keyFiles.append(keyFile)
}
}
/// Load the keys fils in the passed directory
/// - Parameter directory: Pass a directory url to a location with key files
/// - Throws: An error if the keys could not be found
/// - Returns: An array of devices including their keys
static func loadNewKeyFilesIn(directory: URL) throws -> [FindMyDevice] {
os_log(.debug, "Loading key files from %@", directory.path)
let fm = FileManager.default
let subDirectories = try fm.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
// Decode keys for file
let decoder = FindMyKeyDecoder()
var decodedKeys = [FindMyKey]()
for file in keyFiles {
do {
let fmKeys = try decoder.parse(keyFile: file)
decodedKeys.append(contentsOf: fmKeys)
} catch {
os_log(.error, "Decoding keys failed %@", error.localizedDescription)
}
}
var devices = [FindMyDevice]()
let device = FindMyDevice(deviceId: deviceDirectory.lastPathComponent, keys: decodedKeys)
devices.append(device)
} catch {
os_log(.error, "Key directory not found %@", error.localizedDescription)
}
for deviceDirectory in subDirectories {
do {
var keyFiles = [Data]()
let keyDirectory = deviceDirectory.appendingPathComponent("Primary")
let keyFileURLs = try fm.contentsOfDirectory(
at: keyDirectory,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles)
for keyfileURL in keyFileURLs {
// Read the key files
let keyFile = try Data(contentsOf: keyfileURL)
if keyFile.isEmpty == false {
keyFiles.append(keyFile)
}
}
return devices
// Decode keys for file
let decoder = FindMyKeyDecoder()
var decodedKeys = [FindMyKey]()
for file in keyFiles {
do {
let fmKeys = try decoder.parse(keyFile: file)
decodedKeys.append(contentsOf: fmKeys)
} catch {
os_log(.error, "Decoding keys failed %@", error.localizedDescription)
}
}
let device = FindMyDevice(deviceId: deviceDirectory.lastPathComponent, keys: decodedKeys)
devices.append(device)
} catch {
os_log(.error, "Key directory not found %@", error.localizedDescription)
}
}
return devices
}
}

View File

@@ -7,38 +7,38 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import Combine
import CryptoKit
import Foundation
struct FindMyDevice: Codable {
let deviceId: String
var keys = [FindMyKey]()
let deviceId: String
var keys = [FindMyKey]()
}
struct FindMyKey: Codable {
/// The advertising key
let advertisedKey: Data
/// Hashed advertisement key using SHA256
let hashedKey: Data
/// The private key from which the advertisement keys can be derived
let privateKey: Data
/// When this key was used to send out BLE advertisements
let startTime: Date?
/// Duration from start time how long the key has been used to send out BLE advertisements
let duration: Double?
/// The advertising key
let advertisedKey: Data
/// Hashed advertisement key using SHA256
let hashedKey: Data
/// The private key from which the advertisement keys can be derived
let privateKey: Data
/// When this key was used to send out BLE advertisements
let startTime: Date?
/// Duration from start time how long the key has been used to send out BLE advertisements
let duration: Double?
// swiftlint:disable identifier_name
/// ?
let pu: Data?
// swiftlint:disable identifier_name
/// ?
let pu: Data?
/// As exported from Big Sur
let yCoordinate: Data?
/// As exported from BigSur
let fullKey: Data?
/// As exported from Big Sur
let yCoordinate: Data?
/// As exported from BigSur
let fullKey: Data?
}
enum FindMyError: Error {
case noFoldersFound
case parsingFailed
case noFoldersFound
case parsingFailed
}

View File

@@ -7,40 +7,41 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import AppKit
import Foundation
class SavePanel: NSObject, NSOpenSavePanelDelegate {
static let shared = SavePanel()
static let shared = SavePanel()
var fileToSave: Data?
var fileExtension: String?
var panel: NSSavePanel?
var fileToSave: Data?
var fileExtension: String?
var panel: NSSavePanel?
func saveFile(file: Data, fileExtension: String) {
self.fileToSave = file
self.fileExtension = fileExtension
func saveFile(file: Data, fileExtension: String) {
self.fileToSave = file
self.fileExtension = fileExtension
self.panel = NSSavePanel()
self.panel?.delegate = self
self.panel?.title = "Export Find My Keys"
self.panel?.prompt = "Export"
self.panel?.nameFieldLabel = "Offline Keys Plist"
self.panel?.nameFieldStringValue = "OfflineFindingKeys.plist"
self.panel?.allowedFileTypes = ["plist"]
self.panel = NSSavePanel()
self.panel?.delegate = self
self.panel?.title = "Export Find My Keys"
self.panel?.prompt = "Export"
self.panel?.nameFieldLabel = "Offline Keys Plist"
self.panel?.nameFieldStringValue = "OfflineFindingKeys.plist"
self.panel?.allowedFileTypes = ["plist"]
self.panel?.begin(completionHandler: { (response) in
if response == .OK {
// Save the file in a cache directory
let fileURL = self.panel?.url
try? self.fileToSave?.write(to: fileURL!)
}
})
self.panel?.begin(completionHandler: { (response) in
if response == .OK {
// Save the file in a cache directory
let fileURL = self.panel?.url
try? self.fileToSave?.write(to: fileURL!)
}
})
}
}
func panel(_ sender: Any, userEnteredFilename filename: String, confirmed okFlag: Bool) -> String? {
return filename
}
func panel(_ sender: Any, userEnteredFilename filename: String, confirmed okFlag: Bool) -> String?
{
return filename
}
}

3
Firmware/ESP32/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"idf.port": "/dev/cu.usbserial-0001"
}

View File

@@ -36,7 +36,7 @@ These files are required for the next step: Deploy the firmware.
Use the `flash_esp32.sh` script to deploy the firmware and a public key to an ESP32 device connected to your local machine:
```bash
./flash_esp32.sh -p /dev/yourSerialPort "public-key-in-base64"
./flash_esp32.sh -p /dev/yourSerialPort "Base64-encoded advertisement key"
```
> **Note:** You might need to reset your device after running the script before it starts sending advertisements.

View File

@@ -1,5 +1,10 @@
#!/bin/bash
cleanup() {
echo "cleanup ..."
rm "$KEYFILE"
}
# Directory of this script
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
@@ -127,13 +132,13 @@ fi
# Call esptool.py. Errors from here on are critical
set -e
trap cleanup INT TERM EXIT
# Clear NVM
esptool.py --after no_reset \
esptool.py --after no_reset --port "$PORT" \
erase_region 0x9000 0x5000
esptool.py --before no_reset --baud $BAUDRATE \
esptool.py --before no_reset --baud $BAUDRATE --port "$PORT" \
write_flash 0x1000 "$SCRIPT_DIR/build/bootloader/bootloader.bin" \
0x8000 "$SCRIPT_DIR/build/partition_table/partition-table.bin" \
0xe000 "$KEYFILE" \
0x10000 "$SCRIPT_DIR/build/openhaystack.bin"
rm "$KEYFILE"

View File

@@ -43,12 +43,12 @@ static esp_ble_adv_params_t ble_adv_params = {
// Minimum advertising interval for undirected and low duty cycle
// directed advertising. Range: 0x0020 to 0x4000 Default: N = 0x0800
// (1.28 second) Time = N * 0.625 msec Time Range: 20 ms to 10.24 sec
.adv_int_min = 0x00A0, // 100ms
.adv_int_min = 0x0640, // 1s
// Advertising max interval:
// Maximum advertising interval for undirected and low duty cycle
// directed advertising. Range: 0x0020 to 0x4000 Default: N = 0x0800
// (1.28 second) Time = N * 0.625 msec Time Range: 20 ms to 10.24 sec
.adv_int_max = 0x0140, // 200ms
.adv_int_max = 0x0C80, // 2s
// Advertisement type
.adv_type = ADV_TYPE_NONCONN_IND,
// Use the random address

View File

@@ -0,0 +1,19 @@
# OpenHaystack HCI Script for Linux
This script enables Linux devices to send out Bluetooth Low Energy advertisements such that they can be found by [Apple's Find My network](https://developer.apple.com/find-my/).
## Disclaimer
Note that the script is just a proof-of-concept and currently only implements advertising a single static key. This means that **devices running this script are trackable** by other devices in proximity.
## Requirements
The script requires a Linux machine with a Bluetooth Low Energy radio chip, a Python environment, and `hcitool` installed. We tested it on a Raspberry Pi running the official Raspberry Pi OS.
## Usage
Our Python script uses HCI calls to configure Bluetooth advertising. You can copy the required `ADVERTISMENT_KEY` from the app by right-clicking on your accessory and selecting _Copy advertisement key (Base64)_. Then run the script:
```bash
sudo python3 HCI.py --key <ADVERTISMENT_KEY>
```

View File

@@ -1,7 +1,7 @@
PLATFORM := nRF51822
NRF51_SDK_PATH := $(shell pwd)/nrf51_sdk_v4_4_2_33551
NRF51_SDK_DOWNLOAD_URL := https://developer.nordicsemi.com/nRF5_SDK/nRF51_SDK_v4.x.x/nrf51_sdk_v4_4_2_33551.zip
OPENHAYSTACK_FIRMWARE_PATH := $(shell pwd)/../OpenHaystack/OpenHaystack/HaystackApp/firmware.bin
OPENHAYSTACK_FIRMWARE_PATH := $(shell pwd)/../../OpenHaystack/OpenHaystack/HaystackApp/Firmwares/Microbit/firmware.bin
export PLATFORM
export NRF51_SDK_PATH
@@ -10,7 +10,7 @@ ifeq ($(DEPLOY_PATH),)
DEPLOY_PATH := /Volumes/MICROBIT
endif
offline-finding/build/offline-finding.bin: $(NRF51_SDK_PATH) blessed/.git
offline-finding/build/offline-finding.bin: $(NRF51_SDK_PATH) blessed/.git offline-finding/main.c
$(MAKE) -C blessed
$(MAKE) -C offline-finding

View File

@@ -15,7 +15,7 @@
#include "ll.h"
#define ADV_INTERVAL LL_ADV_INTERVAL_MIN_NONCONN /* 100 ms */
#define ADV_INTERVAL 2000000 /* 2 s */
/* don't make `const` so we can replace key in compiled binary image */
static char public_key[28] = "OFFLINEFINDINGPUBLICKEYHERE!";

View File

@@ -51,6 +51,12 @@
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 */; };
78F8BB4C261C50EB00D9F37F /* LargeButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78F8BB4B261C50EB00D9F37F /* LargeButtonStyle.swift */; };
F126102F2600D1D80066A859 /* Slider+LogScale.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126102E2600D1D80066A859 /* Slider+LogScale.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 +156,12 @@
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>"; };
78F8BB4B261C50EB00D9F37F /* LargeButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeButtonStyle.swift; sourceTree = "<group>"; };
F126102E2600D1D80066A859 /* Slider+LogScale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Slider+LogScale.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 +327,7 @@
78EC226325DAE0BE0042B775 /* OpenHaystackTests.swift */,
78EC226525DAE0BE0042B775 /* Info.plist */,
78023CB025F7841F00B083EF /* MicrocontrollerTests.swift */,
F1647C1525FF6C61004144D6 /* BluetoothTests.swift */,
);
path = OpenHaystackTests;
sourceTree = "<group>";
@@ -322,6 +335,7 @@
78EC226E25DBC2FC0042B775 /* HaystackApp */ = {
isa = PBXGroup;
children = (
F12D5A5E25FA79D600CBBA09 /* Bluetooth */,
78023CAC25F7775300B083EF /* Firmwares */,
78286D3A25E4017400F65511 /* Mail Plugin */,
78EC227025DBC8BB0042B775 /* Views */,
@@ -331,6 +345,7 @@
787D8AC025DECD3C00148766 /* AccessoryController.swift */,
78023CAA25F7767000B083EF /* ESP32Controller.swift */,
7821DAD025F7B2C10054DC33 /* FileManager.swift */,
F1647C1A25FF7954004144D6 /* AccessoryNearbyMonitor.swift */,
);
path = HaystackApp;
sourceTree = "<group>";
@@ -347,6 +362,7 @@
78EC227025DBC8BB0042B775 /* Views */ = {
isa = PBXGroup;
children = (
78F8BB4A261C50D500D9F37F /* Styles */,
78286D7625E5114600F65511 /* ActivityIndicator.swift */,
78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */,
78486BEE25DD711E0007ED87 /* PopUpAlertView.swift */,
@@ -356,10 +372,28 @@
7851F1DC25EE90FA0049480D /* AccessoryMapView.swift */,
7821DAD225F7C39A0054DC33 /* ESP32InstallSheet.swift */,
78D9B80525F7CF60009B9CE8 /* ManageAccessoriesView.swift */,
F126102E2600D1D80066A859 /* Slider+LogScale.swift */,
);
path = Views;
sourceTree = "<group>";
};
78F8BB4A261C50D500D9F37F /* Styles */ = {
isa = PBXGroup;
children = (
78F8BB4B261C50EB00D9F37F /* LargeButtonStyle.swift */,
);
path = Styles;
sourceTree = "<group>";
};
F12D5A5E25FA79D600CBBA09 /* Bluetooth */ = {
isa = PBXGroup;
children = (
F12D5A5925FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift */,
F12D5A5F25FA79FA00CBBA09 /* Advertisement.swift */,
);
path = Bluetooth;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -518,7 +552,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if command -v swift-format >/dev/null; then\n swift-format lint -r \"$SRCROOT\"\nelse\n echo \"warning: swift-format not installed, download from https://github.com/apple/swift-format\"\nfi\n";
shellScript = "if command -v swift-format >/dev/null; then\n swift-format format -i -r \"$SRCROOT\"; swift-format lint -r \"$SRCROOT\"\nelse\n echo \"warning: swift-format not installed, download from https://github.com/apple/swift-format\"\nfi\n";
};
F14B2C7E25EFBB11002DC056 /* Set Version Number from Git */ = {
isa = PBXShellScriptBuildPhase;
@@ -575,6 +609,8 @@
78D9B80625F7CF60009B9CE8 /* ManageAccessoriesView.swift in Sources */,
78486BEF25DD711E0007ED87 /* PopUpAlertView.swift in Sources */,
78014A2925DC08580089F6D9 /* MicrobitController.swift in Sources */,
F126102F2600D1D80066A859 /* Slider+LogScale.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 +623,13 @@
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 */,
78F8BB4C261C50EB00D9F37F /* LargeButtonStyle.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 +648,7 @@
buildActionMask = 2147483647;
files = (
78023CB125F7841F00B083EF /* MicrocontrollerTests.swift in Sources */,
F1647C1625FF6C61004144D6 /* BluetoothTests.swift in Sources */,
78EC226425DAE0BE0042B775 /* OpenHaystackTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@@ -6,8 +6,8 @@
"repositoryURL": "https://github.com/apple/swift-crypto.git",
"state": {
"branch": null,
"revision": "9b9d1868601a199334da5d14f4ab2d37d4f8d0c5",
"version": "1.0.2"
"revision": "3bea268b223651c4ab7b7b9ad62ef9b2d4143eb6",
"version": "1.1.6"
}
},
{
@@ -15,8 +15,8 @@
"repositoryURL": "https://github.com/apple/swift-nio.git",
"state": {
"branch": null,
"revision": "6d3ca7e54e06a69d0f2612c2ce8bb8b7319085a4",
"version": "2.26.0"
"revision": "6aa9347d9bc5bbfe6a84983aec955c17ffea96ef",
"version": "2.33.0"
}
},
{
@@ -24,8 +24,8 @@
"repositoryURL": "https://github.com/apple/swift-nio-ssl",
"state": {
"branch": null,
"revision": "bbb38fbcbbe9dc4665b2c638dfa5681b01079bfb",
"version": "2.10.4"
"revision": "5e68c1ded15619bb281b273fa8c2d8fd7f7b2b7d",
"version": "2.16.1"
}
}
]

View File

@@ -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>

View File

@@ -0,0 +1,34 @@
{
"colors" : [
{
"color" : {
"color-space" : "gray-gamma-22",
"components" : {
"alpha" : "1.000",
"white" : "0.866"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "gray-gamma-22",
"components" : {
"alpha" : "0.758",
"white" : "0.310"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,34 @@
{
"colors" : [
{
"color" : {
"color-space" : "gray-gamma-22",
"components" : {
"alpha" : "1.000",
"white" : "0.657"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "gray-gamma-22",
"components" : {
"alpha" : "0.758",
"white" : "0.237"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -52,7 +52,11 @@
BIO_free(bio);
}
NSLog(@"Shared key: %@", [sharedKey base64EncodedStringWithOptions:0]);
// NSLog(@"Shared key: %@", [sharedKey base64EncodedStringWithOptions:0]);
//Free
EC_KEY_free(key);
EC_GROUP_free(curve);
EC_POINT_free(publicKey);
return sharedKey;
}
@@ -68,7 +72,7 @@
// Public key will be stored in point
int res = EC_POINT_oct2point(group, point, pointBytes.bytes, pointBytes.length, ctx);
[self printPoint:point withGroup:group];
// Free the big numbers
BN_CTX_free(ctx);
@@ -90,24 +94,28 @@
BN_CTX *ctx = BN_CTX_new();
BN_CTX_start(ctx);
// Read in the private key data
BIGNUM *privateKeyNum = BN_bin2bn(privateKeyData.bytes, privateKeyData.length, nil);
int res = EC_POINT_mul(group, point, privateKeyNum, nil, nil, ctx);
if (res != 1) {
NSLog(@"Failed");
return nil;
}
res = EC_KEY_set_public_key(key, point);
EC_POINT_free(point);
if (res != 1) {
NSLog(@"Failed");
return nil;
}
privateKeyNum = BN_bin2bn(privateKeyData.bytes, privateKeyData.length, nil);
EC_KEY_set_private_key(key, privateKeyNum);
// Free the big numbers
EC_KEY_set_private_key(key, privateKeyNum);
BN_free(privateKeyNum);
// Free
BN_CTX_free(ctx);
return key;
@@ -126,6 +134,10 @@
size_t size = EC_POINT_point2oct(curve, publicKey, POINT_CONVERSION_COMPRESSED, publicKeyBytes.mutableBytes, keySize, NULL);
//Free
EC_KEY_free(key);
EC_GROUP_free(curve);
if (size == 0) {
return nil;
}
@@ -145,7 +157,10 @@
NSMutableData *privateKeyBytes = [[NSMutableData alloc] initWithLength:keySize];
size_t size = BN_bn2bin(privateKey, privateKeyBytes.mutableBytes);
EC_KEY_free(key);
if (size == 0) {
return nil;
}

View File

@@ -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 {
@@ -37,7 +32,11 @@ class FindMyController: ObservableObject {
self.devices = devices
// Decrypt the reports with the imported keys
DispatchQueue.global(qos: .background).async {
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self = self else {
completion()
return
}
var d = self.devices
// Add the reports to the according device by finding the right key for the report
@@ -62,8 +61,8 @@ class FindMyController: ObservableObject {
}
// Decrypt the reports
self.decryptReports {
self.exportDevices()
self.decryptReports { [weak self] in
self?.exportDevices()
DispatchQueue.main.async {
completion()
}
@@ -102,11 +101,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))
@@ -118,7 +112,11 @@ class FindMyController: ObservableObject {
func fetchReports(with searchPartyToken: Data, completion: @escaping (Error?) -> Void) {
DispatchQueue.global(qos: .background).async {
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self = self else {
completion(FindMyErrors.objectReleased)
return
}
let fetchReportGroup = DispatchGroup()
let fetcher = ReportsFetcher()
@@ -176,7 +174,11 @@ class FindMyController: ObservableObject {
}
#endif
DispatchQueue.main.async {
DispatchQueue.main.async { [weak self] in
guard let self = self else {
completion(FindMyErrors.objectReleased)
return
}
self.devices = devices
self.decryptReports {
@@ -238,4 +240,5 @@ class FindMyController: ObservableObject {
enum FindMyErrors: Error {
case decodingPlistFailed(message: String)
case objectReleased
}

View File

@@ -9,30 +9,33 @@
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() {
self.selfObserver = self.objectWillChange.sink { _ in
self.selfObserver = self.objectWillChange.sink { [weak self] _ in
// objectWillChange is called before the values are actually changed,
// so we dispatch the call to save()
DispatchQueue.main.async {
self.initObserver()
try? self.save()
DispatchQueue.main.async { [weak self] in
self?.initObserver()
try? self?.save()
}
}
}
@@ -42,7 +45,7 @@ class AccessoryController: ObservableObject {
$0.cancel()
})
self.accessories.forEach({
let c = $0.objectWillChange.sink(receiveValue: { self.objectWillChange.send() })
let c = $0.objectWillChange.sink(receiveValue: { [weak self] in self?.objectWillChange.send() })
// Important: You have to keep the returned value allocated,
// otherwise the sink subscription gets cancelled
self.listElementsObserver.append(c)
@@ -66,6 +69,7 @@ class AccessoryController: ObservableObject {
accessory.lastLocation = report?.location
accessory.locationTimestamp = report?.timestamp
accessory.locations = device.decryptedReports
}
}
}
@@ -88,6 +92,111 @@ 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 { [weak self] result in
guard let self = self else {
completion(.failure(.noReportsFound))
return
}
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) { [weak self] 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 {

View File

@@ -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) { [weak self] _ 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
}
}
}

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ struct ESP32Controller {
Bundle.main.resourceURL?.appendingPathComponent("ESP32")
}
/// Tries to find the port / path at which the ESP32 module is attached
/// Tries to find the port / path at which the ESP32 module is attached.
static func findPort() -> [URL] {
// List all ports
let ports = try? FileManager.default.contentsOfDirectory(atPath: "/dev").filter({ $0.contains("cu.") })
@@ -24,7 +24,7 @@ struct ESP32Controller {
return portURLs ?? []
}
/// Runs the script to flash the firmware on an ESP32
/// Runs the script to flash the firmware on an ESP32.
static func flashToESP32(accessory: Accessory, port: URL, completion: @escaping (Result<Void, Error>) -> Void) throws {
// Copy firmware to a temporary directory

View File

@@ -30,8 +30,14 @@ extension FileManager {
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))
do {
// Copy file
try self.createFile(atPath: to.appendingPathComponent(file).path, contents: Data(contentsOf: fileURL), attributes: nil)
} catch {
if fileURL.lastPathComponent != "CodeResources" {
throw error
}
}
}
}
}

View File

@@ -1,139 +0,0 @@
#!/bin/bash
# Directory of this script
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
# Defaults: Directory for the virtual environment
VENV_DIR="$SCRIPT_DIR/venv"
# Defaults: Serial port to access the ESP32
PORT=/dev/ttyS0
# Defaults: Fast baud rate
BAUDRATE=921600
# Parameter parsing
while [[ $# -gt 0 ]]; do
KEY="$1"
case "$KEY" in
-p|--port)
PORT="$2"
shift
shift
;;
-s|--slow)
BAUDRATE=115200
shift
;;
-v|--venvdir)
VENV_DIR="$2"
shift
shift
;;
-h|--help)
echo "flash_esp32.sh - Flash the OpenHaystack firmware onto an ESP32 module"
echo ""
echo " This script will create a virtual environment for the required tools."
echo ""
echo "Call: flash_esp32.sh [-p <port>] [-v <dir>] [-s] PUBKEY"
echo ""
echo "Required Arguments:"
echo " PUBKEY"
echo " The base64-encoded public key"
echo ""
echo "Optional Arguments:"
echo " -h, --help"
echo " Show this message and exit."
echo " -p, --port <port>"
echo " Specify the serial interface to which the device is connected."
echo " -s, --slow"
echo " Use 115200 instead of 921600 baud when flashing."
echo " Might be required for long/bad USB cables or slow USB-to-Serial converters."
echo " -v, --venvdir <dir>"
echo " Select Python virtual environment with esptool installed."
echo " If the directory does not exist, it will be created."
exit 1
;;
*)
if [[ -z "$PUBKEY" ]]; then
PUBKEY="$1"
shift
else
echo "Got unexpected parameter $1"
exit 1
fi
;;
esac
done
# Sanity check: Pubkey exists
if [[ -z "$PUBKEY" ]]; then
echo "Missing public key, call with --help for usage"
exit 1
fi
# Sanity check: Port
if [[ ! -e "$PORT" ]]; then
echo "$PORT does not exist, please specify a valid serial interface with the -p argument"
exit 1
fi
# Setup the virtual environment
if [[ ! -d "$VENV_DIR" ]]; then
# Create the virtual environment
PYTHON="$(which python3)"
if [[ -z "$PYTHON" ]]; then
PYTHON="$(which python)"
fi
if [[ -z "$PYTHON" ]]; then
echo "Could not find a Python installation, please install Python 3."
exit 1
fi
if ! ($PYTHON -V 2>&1 | grep "Python 3" > /dev/null); then
echo "Executing \"$PYTHON\" does not run Python 3, please make sure that python3 or python on your PATH points to Python 3"
exit 1
fi
if ! ($PYTHON -c "import venv" &> /dev/null); then
echo "Python 3 module \"venv\" was not found."
exit 1
fi
$PYTHON -m venv "$VENV_DIR"
if [[ $? != 0 ]]; then
echo "Creating the virtual environment in $VENV_DIR failed."
exit 1
fi
source "$VENV_DIR/bin/activate"
pip install --upgrade pip
pip install esptool
if [[ $? != 0 ]]; then
echo "Could not install Python 3 module esptool in $VENV_DIR";
exit 1
fi
else
source "$VENV_DIR/bin/activate"
fi
# Prepare the key
KEYFILE="$SCRIPT_DIR/tmp.key"
if [[ -f "$KEYFILE" ]]; then
echo "$KEYFILE already exists, stopping here not to override files..."
exit 1
fi
echo "$PUBKEY" | python3 -m base64 -d - > "$KEYFILE"
if [[ $? != 0 ]]; then
echo "Could not parse the public key. Please provide valid base64 input"
exit 1
fi
# Call esptool.py. Errors from here on are critical
set -e
# Clear NVM
esptool.py --after no_reset \
erase_region 0x9000 0x5000
esptool.py --before no_reset --baud $BAUDRATE \
write_flash 0x1000 "$SCRIPT_DIR/build/bootloader/bootloader.bin" \
0x8000 "$SCRIPT_DIR/build/partition_table/partition-table.bin" \
0xe000 "$KEYFILE" \
0x10000 "$SCRIPT_DIR/build/openhaystack.bin"
rm "$KEYFILE"

Binary file not shown.

View File

@@ -20,8 +20,36 @@ struct MailPluginManager {
let pluginURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mail/Bundles").appendingPathComponent(mailBundleName + ".mailbundle")
let localPluginURL = Bundle.main.url(forResource: mailBundleName, withExtension: "mailbundle")!
var isMailPluginInstalled: Bool {
return FileManager.default.fileExists(atPath: pluginURL.path)
//Check if the plug-in is compatible by comparing the IDs
guard FileManager.default.fileExists(atPath: pluginURL.path) else {
return false
}
let infoPlistURL = pluginURL.appendingPathComponent("Contents/Info.plist")
let localInfoPlistURL = localPluginURL.appendingPathComponent("Contents/Info.plist")
guard let infoPlistData = try? Data(contentsOf: infoPlistURL),
let infoPlistDict = try? PropertyListSerialization.propertyList(from: infoPlistData, options: [], format: nil) as? [String: AnyHashable],
let localInfoPlistData = try? Data(contentsOf: localInfoPlistURL),
let localInfoPlistDict = try? PropertyListSerialization.propertyList(from: localInfoPlistData, options: [], format: nil) as? [String: AnyHashable]
else { return false }
//Compare the supported plug-ins
let uuidEntries = localInfoPlistDict.keys.filter({ $0.contains("PluginCompatibilityUUIDs") })
for uuidEntry in uuidEntries {
guard let localEntry = localInfoPlistDict[uuidEntry] as? [String],
let installedEntry = infoPlistDict[uuidEntry] as? [String]
else { return false }
if localEntry != installedEntry {
return false
}
}
return true
}
/// Shows a NSSavePanel to install the mail plugin at the required place.
@@ -58,9 +86,8 @@ struct MailPluginManager {
throw PluginError.permissionNotGranted
}
let localPluginURL = Bundle.main.url(forResource: mailBundleName, withExtension: "mailbundle")!
do {
// Create the Bundles folder if necessary
try FileManager.default.createDirectory(at: pluginsFolderURL, withIntermediateDirectories: true, attributes: nil)
} catch {
print(error.localizedDescription)

View File

@@ -74,10 +74,8 @@ struct MicrobitController {
return patchedFirmware
}
static func deploy(accessory: Accessory) throws {
let microbits = try MicrobitController.findMicrobits()
guard let microBitURL = microbits.first,
let firmwareURL = Bundle.main.url(forResource: "firmware", withExtension: "bin")
static func patchFirmware(for accessory: Accessory) throws -> Data {
guard let firmwareURL = Bundle.main.url(forResource: "firmware", withExtension: "bin")
else {
throw FirmwareFlashError.notFound
}
@@ -87,6 +85,18 @@ struct MicrobitController {
let publicKey = try accessory.getAdvertisementKey()
let patchedFirmware = try MicrobitController.patchFirmware(firmware, pattern: pattern, with: publicKey)
return patchedFirmware
}
static func deploy(accessory: Accessory) throws {
let microbits = try MicrobitController.findMicrobits()
guard let microBitURL = microbits.first
else {
throw FirmwareFlashError.notFound
}
let patchedFirmware = try self.patchFirmware(for: accessory)
try MicrobitController.deployToMicrobit(microBitURL, firmwareFile: patchedFirmware)
}

View File

@@ -31,11 +31,30 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
@Published var name: String
let id: Int
let privateKey: Data
@Published var locations: [FindMyLocationReport]?
@Published var color: Color
@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 +75,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 +95,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 +175,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
case colorSpaceName
case icon
case isDeployed
case isActive
}
static func == (lhs: Accessory, rhs: Accessory) -> Bool {

View File

@@ -19,10 +19,10 @@ struct PreviewData {
static let latitude: Double = 49.878046
static let longitude: Double = 8.656993
static func randomLocation() -> CLLocation {
static func randomLocation(lat: Double = latitude, lng: Double = longitude, distance: Double = 0.005) -> CLLocation {
return CLLocation(
latitude: latitude + Double.random(in: 0..<0.005) * (Bool.random() ? -1 : 1),
longitude: longitude + Double.random(in: 0..<0.005) * (Bool.random() ? -1 : 1)
latitude: lat + Double.random(in: 0..<distance) * (Bool.random() ? -1 : 1),
longitude: lng + Double.random(in: 0..<distance) * (Bool.random() ? -1 : 1)
)
}
@@ -35,6 +35,18 @@ struct PreviewData {
accessory.lastLocation = randomLocation()
accessory.locationTimestamp = randomTimestamp()
accessory.isDeployed = true
accessory.isActive = true
accessory.isNearby = Bool.random()
//Generate recent locations
let startDate = Date().addingTimeInterval(-60 * 60 * 24)
var date = startDate
var locations: [FindMyLocationReport] = []
while date < Date() {
let location = randomLocation(lat: accessory.lastLocation!.coordinate.latitude, lng: accessory.lastLocation!.coordinate.longitude, distance: 0.0005)
locations.append(FindMyLocationReport(lat: location.coordinate.latitude, lng: location.coordinate.longitude, acc: 10, dP: date, t: date, c: 0))
date += 30 * 60
}
accessory.locations = locations
return accessory
}

View File

@@ -60,15 +60,23 @@ struct AccessoryListEntry: View {
label: { Text("Deploy") }
)
}
Circle()
.fill(accessory.isNearby ? Color.green : accessory.isActive ? Color.orange : Color.red)
.frame(width: 8, height: 8)
}
.listRowBackground(Color.clear)
.padding(EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0))
.contextMenu {
Button("Delete", action: { self.delete(accessory) })
Divider()
Button("Rename", action: { self.editingName = true })
Divider()
Button("Copy advertisment key (Base64)", action: { self.copyPublicKey(of: accessory) })
Button("Copy key ID (Base64)", action: { self.copyPublicKeyHash(of: accessory) })
Menu("Copy advertisement key") {
Button("Base64", action: { self.copyAdvertisementKeyB64(of: accessory) })
Button("Byte array", action: { self.copyAdvertisementKey(escapedString: false) })
Button("Escaped string", action: { self.copyAdvertisementKey(escapedString: true) })
}
Divider()
Button("Mark as \(accessory.isDeployed ? "deployable" : "deployed")", action: { accessory.isDeployed.toggle() })
}
@@ -86,6 +94,18 @@ struct AccessoryListEntry: View {
}
}
func copyAdvertisementKeyB64(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()
@@ -98,6 +118,28 @@ struct AccessoryListEntry: View {
}
}
func copyAdvertisementKey(escapedString: Bool) {
do {
let publicKey = try self.accessory.getAdvertisementKey()
let keyByteArray = [UInt8](publicKey)
if escapedString {
let string = keyByteArray.map { "\\x\(String($0, radix: 16))" }.joined()
let pasteboard = NSPasteboard.general
pasteboard.prepareForNewContents(with: .currentHostOnly)
pasteboard.setString(string, forType: .string)
} else {
let string = keyByteArray.map { "0x\(String($0, radix: 16))" }.joined(separator: ", ")
let pasteboard = NSPasteboard.general
pasteboard.prepareForNewContents(with: .currentHostOnly)
pasteboard.setString(string, forType: .string)
}
} catch {
os_log("Failed extracing public key %@", String(describing: error))
assert(false)
}
}
struct AccessoryListEntry_Previews: PreviewProvider {
@StateObject static var accessory = PreviewData.accessories.first!
@State static var alertType: OpenHaystackMainView.AlertType?

View File

@@ -45,7 +45,8 @@ class AccessoryAnnotationView: MKAnnotationView {
func updateView() {
guard let accessory = (self.annotation as? AccessoryAnnotation)?.accessory else { return }
self.pinView?.removeFromSuperview()
self.pinView = NSHostingView(rootView: AccessoryPinView(accessory: accessory))
self.pinView = nil
self.pinView = NSHostingView(rootView: AccessoryPinView(accessory: accessory)) // TODO: LEAK! This view is not release properly
self.addSubview(pinView!)
@@ -103,3 +104,11 @@ class AccessoryAnnotation: NSObject, MKAnnotation {
self.accessory = accessory
}
}
class AccessoryHistoryAnnotation: NSObject, MKAnnotation {
var coordinate: CLLocationCoordinate2D
init(coordinate: CLLocationCoordinate2D) {
self.coordinate = coordinate
}
}

View File

@@ -14,7 +14,10 @@ import SwiftUI
struct AccessoryMapView: NSViewControllerRepresentable {
@ObservedObject var accessoryController: AccessoryController
@Binding var mapType: MKMapType
var focusedAccessory: Accessory?
@Binding var focusedAccessory: Accessory?
@Binding var showHistory: Bool
@Binding var showPastHistory: TimeInterval
var delayer = UpdateDelayer()
func makeNSViewController(context: Context) -> MapViewController {
return MapViewController(nibName: NSNib.Name("MapViewController"), bundle: nil)
@@ -23,8 +26,30 @@ struct AccessoryMapView: NSViewControllerRepresentable {
func updateNSViewController(_ nsViewController: MapViewController, context: Context) {
let accessories = self.accessoryController.accessories
nsViewController.zoom(on: focusedAccessory)
nsViewController.addLastLocations(from: accessories)
nsViewController.focusedAccessory = focusedAccessory
if showHistory {
delayer.delayUpdate {
nsViewController.addAllLocations(from: focusedAccessory!, past: showPastHistory)
nsViewController.zoomInOnAll()
}
} else {
nsViewController.addLastLocations(from: accessories)
nsViewController.zoomInOnSelection()
}
nsViewController.changeMapType(mapType)
}
}
class UpdateDelayer {
/// Some view updates need to be delayed to mitigate UI glitches.
var delayedWorkItem: DispatchWorkItem?
func delayUpdate(delay: Double = 0.3, closure: @escaping () -> Void) {
self.delayedWorkItem?.cancel()
let workItem = DispatchWorkItem {
closure()
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
self.delayedWorkItem = workItem
}
}

View File

@@ -8,6 +8,7 @@
//
import SwiftUI
import os
struct ManageAccessoriesView: View {
@@ -21,6 +22,9 @@ struct ManageAccessoriesView: View {
@Binding var focusedAccessory: Accessory?
@Binding var accessoryToDeploy: Accessory?
@Binding var showESP32DeploySheet: Bool
@State var sheetShown: SheetType?
@State var showMailPopup = false
var body: some View {
VStack {
@@ -38,20 +42,21 @@ struct ManageAccessoriesView: View {
}
}
.toolbar(content: {
Spacer()
Button(action: self.addAccessory) {
Label("Add accessory", systemImage: "plus")
}
self.toolbarView
})
.sheet(
isPresented: self.$showESP32DeploySheet,
content: {
.sheet(item: self.$sheetShown) { sheetType in
switch sheetType {
case .esp32Install:
ESP32InstallSheet(accessory: self.$accessoryToDeploy, alertType: self.$alertType)
})
case .deployFirmware:
self.selectTargetView
}
}
}
/// Accessory List view.
var accessoryList: some View {
List(self.accessories, id: \.self, selection: $focusedAccessory) { accessory in
AccessoryListEntry(
accessory: accessory,
@@ -70,12 +75,92 @@ struct ManageAccessoriesView: View {
alertType: self.$alertType,
delete: self.delete(accessory:),
deployAccessoryToMicrobit: self.deploy(accessory:),
zoomOn: { self.focusedAccessory = $0 })
zoomOn: { self.focusedAccessory = $0 }
)
}
.listStyle(SidebarListStyle())
.listStyle(PlainListStyle())
}
/// 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")
}
}
var selectTargetView: some View {
VStack {
Text("Select target")
.font(.title)
Text("Please select to which device you want to deply")
.padding(.bottom, 4)
VStack {
Button(
"Micro:bit",
action: {
self.sheetShown = nil
if let accessory = self.accessoryToDeploy {
self.deployAccessoryToMicrobit(accessory: accessory)
}
}
)
.buttonStyle(LargeButtonStyle())
Button(
"Export Microbit firmware",
action: {
self.sheetShown = nil
if let accessory = self.accessoryToDeploy {
self.exportMicrobitFirmware(for: accessory)
}
}
)
.buttonStyle(LargeButtonStyle())
Button(
"ESP32",
action: {
self.sheetShown = .esp32Install
}
)
.buttonStyle(LargeButtonStyle())
Button(
"Cancel",
action: {
self.sheetShown = nil
}
)
.buttonStyle(LargeButtonStyle(destructive: true))
}
}
.padding()
}
/// Delete an accessory from the list of accessories.
func delete(accessory: Accessory) {
do {
@@ -87,7 +172,7 @@ struct ManageAccessoriesView: View {
func deploy(accessory: Accessory) {
self.accessoryToDeploy = accessory
self.alertType = .selectDepoyTarget
self.sheetShown = .deployFirmware
}
/// Add an accessory with the provided details.
@@ -99,6 +184,81 @@ 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
}
}
/// Deploy the public key of the accessory to a BBC microbit.
func deployAccessoryToMicrobit(accessory: Accessory) {
do {
try MicrobitController.deploy(accessory: accessory)
} catch {
os_log("Error occurred %@", String(describing: error))
self.alertType = .deployFailed
return
}
self.alertType = .deployedSuccessfully
accessory.isDeployed = true
self.accessoryToDeploy = nil
}
func exportMicrobitFirmware(for accessory: Accessory) {
do {
let firmware = try MicrobitController.patchFirmware(for: accessory)
let savePanel = NSSavePanel()
savePanel.allowedFileTypes = ["bin"]
savePanel.canCreateDirectories = true
savePanel.directoryURL = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
savePanel.message = "Export the micro:bit firmware"
savePanel.nameFieldLabel = "Firmware name"
savePanel.nameFieldStringValue = "openhaystack_firmware.bin"
savePanel.prompt = "Export"
savePanel.title = "Export firmware"
let result = savePanel.runModal()
if result == .OK,
let url = savePanel.url
{
// Store the accessory file
try firmware.write(to: url)
}
} catch {
os_log("Error occurred %@", String(describing: error))
self.alertType = .exportFailed
return
}
}
enum SheetType: Int, Identifiable {
var id: Int {
return self.rawValue
}
case esp32Install
case deployFirmware
}
}
struct ManageAccessoriesView_Previews: PreviewProvider {
@@ -113,3 +273,11 @@ struct ManageAccessoriesView_Previews: PreviewProvider {
ManageAccessoriesView(alertType: self.$alertType, focusedAccessory: self.$focussed, accessoryToDeploy: self.$deploy, showESP32DeploySheet: self.$showESPSheet)
}
}
//FIXME: This is a workaround, because the List with Default style (and clear background) started to crop the rows on macOS 11.3
extension NSTableView {
open override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
self.backgroundColor = .clear
}
}

View File

@@ -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?
@@ -29,7 +28,12 @@ struct OpenHaystackMainView: View {
@State var mapType: MKMapType = .standard
@State var isLoading = false
@State var focusedAccessory: Accessory?
@State var historyMapView = false
@State var historySeconds: TimeInterval = TimeInterval.Units.day.rawValue
@State var accessoryToDeploy: Accessory?
@State var showMailPlugInPopover = false
@State var mailPluginIsActive = false
@State var showESP32DeploySheet = false
@@ -43,11 +47,14 @@ 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)
.overlay(self.mapOverlay)
AccessoryMapView(
accessoryController: self.accessoryController, mapType: self.$mapType, focusedAccessory: self.$focusedAccessory, showHistory: self.$historyMapView,
showPastHistory: self.$historySeconds
)
.overlay(self.mapOverlay)
if self.popUpAlertType != nil {
VStack {
Spacer()
@@ -59,15 +66,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 +110,57 @@ struct OpenHaystackMainView: View {
}
}
/// All toolbar items shown.
var toolbarView: some View {
Group {
if self.historyMapView {
Text("\(TimeInterval(self.historySeconds).description)")
Slider<Text, EmptyView>.withLogScale(value: $historySeconds, in: 30 * TimeInterval.Units.minute.rawValue...TimeInterval.Units.week.rawValue) {
Text("Past time to show")
}
.frame(width: 80)
}
Toggle(isOn: $historyMapView) {
Label("Show location history", systemImage: "clock")
}
.disabled(self.focusedAccessory == nil)
Picker("", selection: self.$mapType) {
Text("Satellite").tag(MKMapType.hybrid)
Text("Standard").tag(MKMapType.standard)
}
.pickerStyle(SegmentedPickerStyle())
Button(
action: {
if !self.mailPluginIsActive {
self.showMailPlugInPopover.toggle()
self.checkPluginIsRunning(silent: true, nil)
} 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)
@@ -128,69 +178,50 @@ struct OpenHaystackMainView: View {
if pluginManager.isMailPluginInstalled == false {
// Install the mail plugin
self.alertType = .activatePlugin
self.checkPluginIsRunning(silent: true, nil)
} else {
self.checkPluginIsRunning(nil)
}
}
/// 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
}
self.isLoading = true
self.accessoryController.downloadLocationReports { result in
self.isLoading = false
switch result {
case .failure(let alert):
if alert == .noReportsFound {
self.popUpAlertType = .noReportsFound
} else {
if alert == .activatePlugin {
self.mailPluginIsActive = false
}
self.alertType = alert
}
withAnimation {
self.isLoading = false
}
case .success(_):
break
}
}
}
func deploy(accessory: Accessory) {
self.accessoryToDeploy = accessory
self.alertType = .selectDepoyTarget
}
var mailStatePopover: some View {
VStack {
HStack {
Image(systemName: "envelope")
.font(.title)
.foregroundColor(self.mailPluginIsActive ? .green : .red)
/// Deploy the public key of the accessory to a BBC microbit.
func deployAccessoryToMicrobit(accessory: Accessory) {
do {
try MicrobitController.deploy(accessory: accessory)
} catch {
os_log("Error occurred %@", String(describing: error))
self.alertType = .deployFailed
return
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")
}
}
.padding()
}
self.alertType = .deployedSuccessfully
self.accessoryToDeploy = nil
.frame(width: 250, height: 120)
}
/// Ask to install and activate the mail plugin.
@@ -210,7 +241,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 +249,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 +268,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)
})
}
}
}
@@ -313,24 +356,25 @@ struct OpenHaystackMainView: View {
action: {
self.downloadPlugin()
}), secondaryButton: .cancel())
case .selectDepoyTarget:
let microbitButton = Alert.Button.default(Text("Microbit"), action: { self.deployAccessoryToMicrobit(accessory: self.accessoryToDeploy!) })
let esp32Button = Alert.Button.default(
Text("ESP32"),
action: {
self.showESP32DeploySheet = true
})
case .downloadingReportsFailed:
return Alert(
title: Text("Select target"),
message: Text("Please select to which device you want to deploy"),
primaryButton: microbitButton,
secondaryButton: esp32Button)
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 +385,21 @@ 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)
}
}
@@ -362,3 +408,35 @@ extension Alert.Button {
Alert.Button.default(Text("Okay"))
}
}
extension TimeInterval {
var description: String {
var value = 0
var unit = Units.second
Units.allCases.forEach { u in
if self.rounded() >= u.rawValue {
value = Int((self / u.rawValue).rounded())
unit = u
}
}
return "\(value) \(unit.description)\(value > 1 ? "s" : "")"
}
enum Units: Double, CaseIterable {
case second = 1
case minute = 60
case hour = 3600
case day = 86400
case week = 604800
var description: String {
switch self {
case .second: return "Second"
case .minute: return "Minute"
case .hour: return "Hour"
case .day: return "Day"
case .week: return "Week"
}
}
}
}

View File

@@ -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
extension Binding where Value == Double {
func logarithmic(base: Double = 10.0) -> Binding<Double> {
Binding(
get: {
logC(self.wrappedValue, forBase: base)
},
set: { (newValue) in
self.wrappedValue = pow(base, newValue)
})
}
}
extension Slider {
static func withLogScale(
base: Double = 10.0,
value: Binding<Double>,
in inRange: ClosedRange<Double>,
minimumValueLabel: ValueLabel = EmptyView() as! ValueLabel,
maximumValueLabel: ValueLabel = EmptyView() as! ValueLabel,
label: () -> Label = { EmptyView() as! Label },
onEditingChanged: @escaping (Bool) -> Void = { _ in }
) -> Slider where Label: View, ValueLabel: View {
return self.init(
value: value.logarithmic(base: base),
in: logC(inRange.lowerBound, forBase: base)...logC(inRange.upperBound, forBase: base),
onEditingChanged: onEditingChanged, minimumValueLabel: minimumValueLabel,
maximumValueLabel: maximumValueLabel,
label: label)
}
}
private func logC(_ value: Double, forBase base: Double) -> Double {
return log(value) / log(base)
}

View File

@@ -0,0 +1,33 @@
//
// 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
struct LargeButtonStyle: ButtonStyle {
var active: Bool = false
var destructive: Bool = false
func makeBody(configuration: Configuration) -> some View {
ZStack {
if configuration.isPressed {
RoundedRectangle(cornerRadius: 5.0)
.fill(Color.accentColor)
} else {
RoundedRectangle(cornerRadius: 5.0)
.fill(self.active ? Color.accentColor : self.destructive ? Color.red : Color("Button"))
}
configuration.label
.font(Font.headline)
.padding(6)
}
}
}

View File

@@ -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>

View File

@@ -19,11 +19,7 @@ final class MapViewController: NSViewController, MKMapViewDelegate {
super.viewDidLoad()
self.mapView.delegate = self
self.mapView.register(AccessoryAnnotationView.self, forAnnotationViewWithReuseIdentifier: "Accessory")
}
func zoom(on accessory: Accessory?) {
self.focusedAccessory = accessory
self.zoomInOnSelection()
self.mapView.register(MKPinAnnotationView.self, forAnnotationViewWithReuseIdentifier: "AccessoryHistory")
}
func addLastLocations(from accessories: [Accessory]) {
@@ -34,15 +30,11 @@ final class MapViewController: NSViewController, MKMapViewDelegate {
let annotation = AccessoryAnnotation(accessory: accessory)
self.mapView.addAnnotation(annotation)
}
self.zoomInOnSelection()
}
func zoomInOnSelection() {
var annotations = [MKAnnotation]()
if focusedAccessory == nil {
// Show all locations
annotations = self.mapView.annotations
zoomInOnAll()
} else {
// Show focused accessory
let focusedAnnotation: MKAnnotation? = self.mapView.annotations.first(where: { annotation in
@@ -50,11 +42,18 @@ final class MapViewController: NSViewController, MKMapViewDelegate {
return accessoryAnnotation.accessory == self.focusedAccessory
})
if let annotation = focusedAnnotation {
annotations = [annotation]
zoomInOn(annotations: [annotation])
}
}
DispatchQueue.main.async {
self.mapView.showAnnotations(annotations, animated: true)
}
func zoomInOnAll() {
zoomInOn(annotations: self.mapView.annotations)
}
func zoomInOn(annotations: [MKAnnotation]) {
DispatchQueue.main.async { [weak self] in
self?.mapView.showAnnotations(annotations, animated: true)
}
}
@@ -62,12 +61,33 @@ final class MapViewController: NSViewController, MKMapViewDelegate {
self.mapView.mapType = mapType
}
func addAllLocations(from accessory: Accessory, past: TimeInterval) {
let now = Date()
let pastLocations = accessory.locations?.filter { location in
guard let timestamp = location.timestamp else {
return false
}
return timestamp + past >= now
}
self.mapView.removeAnnotations(self.mapView.annotations)
for location in pastLocations ?? [] {
let coordinate = CLLocationCoordinate2DMake(location.latitude, location.longitude)
let annotation = AccessoryHistoryAnnotation(coordinate: coordinate)
self.mapView.addAnnotation(annotation)
}
}
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
switch annotation {
case is AccessoryAnnotation:
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "Accessory", for: annotation)
annotationView.annotation = annotation
return annotationView
case is AccessoryHistoryAnnotation:
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "AccessoryHistory", for: annotation)
annotationView.annotation = annotation
return annotationView
default:
return nil
}

View File

@@ -12,28 +12,32 @@ import SwiftUI
@main
struct OpenHaystackApp: App {
@StateObject var accessoryController: AccessoryController
@StateObject var findMyController: FindMyController
var accessoryNearbyMonitor: AccessoryNearbyMonitor?
var frameWidth: CGFloat? = nil
var frameHeight: CGFloat? = nil
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
// self.frameWidth = 1920
// self.frameHeight = 1080
} 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)
.frame(width: self.frameWidth, height: self.frameHeight)
}
.commands {
SidebarCommands()
}
}
}

View File

@@ -26,10 +26,12 @@
CFTypeRef item;
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &item);
if (status == errSecSuccess) {
NSData *securityToken = (__bridge NSData *)(item);
CFRelease(item);
NSLog(@"Fetched token %@", [[NSString alloc] initWithData:securityToken encoding:NSUTF8StringEncoding]);
if (securityToken.length == 0) {
@@ -79,7 +81,8 @@
if (status == errSecSuccess) {
NSDictionary *itemDict = (__bridge NSDictionary *)(item);
CFRelease(item);
NSString *accountId = itemDict[(NSString *)kSecAttrAccount];
return accountId;

View File

@@ -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

View File

@@ -22,43 +22,90 @@
<string>Copyright © 2021 SEEMOO TU Darmstadt</string>
<key>NSPrincipalClass</key>
<string>OpenHaystackPluginService</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>
<key>Supported10.14PluginCompatibilityUUIDs</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>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.10PluginCompatibilityUUIDs</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>
<key>Supported11.5PluginCompatibilityUUIDs</key>
<array>
<string>D985F0E4-3BBC-4B95-BBA1-12056AC4A531</string>
</array>
<key>Supported11.6PluginCompatibilityUUIDs</key>
<array>
<string>D985F0E4-3BBC-4B95-BBA1-12056AC4A531</string>
</array>
<key>Supported11.7PluginCompatibilityUUIDs</key>
<array>
<string>D985F0E4-3BBC-4B95-BBA1-12056AC4A531</string>
</array>
<key>Supported11.8PluginCompatibilityUUIDs</key>
<array>
<string>D985F0E4-3BBC-4B95-BBA1-12056AC4A531</string>
</array>
<key>Supported11.9PluginCompatibilityUUIDs</key>
<array>
<string>D985F0E4-3BBC-4B95-BBA1-12056AC4A531</string>
</array>
<key>Supported12.0PluginCompatibilityUUIDs</key>
<array>
<string>D985F0E4-3BBC-4B95-BBA1-12056AC4A531</string>
<string>25288CEF-7D9B-49A8-BE6B-E41DA6277CF3</string>
</array>
</dict>
</plist>

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

View File

@@ -25,8 +25,8 @@ OpenHaystack is a framework for tracking personal Bluetooth devices via Apple's
## What is _OpenHaystack_?
OpenHaystack is an application that allows you to create your own tags that are tracked by Apple's [Find My network](#how-does-apples-find-my-network-work). All you need is a Mac and a [BBC micro:bit](https://microbit.org/) or any [other Bluetooth-capable device](#how-to-track-other-bluetooth-devices).
By using the app, you can track your micro:bit tag anywhere on earth without cellular coverage. Nearby iPhones will discover your tag and upload their location to Apple's servers when they have a network connection.
OpenHaystack is an application that allows you to create your own accessories that are tracked by Apple's [Find My network](#how-does-apples-find-my-network-work). All you need is a Mac and a [BBC micro:bit](https://microbit.org/) or any [other Bluetooth-capable device](#how-to-track-other-bluetooth-devices).
By using the app, you can track your accessories anywhere on earth without cellular coverage. Nearby iPhones will discover your accessories and upload their location to Apple's servers when they have a network connection.
### History
@@ -37,7 +37,7 @@ Since its release, we received quite a bit of [press and media coverage](https:/
### Disclaimer
OpenHaystack is experimental software. The code is untested and incomplete. For example, OpenHaystack tags using our [firmware](Firmware) broadcast a fixed public key and, therefore, are trackable by other devices in proximity (this might change in a future release). OpenHaystack is not affiliated with or endorsed by Apple Inc.
OpenHaystack is experimental software. The code is untested and incomplete. For example, OpenHaystack accessories using our [firmware](Firmware) broadcast a fixed public key and, therefore, are trackable by other devices in proximity (this might change in a future release). OpenHaystack is not affiliated with or endorsed by Apple Inc.
## How to use _OpenHaystack_?
@@ -58,22 +58,18 @@ Our plugin does not access any other private data such as emails (see [source co
2. Open OpenHaystack. This will ask you to install the Mail plugin in `~/Library/Mail/Bundle`.
3. Open a terminal and run `sudo spctl --master-disable`, which will disable Gatekeeper and allow our Apple Mail plugin to run.
4. Open Apple Mail. Go to _Preferences__General__Manage Plug-Ins..._ and activate the checkbox next to _OpenHaystackMail.mailbundle_.
* If the _Manage Plug-Ins..._ button does not appear. Run this command in terminal `sudo defaults write "/Library/Preferences/com.apple.mail" EnableBundles 1`
5. Allow access and restart Mail.
6. Open a terminal and enter `sudo spctl --master-enable`, which will enable Gatekeeper again.
### Usage
**Adding a new tag.**
To create a new tag, you just need to enter a name for it and optionally select a suitable icon and a color. The app then generates a new key pair that is used to encrypt and decrypt the location reports. The private key is stored in your Mac's keychain.
**Adding a new accessory.**
To create a new accessory, you just need to enter a name for it and optionally select a suitable icon and a color. The app then generates a new key pair that is used to encrypt and decrypt the location reports. The private key is stored in your Mac's keychain.
**BBC Microbit**
Upon deploying, the app will try to flash our firmware image with the new public key to a USB-connected [BBC micro:bit v1](https://microbit.org/).
**ESP32**
Since version 0.3.1 we also ship a simple ESP32 firmware that supports the same features as our firmware for the BBC micro:bit. To flash an ESP32 connect it via USB and select the correct serial port when deploying. It can take up to 3 minuites, a Python 3 version is required.
**Manual**
However, you may also copy the public key used for advertising and deploy it via some other mechanism.
**Deploy to device.**
Connect a [supported device](#how-to-track-other-bluetooth-devices) via USB to your Mac and hit the _Deploy_ button next to the accessory's name and choose the corresponding.
Instead of using OpenHaystack's integrated deployment, you may also copy the public key used for advertising (right click on accessory) and deploy it manually.
**Display devices' locations.**
It can take up to 30 minutes until you will see the first location report on the map on the right side. The map will always show all your items' most recent locations. You can click on every item to check when the last update was received.
@@ -87,12 +83,12 @@ We briefly explain Apple's offline finding system (aka [_Find My network_](https
### Pairing (1)
To use Apple's Find My network, we generate a public-private key pair on an elliptic curve (P-224). The private key remains on the Mac securely stored in the keychain, and the public key will be deployed on the tag, e.g., an attached micro:bit.
To use Apple's Find My network, we generate a public-private key pair on an elliptic curve (P-224). The private key remains on the Mac securely stored in the keychain, and the public key is deployed on the accessory, e.g., an attached micro:bit.
### Losing (2)
In short, the tags broadcast the public key as Bluetooth Low Energy (BLE) advertisements (see [firmware](Firmware)).
Nearby iPhones will not be able to distinguish our tags from a genuine Apple device or certified accessory.
In short, the accessories broadcast the public key as Bluetooth Low Energy (BLE) advertisements (see [firmware](Firmware)).
Nearby iPhones will not be able to distinguish our accessories from a genuine Apple device or certified accessory.
### Finding (3)
@@ -101,21 +97,22 @@ All iPhones on iOS 13 or newer do this by default. OpenHaystack is not involved
### Searching (4)
Apple does not know which encrypted locations belong to which Apple account or device. Therefore, every Apple user can download any location report as long as they know the corresponding public key. This is not a security issue: all reports are end-to-end encrypted and cannot be decrypted unless one knows the corresponding private key (stored in the keychain). We leverage this feature to download the reports from Apple that have been created for our OpenHaystack tags. We use our private keys to decrypt the location reports and show the most recent one on the map.
Apple does not know which encrypted locations belong to which Apple account or device. Therefore, every Apple user can download any location report as long as they know the corresponding public key. This is not a security issue: all reports are end-to-end encrypted and cannot be decrypted unless one knows the corresponding private key (stored in the keychain). We leverage this feature to download the reports from Apple that have been created for our OpenHaystack accessories. We use our private keys to decrypt the location reports and show the most recent one on the map.
Apple protects their database against arbitrary access by requiring an authenticated Apple user to download location reports.
We use our Apple Mail plugin, which runs with elevated privileges, to access the required authentication information. The OpenHaystack app communicates with the plugin while downloading reports. This is why you need to keep Mail open while using OpenHaystack.
## How to track other Bluetooth devices?
Currently, we only provide a convenient deployment method of our OpenHaystack firmware for the BBC micro:bit.
However, you should be able to implement the advertisements on other devices that support Bluetooth Low Energy based on the [source code of our firmware](Firmware) and the specification in [our paper](#references).
In principle, any Bluetooth device can be turned into an OpenHaystack accessory that is trackable via Apple's Find My network.
Currently, we provide a convenient deployment method of our OpenHaystack firmwares for a small number of embedded devices (see table below). We also support Linux devices via our generic HCI script.
Feel free to port OpenHaystack to other devices that support Bluetooth Low Energy based on the [source code of our firmware](Firmware) and the specification in [our paper](#references). Please share your results with us!
In addition, you can easily turn any Linux machine (including **Raspberry Pi**) into a _tag_ that can be tracked via the Find My network. Our Python script uses HCI calls to configure Bluetooth advertising. You can copy the required `ADVERTISMENT_KEY` from the app by right-clicking on your accessory. Then run the script:
```bash
sudo python3 HCI.py --key <ADVERTISMENT_KEY>
```
| Platform | Tested on | Deploy via app | Comment |
|----------|-----------|:--------------:|---------|
| [Nordic nRF51](Firmware/Microbit_v1) | BBC micro:bit v1 | ✓ | Only supports nRF51822 at this time (see issue #6). |
| [Espressif ESP32](Firmware/ESP32) | SP32-WROOM, ESP32-WROVER | ✓ | Deployment can take up to 3 minutes. Requires Python 3. Thanks **@fhessel**. |
| [Linux HCI](Firmware/Linux_HCI) | Raspberry Pi 4 w/ Raspbian | | Should support any Linux machine. |
![Setup](Resources/Setup.jpg)
@@ -126,7 +123,8 @@ sudo python3 HCI.py --key <ADVERTISMENT_KEY>
## References
- Alexander Heinrich, Milan Stute, Tim Kornhuber, Matthias Hollick. **Who Can _Find My_ Devices? Security and Privacy of Apple's Crowd-Sourced Bluetooth Location Tracking System.** _Proceedings on Privacy Enhancing Technologies (PoPETs)_, 2021. [📄 Preprint](https://arxiv.org/abs/2103.02282).
- Alexander Heinrich, Milan Stute, Tim Kornhuber, Matthias Hollick. **Who Can _Find My_ Devices? Security and Privacy of Apple's Crowd-Sourced Bluetooth Location Tracking System.** _Proceedings on Privacy Enhancing Technologies (PoPETs)_, 2021. [doi:10.2478/popets-2021-0045](https://doi.org/10.2478/popets-2021-0045) [📄 Paper](https://www.petsymposium.org/2021/files/papers/issue3/popets-2021-0045.pdf) [📄 Preprint](https://arxiv.org/abs/2103.02282).
- Alexander Heinrich, Milan Stute, and Matthias Hollick. **DEMO: OpenHaystack: A Framework for Tracking Personal Bluetooth Devices via Apples Massive Find My Network.** _14th ACM Conference on Security and Privacy in Wireless and Mobile (WiSec 21)_, 2021.
- Tim Kornhuber. **Analysis of Apple's Crowd-Sourced Location Tracking System.** _Technical University of Darmstadt_, Master's thesis, 2020.
- Apple Inc. **Find My Network Accessory Specification Developer Preview Release R3.** 2020. [📄 Download](https://developer.apple.com/find-my/).

BIN
Resources/Pins-NRF52832.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB