mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge pull request #1276 from meshtastic/keychain_backup
iCloud Keychain Private Key backup
This commit is contained in:
commit
c0c9c89854
6 changed files with 256 additions and 36 deletions
|
|
@ -2251,6 +2251,7 @@
|
|||
}
|
||||
},
|
||||
"Admin & Direct Message Keys" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -2283,6 +2284,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Admin Keys" : {
|
||||
|
||||
},
|
||||
"Administration" : {
|
||||
"localizations" : {
|
||||
|
|
@ -3777,6 +3781,12 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Backup" : {
|
||||
|
||||
},
|
||||
"Backup your private key to your iCloud keychain." : {
|
||||
|
||||
},
|
||||
"Bad" : {
|
||||
"localizations" : {
|
||||
|
|
@ -9373,6 +9383,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Direct Message Key" : {
|
||||
|
||||
},
|
||||
"Direct Messages" : {
|
||||
"localizations" : {
|
||||
|
|
@ -13070,6 +13083,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Generate a new private key to replace the one currently in use. Public key will automatically be regenerated as well." : {
|
||||
|
||||
},
|
||||
"Generate QR Code" : {
|
||||
"localizations" : {
|
||||
|
|
@ -13128,6 +13144,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Generated from your public key and sent out to other nodes on the mesh to allow them to compute a shared secret key." : {
|
||||
|
||||
},
|
||||
"Get custom waterproof solar and detection sensor router nodes, aluminium desktop nodes and rugged handsets." : {
|
||||
"localizations" : {
|
||||
|
|
@ -15510,6 +15529,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Key Backup" : {
|
||||
|
||||
},
|
||||
"Key Mapping" : {
|
||||
"localizations" : {
|
||||
|
|
@ -24541,6 +24563,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Restore" : {
|
||||
|
||||
},
|
||||
"Resume" : {
|
||||
"localizations" : {
|
||||
|
|
@ -27216,6 +27241,7 @@
|
|||
}
|
||||
},
|
||||
"Sent out to other nodes on the mesh to allow them to compute a shared secret key." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@
|
|||
DD1BD0EB2C601795008C0C70 /* CLLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0EA2C601795008C0C70 /* CLLocation.swift */; };
|
||||
DD1BD0EE2C603C91008C0C70 /* CustomFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0ED2C603C91008C0C70 /* CustomFormatters.swift */; };
|
||||
DD1BD0F32C63C65E008C0C70 /* SecurityConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */; };
|
||||
DD1BEF4A2E0292320090CE24 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF492E0292220090CE24 /* KeychainHelper.swift */; };
|
||||
DD1BEF4C2E030D310090CE24 /* KeyBackupStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */; };
|
||||
DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */; };
|
||||
DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2160AE28C5552500C17253 /* MQTTConfig.swift */; };
|
||||
DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */; };
|
||||
|
|
@ -372,6 +374,8 @@
|
|||
DD1BD0F12C61D3AD008C0C70 /* MeshtasticDataModelV 42.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 42.xcdatamodel"; sourceTree = "<group>"; };
|
||||
DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityConfig.swift; sourceTree = "<group>"; };
|
||||
DD1BEF462DFF284C0090CE24 /* MeshtasticDataModelV 53.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 53.xcdatamodel"; sourceTree = "<group>"; };
|
||||
DD1BEF492E0292220090CE24 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
|
||||
DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyBackupStatus.swift; sourceTree = "<group>"; };
|
||||
DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMessageList.swift; sourceTree = "<group>"; };
|
||||
DD2160AE28C5552500C17253 /* MQTTConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTConfig.swift; sourceTree = "<group>"; };
|
||||
DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralModel.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -876,6 +880,7 @@
|
|||
DD8ED9C6289CE4A100B3B0AB /* Enums */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */,
|
||||
DDA951592BC6624100CEA535 /* TelemetryWeather.swift */,
|
||||
DDE0F7C4295F77B700B8AAB3 /* AppSettingsEnums.swift */,
|
||||
DDB6ABD828B0A4BA00384BA1 /* BluetoothModes.swift */,
|
||||
|
|
@ -1067,6 +1072,7 @@
|
|||
children = (
|
||||
DDD43FE12A78C86B0083A3E9 /* Mqtt */,
|
||||
DDAF8C5226EB1DF10058C060 /* BLEManager.swift */,
|
||||
DD1BEF492E0292220090CE24 /* KeychainHelper.swift */,
|
||||
DDC2E1A626CEB3400042C5E4 /* LocationHelper.swift */,
|
||||
DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */,
|
||||
DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */,
|
||||
|
|
@ -1443,6 +1449,7 @@
|
|||
233E99C32D849D7A00CC3A77 /* WeightCompactWidget.swift in Sources */,
|
||||
DD4A911E2708C65400501B7E /* AppSettings.swift in Sources */,
|
||||
DD1BD0F32C63C65E008C0C70 /* SecurityConfig.swift in Sources */,
|
||||
DD1BEF4C2E030D310090CE24 /* KeyBackupStatus.swift in Sources */,
|
||||
DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */,
|
||||
DD6F65722C6AB8EC0053C113 /* SecureInput.swift in Sources */,
|
||||
DD13AA492AB73BF400BA0C98 /* PositionPopover.swift in Sources */,
|
||||
|
|
@ -1576,6 +1583,7 @@
|
|||
233E99C72D84A70900CC3A77 /* SoilCompactWidgets.swift in Sources */,
|
||||
BCE2D3C92C7C377F008E6199 /* FactoryResetNodeIntent.swift in Sources */,
|
||||
DD93800B2BA3F968008BEC06 /* NodeMapContent.swift in Sources */,
|
||||
DD1BEF4A2E0292320090CE24 /* KeychainHelper.swift in Sources */,
|
||||
DD41582A28585C32009B0E59 /* RangeTestConfig.swift in Sources */,
|
||||
DD1925B728CDA5A400720036 /* CannedMessagesConfigEnums.swift in Sources */,
|
||||
DDDB444429F8A8DD00EE2349 /* Float.swift in Sources */,
|
||||
|
|
|
|||
47
Meshtastic/Enums/KeyBackupStatus.swift
Normal file
47
Meshtastic/Enums/KeyBackupStatus.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// iCloudStats.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Copyright(c) Garth Vander Houwen 6/18/25.
|
||||
//
|
||||
|
||||
enum KeyBackupStatus: String, CaseIterable, Equatable, Decodable {
|
||||
case saved
|
||||
case restored
|
||||
case deleted
|
||||
case saveFailed
|
||||
case restoreFailed
|
||||
case deleteFailed
|
||||
var description: String {
|
||||
switch self {
|
||||
case .saved:
|
||||
return "Private Key saved successfully to iCloud keychain.".localized
|
||||
case .restored:
|
||||
return "Private Key restored successfully from iCloud keychain.".localized
|
||||
case .deleted:
|
||||
return "Private Key deleted successfully from iCloud keychain.".localized
|
||||
case .saveFailed:
|
||||
return "Private Key failed to save to iCloud keychain.".localized
|
||||
case .restoreFailed:
|
||||
return "Private Key value not found in iCloud keychain.".localized
|
||||
case .deleteFailed:
|
||||
return "Private Key failed to delete from iCloud keychain.".localized
|
||||
}
|
||||
}
|
||||
var success: Bool {
|
||||
switch self {
|
||||
case .saved:
|
||||
return true
|
||||
case .restored:
|
||||
return true
|
||||
case .deleted:
|
||||
return true
|
||||
case .saveFailed:
|
||||
return false
|
||||
case .restoreFailed:
|
||||
return false
|
||||
case .deleteFailed:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
66
Meshtastic/Helpers/KeychainHelper.swift
Normal file
66
Meshtastic/Helpers/KeychainHelper.swift
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
//
|
||||
// KeychainHelper.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Copyright(c) Garth Vander Houwen 6/17/25.
|
||||
//
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
class KeychainHelper {
|
||||
|
||||
static let standard = KeychainHelper()
|
||||
|
||||
private init() {}
|
||||
|
||||
func save(key: String, value: String, service: String = Bundle.main.bundleIdentifier!) -> OSStatus {
|
||||
let data = value.data(using: .utf8)!
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrSynchronizable as String: kCFBooleanTrue!,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary) // Delete existing item if any
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
return status
|
||||
}
|
||||
|
||||
func read(key: String, service: String = Bundle.main.bundleIdentifier!) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: kCFBooleanTrue,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecAttrSynchronizable as String: kCFBooleanTrue!
|
||||
]
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
|
||||
if status == errSecSuccess {
|
||||
if let data = item as? Data {
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func delete(key: String, service: String = Bundle.main.bundleIdentifier!) -> OSStatus {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecAttrSynchronizable as String: kCFBooleanTrue!
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,15 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.usernotifications.critical-alerts</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:meshtastic.org/e/*</string>
|
||||
<string>applinks:meshtastic.org/v/*</string>
|
||||
</array>
|
||||
<key>com.apple.developer.carplay-communication</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.usernotifications.critical-alerts</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.weatherkit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
|
|
@ -21,7 +23,9 @@
|
|||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.carplay-communication</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)gvh.MeshtasticClient</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ struct SecurityConfig: View {
|
|||
@State var serialEnabled = false
|
||||
@State var debugLogApiEnabled = false
|
||||
@State var privateKeyIsSecure = true
|
||||
@State var backupStatus: KeyBackupStatus?
|
||||
@State var backupStatusError: OSStatus?
|
||||
|
||||
private var isValidKeyPair: Bool {
|
||||
guard let privateKeyBytes = Data(base64Encoded: privateKey),
|
||||
|
|
@ -51,7 +53,7 @@ struct SecurityConfig: View {
|
|||
ConfigHeader(title: "Security", config: \.securityConfig, node: node, onAppear: setSecurityValues)
|
||||
Text("Security Config Settings require a firmware version 2.5+")
|
||||
.font(.title3)
|
||||
Section(header: Text("Admin & Direct Message Keys")) {
|
||||
Section(header: Text("Direct Message Key")) {
|
||||
VStack(alignment: .leading) {
|
||||
Label("Public Key", systemImage: "key")
|
||||
Text(publicKey)
|
||||
|
|
@ -66,7 +68,7 @@ struct SecurityConfig: View {
|
|||
RoundedRectangle(cornerRadius: 10.0)
|
||||
.stroke(isValidKeyPair ? Color.clear : Color.red, lineWidth: 2.0)
|
||||
)
|
||||
Text("Sent out to other nodes on the mesh to allow them to compute a shared secret key.")
|
||||
Text("Generated from your public key and sent out to other nodes on the mesh to allow them to compute a shared secret key.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(idiom == .phone ? .caption : .callout)
|
||||
Divider()
|
||||
|
|
@ -79,6 +81,71 @@ struct SecurityConfig: View {
|
|||
Text("Used to create a shared key with a remote device.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(idiom == .phone ? .caption : .callout)
|
||||
if let currentNode = node {
|
||||
Divider()
|
||||
Label("Key Backup", systemImage: "icloud")
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
let keychainKey = "PrivateKeyNode\(currentNode.num)"
|
||||
Button {
|
||||
let status = KeychainHelper.standard.save(key: keychainKey, value: privateKey)
|
||||
if status == errSecSuccess {
|
||||
backupStatus = KeyBackupStatus.saved
|
||||
} else {
|
||||
backupStatus = KeyBackupStatus.saveFailed
|
||||
backupStatusError = status
|
||||
}
|
||||
}
|
||||
label: {
|
||||
Image(systemName: "icloud.and.arrow.up")
|
||||
Text("Backup")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.small)
|
||||
Spacer()
|
||||
Button {
|
||||
if let value = KeychainHelper.standard.read(key: keychainKey) {
|
||||
self.privateKey = value
|
||||
self.privateKeyIsSecure = false
|
||||
backupStatus = KeyBackupStatus.restored
|
||||
} else {
|
||||
backupStatus = KeyBackupStatus.restoreFailed
|
||||
}
|
||||
}
|
||||
label: {
|
||||
Image(systemName: "key.icloud")
|
||||
Text("Restore")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.small)
|
||||
Spacer()
|
||||
Button {
|
||||
let status = KeychainHelper.standard.delete(key: keychainKey)
|
||||
if status == errSecSuccess {
|
||||
backupStatus = KeyBackupStatus.deleted
|
||||
} else {
|
||||
backupStatus = KeyBackupStatus.deleteFailed
|
||||
}
|
||||
}
|
||||
label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let status = backupStatus {
|
||||
let state = status.success
|
||||
Text("\(status.description)")
|
||||
.font(.caption)
|
||||
.foregroundColor(state ? .green : .red)
|
||||
}
|
||||
Text("Backup your private key to your iCloud keychain.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(idiom == .phone ? .caption : .callout)
|
||||
}
|
||||
Divider()
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Label("Regenerate Private Key", systemImage: "arrow.clockwise.circle")
|
||||
Spacer()
|
||||
|
|
@ -95,38 +162,40 @@ struct SecurityConfig: View {
|
|||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.small)
|
||||
}
|
||||
Divider()
|
||||
Label("Primary Admin Key", systemImage: "key.viewfinder")
|
||||
SecureInput("Primary Admin Key", text: $adminKey, isValid: $hasValidAdminKey)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10.0)
|
||||
.stroke(hasValidAdminKey ? Color.clear : Color.red, lineWidth: 2.0)
|
||||
)
|
||||
Text("The primary public key authorized to send admin messages to this node.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(idiom == .phone ? .caption : .callout)
|
||||
Divider()
|
||||
Label("Secondary Admin Key", systemImage: "key.viewfinder")
|
||||
SecureInput("Secondary Admin Key", text: $adminKey2, isValid: $hasValidAdminKey2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10.0)
|
||||
.stroke(hasValidAdminKey2 ? Color.clear : Color.red, lineWidth: 2.0)
|
||||
)
|
||||
Text("The secondary public key authorized to send admin messages to this node.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(idiom == .phone ? .caption : .callout)
|
||||
Divider()
|
||||
Label("Tertiary Admin Key", systemImage: "key.viewfinder")
|
||||
SecureInput("Tertiary Admin Key", text: $adminKey3, isValid: $hasValidAdminKey3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10.0)
|
||||
.stroke(hasValidAdminKey3 ? Color.clear : Color.red, lineWidth: 2.0)
|
||||
)
|
||||
Text("The tertiary public key authorized to send admin messages to this node.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(idiom == .phone ? .caption : .callout)
|
||||
Text("Generate a new private key to replace the one currently in use. Public key will automatically be regenerated as well.")
|
||||
}
|
||||
}
|
||||
Section(header: Text("Admin Keys")) {
|
||||
Label("Primary Admin Key", systemImage: "key.viewfinder")
|
||||
SecureInput("Primary Admin Key", text: $adminKey, isValid: $hasValidAdminKey)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10.0)
|
||||
.stroke(hasValidAdminKey ? Color.clear : Color.red, lineWidth: 2.0)
|
||||
)
|
||||
Text("The primary public key authorized to send admin messages to this node.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(idiom == .phone ? .caption : .callout)
|
||||
Divider()
|
||||
Label("Secondary Admin Key", systemImage: "key.viewfinder")
|
||||
SecureInput("Secondary Admin Key", text: $adminKey2, isValid: $hasValidAdminKey2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10.0)
|
||||
.stroke(hasValidAdminKey2 ? Color.clear : Color.red, lineWidth: 2.0)
|
||||
)
|
||||
Text("The secondary public key authorized to send admin messages to this node.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(idiom == .phone ? .caption : .callout)
|
||||
Divider()
|
||||
Label("Tertiary Admin Key", systemImage: "key.viewfinder")
|
||||
SecureInput("Tertiary Admin Key", text: $adminKey3, isValid: $hasValidAdminKey3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10.0)
|
||||
.stroke(hasValidAdminKey3 ? Color.clear : Color.red, lineWidth: 2.0)
|
||||
)
|
||||
Text("The tertiary public key authorized to send admin messages to this node.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(idiom == .phone ? .caption : .callout)
|
||||
}
|
||||
Section(header: Text("Logs")) {
|
||||
Toggle(isOn: $serialEnabled) {
|
||||
Label("Serial Console", systemImage: "terminal")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue