Merge pull request #1276 from meshtastic/keychain_backup

iCloud Keychain Private Key backup
This commit is contained in:
Garth Vander Houwen 2025-06-18 16:59:04 -07:00 committed by GitHub
commit c0c9c89854
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 256 additions and 36 deletions

View file

@ -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" : {

View file

@ -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 */,

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

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

View file

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

View file

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