Meshtastic-Apple/Meshtastic/Views/Settings/Config/SecurityConfig.swift
Garth Vander Houwen 026bb80fba
Transports Interface to Support TCP for all Platforms and Serial on Mac (#1341)
* Initial implementation of transports

* Initial LogRadio implementation

* Fixes for Settings view (caused by debug commenting)

* Refinement of the object and actor model

* Connect view text and tab updates

* Fix mac catalyst and tests

* Warning and logging clean-up

* In progress commit

* Serial Transport and Reconnect draft work

* Serial transport and reconnection draft work

* Quick fix for BLE - still more work to do

* interim commit

* More in progress changes

* Minor improvements

* Pretty good initial implementation

* Bump version beyond the app store

* Fix for disconnection swipeAction

* Tweaks to TCPConnection implementation

* Retry for NONCE_ONLY_DB

* Revert json string change

* Simplified some of the API + "Anti-discovery"

* Tweaks for devices leaving the discovery process

* Bump version

* iOS26 Tweaks

* Tweaks and bug fixes

* Add link with slash sf symbol

* update symbol image on connect view

* BLE disconnect handling

* Log privacy attributes

* Onboarding and minor fixes.

* change database to nodes, add emoji to tcp logs

* Error handling improvements

* More logging emojis

* Suppressed unnecessary errors on disconnect

* Heartbeat emoji

* Add bluetooth symbol

* add privacy attributes to [TCP] logs, add custom bluetooth logo

* Improve routing logs

* Emoji for connect logs

* Heartbeat emoji

* Add CBCentralManagerScanOptionAllowDuplicatesKey options to central for bluetooth

* fix nav errors by switching from observableobject to state

* Update connection indicator icon

* fix for BLE disconnects

* Connection process fixes

* More fixes/tweaks to connection process

* Strict concurrency

* Fix some warnings, remove wifi warning

* delete stale keys

* interim commit

* Update privacy for log, fix wrong space

* fix a couple of linting items

* Switch to targeted

* interim commit

* BLE Signal strenth on connect view

* Remove BLE RSSI from long press menu

* Modem lights

* minor spacing tweak

* Additional BLE logging and a scanning fix.

* Discovery and BLE RSSI improvements

* Background suspension

* Update isConnected to enable UI during db load

* update protobufs

* Replace config if statements with switches, Fix unknown module config logging, make dark mode modem circle stroke color white so they are visible

* Additional logging cleanup

* hast

* Set unmessagable to true if the longname has the unmessagable emoji

* Connect error handling improvements

* Admin popup list icon and activity lights updates

* Revert use of .toolbar back to .navigationBarItems

* More public logging

* Better BLE error handling

* Node DB progress meter

* minor tweak to activity light interaction timing

* Fix comment linting, remove stale keys

* Remove stale keys

* Easy linting fixes

* Two more simple linting fixes

* clean up meshtasticapp

* More public logging

* Replay config

* Logging

* Fix for unselected node on Settings

* Tweak to progress meter based on device idiom

* Update protos

* Session replay redaction of messages

* Serial fix for old devices, and a let statement

* Mask text too

* Fix typo

* BLE poweredOff is now an auto-reconnectable error

* Update logging

* Fix for peerRemovedPairingInformation

* Logging for BLE peripheral:didUpdateValueFor errors.

* Fix for inconsistent swipe disconnect behavior

* periperal:didUpdateValueFor error handling

* Fix for BLEConnection continuation guarding

* BLEConnection actor deadlock on disconnect

* Heartbeat nonce

* Fix for swipe disconnect and task cancellation

* Fix for swipe actions not honoring .disabled()

* Tell BLETransport when BLEConnection is cancelled

* Update navigation logging

* Logging updates

* Bump version to 2.7.0

* Organize into folders and heartbeat stuff

* Minor improvements to manual TCP connection

* Auto-connect toggle

* Possible BLE bug, still waiting to see in logs

* Concurrency tweaks

* Concurrency improvements

* requestDeviceMetadata fix. fixes remote admin

* Minor typo fixes

* "All" button for log filters: category and level

* More robust continuation handling for BLE

* @FetchRequest based ChannelMessageList

* Update info.plist and device hardware file

* Move auto connect toggle to app settings and debug mode, tint properly with the accent color

* Add label to auto connect toggle

* Update log for node info received from ourselves over the mesh

* Remove unused scrollViewProxy

* Update Meshtastic/Views/Onboarding/DeviceOnboarding.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update target for connect view

* Properly Set datadog environment

* Comment out ble manager

* Adjust cyclomatic complexity thresholds in .swiftlint.yml

* Linting fixes, delete ble manager

* Make session replay debug only

---------

Co-authored-by: jake-b <jake-b@users.noreply.github.com>
Co-authored-by: jake <jake@jakes-Mac-mini.local>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-27 08:09:02 -07:00

431 lines
16 KiB
Swift

//
// Security.swift
// Meshtastic
//
// Copyright(c) Garth Vander Houwen 8/7/24.
//
import Foundation
import SwiftUI
import CoreData
import MeshtasticProtobufs
import OSLog
import CryptoKit
struct SecurityConfig: View {
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
@Environment(\.managedObjectContext) var context
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.dismiss) private var goBack
var node: NodeInfoEntity?
@State var hasChanges = false
@State var publicKey = ""
@State var privateKey = ""
@State var hasValidPrivateKey: Bool = false
@State var adminKey: String = ""
@State var adminKey2: String = ""
@State var adminKey3: String = ""
@State var hasValidAdminKey: Bool = true
@State var hasValidAdminKey2: Bool = true
@State var hasValidAdminKey3: Bool = true
@State var isManaged = false
@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),
let calculatedPublicKey = generatePublicKeyDisplay(from: privateKeyBytes),
let decodedPublicKey = Data(base64Encoded: publicKey) else {
return false
}
return calculatedPublicKey == decodedPublicKey
}
var body: some View {
VStack {
Form {
ConfigHeader(title: "Security", config: \.securityConfig, node: node, onAppear: setSecurityValues)
Text("Security Config Settings require a firmware version 2.5+")
.font(.title3)
Section(header: Text("Direct Message Key")) {
VStack(alignment: .leading) {
Label("Public Key", systemImage: "key")
Text(publicKey)
.font(idiom == .phone ? .caption : .callout)
.allowsTightening(true)
.monospaced()
.keyboardType(.alphabet)
.foregroundStyle(.tertiary)
.disableAutocorrection(true)
.textSelection(.enabled)
.background(
RoundedRectangle(cornerRadius: 10.0)
.stroke(isValidKeyPair ? Color.clear : Color.red, lineWidth: 2.0)
)
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()
Label("Private Key", systemImage: "key.fill")
SecureInput("Private Key", text: $privateKey, isValid: $hasValidPrivateKey, isSecure: $privateKeyIsSecure)
.background(
RoundedRectangle(cornerRadius: 10.0)
.stroke(hasValidPrivateKey ? Color.clear : Color.red, lineWidth: 2.0)
)
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()
Button {
if let keyBytes = generatePrivateKey(count: 32) {
privateKey = keyBytes.base64EncodedString()
self.privateKeyIsSecure = false
}
} label: {
Image(systemName: "lock.rotation")
.font(.title)
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.small)
}
Text("Generate a new private key to replace the one currently in use. The public key will automatically be regenerated from your private key.")
.foregroundStyle(.secondary)
.font(idiom == .phone ? .caption : .callout)
}
}
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")
Text("Serial Console over the Stream API.")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $debugLogApiEnabled) {
Label("Debug Logs", systemImage: "ant.fill")
Text("Output live debug logging over serial, view and export position-redacted device logs over Bluetooth.")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
if adminKey.length > 0 || UserDefaults.enableAdministration {
Section(header: Text("Administration")) {
Toggle(isOn: $isManaged) {
Label("Managed Device", systemImage: "gearshape.arrow.triangle.2.circlepath")
Text("Device is managed by a mesh administrator, the user is unable to access any of the device settings.")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
}
}
}
.scrollDismissesKeyboard(.immediately)
.navigationTitle("Security Config")
.navigationBarItems(trailing: ZStack {
ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?")
})
.onChange(of: node) { _, _ in
setSecurityValues()
}
.onChange(of: isManaged) { _, newIsManaged in
if newIsManaged != node?.securityConfig?.isManaged { hasChanges = true }
}
.onChange(of: serialEnabled) { _, newSerialEnabled in
if newSerialEnabled != node?.securityConfig?.serialEnabled { hasChanges = true }
}
.onChange(of: debugLogApiEnabled) { _, newDebugLogApiEnabled in
if newDebugLogApiEnabled != node?.securityConfig?.debugLogApiEnabled { hasChanges = true }
}
.onChange(of: privateKey) { _, key in
let tempKey = Data(base64Encoded: privateKey) ?? Data()
if tempKey.count == 32 {
hasValidPrivateKey = true
if let privateKeyBytes = Data(base64Encoded: privateKey), privateKeyBytes.count == 32 {
// Valid private key -- generate the public key
publicKey = generatePublicKeyDisplay(from: privateKeyBytes)?.base64EncodedString() ?? ""
}
} else {
hasValidPrivateKey = false
}
if key != node?.securityConfig?.privateKey?.base64EncodedString() ?? "" && hasValidPrivateKey { hasChanges = true }
}
.onChange(of: adminKey) { _, key in
let tempKey = Data(base64Encoded: key) ?? Data()
if key.isEmpty {
hasValidAdminKey = true
} else if tempKey.count == 32 {
hasValidAdminKey = true
} else {
hasValidAdminKey = false
}
if key != node?.securityConfig?.adminKey?.base64EncodedString() ?? "" && hasValidAdminKey { hasChanges = true }
}
.onChange(of: adminKey2) { _, key in
let tempKey = Data(base64Encoded: key) ?? Data()
if key.isEmpty {
hasValidAdminKey2 = true
} else if tempKey.count == 32 {
hasValidAdminKey2 = true
} else {
hasValidAdminKey2 = false
}
if key != node?.securityConfig?.adminKey2?.base64EncodedString() ?? "" && hasValidAdminKey2 { hasChanges = true }
}
.onChange(of: adminKey3) { _, key in
let tempKey = Data(base64Encoded: key) ?? Data()
if key.isEmpty {
hasValidAdminKey3 = true
} else if tempKey.count == 32 {
hasValidAdminKey3 = true
} else {
hasValidAdminKey3 = false
}
if key != node?.securityConfig?.adminKey3?.base64EncodedString() ?? "" && hasValidAdminKey3 { hasChanges = true }
}
.onFirstAppear {
// Need to request a SecurityConfig from the remote node before allowing changes
if let deviceNum = accessoryManager.activeDeviceNum, let node {
if let connectedNode = getNodeInfo(id: deviceNum, context: context) {
if node.num != deviceNum {
if UserDefaults.enableAdministration {
/// 2.5 Administration with session passkey
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.securityConfig == nil {
Task {
do {
Logger.mesh.info("⚙️ Empty or expired security config requesting via PKI admin")
try await accessoryManager.requestSecurityConfig(fromUser: connectedNode.user!, toUser: node.user!)
} catch {
Logger.mesh.info("🚨 Security config request failed")
}
}
}
} else {
if node.deviceConfig == nil {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}
}
}
}
SaveConfigButton(node: node, hasChanges: $hasChanges) {
if !hasValidPrivateKey || !hasValidAdminKey || !hasValidAdminKey2 || !hasValidAdminKey3 {
return
}
guard let deviceNum = accessoryManager.activeDeviceNum,
let connectedNode = getNodeInfo(id: deviceNum, context: context),
let fromUser = connectedNode.user,
let toUser = node?.user else {
return
}
var config = Config.SecurityConfig()
config.privateKey = Data(base64Encoded: privateKey) ?? Data()
config.adminKey = [Data(base64Encoded: adminKey) ?? Data(), Data(base64Encoded: adminKey2) ?? Data(), Data(base64Encoded: adminKey3) ?? Data()]
config.isManaged = isManaged
config.serialEnabled = serialEnabled
config.debugLogApiEnabled = debugLogApiEnabled
let keyUpdated = node?.securityConfig?.privateKey?.base64EncodedString() ?? "" != privateKey
Task {
_ = try await accessoryManager.saveSecurityConfig(
config: config,
fromUser: fromUser,
toUser: toUser
)
Task { @MainActor in
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
if keyUpdated {
node?.user?.publicKey = Data(base64Encoded: publicKey) ?? Data()
do {
try context.save()
Logger.data.info("💾 Saved UserEntity Public Key to Core Data for \(node?.num ?? 0, privacy: .public)")
} catch {
context.rollback()
let nsError = error as NSError
Logger.data.error("Error Updating Core Data UserEntity: \(nsError, privacy: .public)")
}
}
}
hasChanges = false
if keyUpdated {
Task {
do {
try await accessoryManager.sendReboot(
fromUser: fromUser,
toUser: toUser
)
} catch {
Logger.mesh.warning("Reboot Failed")
}
}
}
goBack()
}
}
}
func setSecurityValues() {
self.publicKey = node?.securityConfig?.publicKey?.base64EncodedString() ?? ""
self.privateKey = node?.securityConfig?.privateKey?.base64EncodedString() ?? ""
self.adminKey = node?.securityConfig?.adminKey?.base64EncodedString(options: .lineLength64Characters) ?? ""
self.adminKey2 = node?.securityConfig?.adminKey2?.base64EncodedString(options: .lineLength64Characters) ?? ""
self.adminKey3 = node?.securityConfig?.adminKey3?.base64EncodedString(options: .lineLength64Characters) ?? ""
self.isManaged = node?.securityConfig?.isManaged ?? false
self.serialEnabled = node?.securityConfig?.serialEnabled ?? false
self.debugLogApiEnabled = node?.securityConfig?.debugLogApiEnabled ?? false
self.hasChanges = false
}
func generatePrivateKey(count: Int) -> Data? {
var randomBytes = Data(count: count)
let status = randomBytes.withUnsafeMutableBytes { (mutableBytes: UnsafeMutableRawBufferPointer) -> Int32 in
guard let pointer = mutableBytes.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
return -1 // Indicate an error
}
return SecRandomCopyBytes(kSecRandomDefault, count, pointer)
}
if status == errSecSuccess {
// Generate a random "f" value and then adjust the value to make
// it valid as an "s" value for eval(). According to the specification
// we need to mask off the 3 right-most bits of f[0], mask off the
// left-most bit of f[31], and set the second to left-most bit of f[31].
var f = randomBytes
f[0] &= 0xF8
f[31] = (f[31] & 0x7F) | 0x40
return f
} else {
// Handle error, perhaps by logging or throwing an exception
Logger.mesh.debug("Error generating random bytes: \(status)")
return nil
}
}
// Generate a new public key for display purposes to show the user what will be changed after the new private key is saved to the device
func generatePublicKeyDisplay(from privateKeyData: Data) -> Data? {
guard privateKeyData.count == 32 else {
Logger.mesh.debug("Invalid private key length. Must be 32 bytes for Curve25519.")
return nil
}
do {
// Create a Curve25519 private key from raw representation
let privateKey = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: privateKeyData)
let publicKey = privateKey.publicKey
return publicKey.rawRepresentation
} catch {
Logger.mesh.debug("Failed to create Curve25519 key: \(error)")
return nil
}
}
}