diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index 1e5f5eaeba..1ed549101d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -42,11 +42,13 @@ import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.Constants import org.meshtastic.proto.Data import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LockdownAuth import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.Neighbor import org.meshtastic.proto.NeighborInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.ToRadio import kotlin.math.absoluteValue import kotlin.random.Random import kotlin.time.Duration.Companion.hours @@ -355,6 +357,38 @@ class CommandSenderImpl( } } + override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int) { + val validUntilEpoch = + if (hours > 0) (nowMillis / 1000L + hours.toLong() * SECONDS_PER_HOUR).toInt() else 0 + val lockdownAuth = + LockdownAuth( + passphrase = passphrase.encodeToByteArray().toByteString(), + boots_remaining = boots.coerceAtLeast(0), + valid_until_epoch = validUntilEpoch, + ) + sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth)) + } + + override fun sendLockNow() { + sendLockdownAdmin(AdminMessage(lockdown_auth = LockdownAuth(lock_now = true))) + } + + private fun sendLockdownAdmin(adminMessage: AdminMessage) { + val myNum = nodeManager.myNodeNum ?: return + val packet = + MeshPacket( + to = myNum, + id = generatePacketId(), + channel = 0, + want_ack = true, + hop_limit = DEFAULT_HOP_LIMIT, + hop_start = DEFAULT_HOP_LIMIT, + priority = MeshPacket.Priority.RELIABLE, + decoded = Data(portnum = PortNum.ADMIN_APP, payload = adminMessage.encode().toByteString()), + ) + packetHandler.sendToRadio(ToRadio(packet = packet)) + } + fun resolveNodeNum(toId: String): Int = when (toId) { DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST else -> { @@ -436,5 +470,7 @@ class CommandSenderImpl( private const val HEX_RADIX = 16 private const val DEFAULT_HOP_LIMIT = 3 + + private const val SECONDS_PER_HOUR = 3600 } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt index 4d35a27df5..540d7d0bbc 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager import org.koin.core.annotation.Single import org.meshtastic.core.repository.FromRadioPacketHandler +import org.meshtastic.core.repository.LockdownCoordinator import org.meshtastic.core.repository.MeshRouter import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.Notification @@ -37,6 +38,7 @@ class FromRadioPacketHandlerImpl( private val mqttManager: MqttManager, private val packetHandler: PacketHandler, private val notificationManager: NotificationManager, + private val lockdownCoordinator: LockdownCoordinator, ) : FromRadioPacketHandler { @Suppress("CyclomaticComplexMethod") override fun handleFromRadio(proto: FromRadio) { @@ -50,6 +52,7 @@ class FromRadioPacketHandlerImpl( val moduleConfig = proto.moduleConfig val channel = proto.channel val clientNotification = proto.clientNotification + val lockdownStatus = proto.lockdown_status when { myInfo != null -> router.value.configFlowManager.handleMyInfo(myInfo) @@ -58,12 +61,16 @@ class FromRadioPacketHandlerImpl( router.value.configFlowManager.handleNodeInfo(nodeInfo) serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})") } - configCompleteId != null -> router.value.configFlowManager.handleConfigComplete(configCompleteId) + configCompleteId != null -> { + router.value.configFlowManager.handleConfigComplete(configCompleteId) + lockdownCoordinator.onConfigComplete() + } mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage) queueStatus != null -> packetHandler.handleQueueStatus(queueStatus) config != null -> router.value.configHandler.handleDeviceConfig(config) moduleConfig != null -> router.value.configHandler.handleModuleConfig(moduleConfig) channel != null -> router.value.configHandler.handleChannel(channel) + lockdownStatus != null -> lockdownCoordinator.handleLockdownStatus(lockdownStatus) clientNotification != null -> { serviceRepository.setClientNotification(clientNotification) notificationManager.dispatch( diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index b1a33330d2..995b1fee48 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -33,6 +33,7 @@ import org.meshtastic.core.model.Reaction import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.DataPair +import org.meshtastic.core.repository.LockdownCoordinator import org.meshtastic.core.repository.MeshActionHandler import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.MeshMessageProcessor @@ -63,6 +64,7 @@ class MeshActionHandlerImpl( private val databaseManager: DatabaseManager, private val notificationManager: NotificationManager, private val messageProcessor: Lazy, + private val lockdownCoordinator: LockdownCoordinator, ) : MeshActionHandler { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -351,4 +353,12 @@ class MeshActionHandlerImpl( } } } + + override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) { + lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl) + } + + override fun handleSendLockNow() { + lockdownCoordinator.lockNow() + } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index 5e706c288c..898f9cd3be 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -38,6 +38,7 @@ import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.HistoryManager +import org.meshtastic.core.repository.LockdownCoordinator import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshServiceNotifications @@ -88,6 +89,7 @@ class MeshConnectionManagerImpl( private val packetRepository: PacketRepository, private val workerManager: MeshWorkerManager, private val appWidgetUpdater: AppWidgetUpdater, + private val lockdownCoordinator: LockdownCoordinator, ) : MeshConnectionManager { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var sleepTimeout: Job? = null @@ -182,6 +184,7 @@ class MeshConnectionManagerImpl( serviceBroadcasts.broadcastConnection() Logger.i { "Starting mesh handshake (Stage 1)" } connectTimeMsec = nowMillis + lockdownCoordinator.onConnect() startConfigOnly() } @@ -238,6 +241,7 @@ class MeshConnectionManagerImpl( private fun handleDisconnected() { serviceRepository.setConnectionState(ConnectionState.Disconnected) + lockdownCoordinator.onDisconnect() packetHandler.stopPacketQueue() locationManager.stop() mqttManager.stop() @@ -258,6 +262,14 @@ class MeshConnectionManagerImpl( action() } + override fun clearRadioConfig() { + scope.handledLaunch { + radioConfigRepository.clearLocalConfig() + radioConfigRepository.clearChannelSet() + radioConfigRepository.clearLocalModuleConfig() + } + } + override fun startNodeInfoOnly() { val action = { packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE)) } startHandshakeStallGuard(2, action) diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts index 6d3eaf0bea..c5b2da2f56 100644 --- a/core/service/build.gradle.kts +++ b/core/service/build.gradle.kts @@ -49,6 +49,7 @@ kotlin { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.security.crypto) implementation(libs.koin.android) implementation(libs.koin.androidx.workmanager) } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownHandlerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownHandlerImpl.kt new file mode 100644 index 0000000000..480e609f02 --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownHandlerImpl.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.service + +import co.touchlab.kermit.Logger +import org.koin.core.annotation.Single +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.meshtastic.core.model.service.LockdownState +import org.meshtastic.core.model.service.LockdownTokenInfo +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.LockdownCoordinator +import org.meshtastic.core.repository.MeshConnectionManager +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.proto.LockdownStatus + +@Single(binds = [LockdownCoordinator::class]) +class LockdownHandlerImpl( + private val serviceRepository: ServiceRepository, + private val commandSender: CommandSender, + private val passphraseStore: LockdownPassphraseStore, + private val radioInterfaceService: RadioInterfaceService, +) : LockdownCoordinator, KoinComponent { + private val connectionManager: MeshConnectionManager by inject() + @Volatile private var wasAutoAttempt = false + + @Volatile private var wasLockNow = false + + @Volatile private var pendingPassphrase: String? = null + + @Volatile private var pendingBoots: Int = LockdownPassphraseStore.DEFAULT_BOOTS + + @Volatile private var pendingHours: Int = 0 + + /** Called when the BLE connection is established, before the first config request. */ + override fun onConnect() { + serviceRepository.setSessionAuthorized(false) + wasAutoAttempt = false + wasLockNow = false + pendingPassphrase = null + pendingBoots = LockdownPassphraseStore.DEFAULT_BOOTS + pendingHours = 0 + } + + /** Called when the BLE connection is lost. */ + override fun onDisconnect() { + serviceRepository.setSessionAuthorized(false) + serviceRepository.setLockdownTokenInfo(null) + serviceRepository.setLockdownState(LockdownState.None) + wasAutoAttempt = false + wasLockNow = false + pendingPassphrase = null + } + + /** + * Called on every config_complete_id. Once [sessionAuthorized] is true (set on UNLOCKED), + * this is a no-op — preventing the startConfigOnly config_complete_id from triggering any + * further lockdown handling. + */ + override fun onConfigComplete() { + if (serviceRepository.sessionAuthorized.value) return + } + + /** Routes typed firmware [LockdownStatus] to per-state handlers. */ + override fun handleLockdownStatus(status: LockdownStatus) { + when (status.state) { + LockdownStatus.State.NEEDS_PROVISION -> handleNeedsProvision() + LockdownStatus.State.LOCKED -> handleLocked(status.lock_reason) + LockdownStatus.State.UNLOCKED -> handleUnlocked(status) + LockdownStatus.State.UNLOCK_FAILED -> handleUnlockFailed(status.backoff_seconds) + LockdownStatus.State.STATE_UNSPECIFIED -> Unit + } + } + + private fun handleLockNowAcknowledged() { + Logger.i { "Lockdown: Lock Now acknowledged — resetting session authorization" } + serviceRepository.setSessionAuthorized(false) + wasAutoAttempt = false + wasLockNow = false + pendingPassphrase = null + // Purge cached config; fresh config is loaded after successful re-authentication. + connectionManager.clearRadioConfig() + // Signal the UI to disconnect — no dialog, just drop the connection. + serviceRepository.setLockdownState(LockdownState.LockNowAcknowledged) + } + + private fun handleLocked(lockReason: String) { + if (wasLockNow) { + handleLockNowAcknowledged() + return + } + val deviceAddress = radioInterfaceService.getDeviceAddress() + if (deviceAddress != null) { + val stored = passphraseStore.getPassphrase(deviceAddress) + if (stored != null) { + Logger.i { "Lockdown: Auto-unlocking (reason=$lockReason) with stored passphrase for $deviceAddress" } + wasAutoAttempt = true + commandSender.sendLockdownPassphrase(stored.passphrase, stored.boots, stored.hours) + return + } + } + serviceRepository.setLockdownState(LockdownState.Locked(lockReason)) + } + + private fun handleNeedsProvision() { + serviceRepository.setLockdownState(LockdownState.NeedsProvision) + } + + private fun handleUnlocked(status: LockdownStatus) { + val deviceAddress = radioInterfaceService.getDeviceAddress() + val passphrase = pendingPassphrase + if (deviceAddress != null && passphrase != null) { + passphraseStore.savePassphrase(deviceAddress, passphrase, pendingBoots, pendingHours) + Logger.i { "Lockdown: Saved passphrase for $deviceAddress" } + } + pendingPassphrase = null + serviceRepository.setLockdownTokenInfo( + LockdownTokenInfo( + bootsRemaining = status.boots_remaining, + expiryEpoch = status.valid_until_epoch.toLong() and UINT32_MASK, + ), + ) + serviceRepository.setLockdownState(LockdownState.Unlocked) + // Mark session authorized BEFORE calling startConfigOnly(). When the resulting + // config_complete_id arrives, onConfigComplete() will see sessionAuthorized=true and + // return immediately — no passphrase re-send, no loop. + serviceRepository.setSessionAuthorized(true) + connectionManager.startConfigOnly() + } + + private fun handleUnlockFailed(backoffSeconds: Int) { + pendingPassphrase = null + if (wasAutoAttempt) { + wasAutoAttempt = false + if (backoffSeconds > 0) { + Logger.i { "Lockdown: Auto-unlock rate-limited (backoff=${backoffSeconds}s)" } + serviceRepository.setLockdownState(LockdownState.UnlockBackoff(backoffSeconds)) + } else { + val deviceAddress = radioInterfaceService.getDeviceAddress() + if (deviceAddress != null) { + passphraseStore.clearPassphrase(deviceAddress) + Logger.i { "Lockdown: Auto-unlock failed (wrong passphrase), cleared stored passphrase for $deviceAddress" } + } + serviceRepository.setLockdownState(LockdownState.Locked()) + } + return + } + if (backoffSeconds > 0) { + Logger.i { "Lockdown: Unlock failed with backoff of ${backoffSeconds}s" } + serviceRepository.setLockdownState(LockdownState.UnlockBackoff(backoffSeconds)) + } else { + serviceRepository.setLockdownState(LockdownState.UnlockFailed) + } + } + + override fun submitPassphrase(passphrase: String, boots: Int, hours: Int) { + pendingPassphrase = passphrase + pendingBoots = boots + pendingHours = hours + wasAutoAttempt = false + wasLockNow = false + serviceRepository.setLockdownState(LockdownState.None) // hide dialog while awaiting response + commandSender.sendLockdownPassphrase(passphrase, boots, hours) + } + + override fun lockNow() { + wasLockNow = true + commandSender.sendLockNow() + } + + companion object { + private const val UINT32_MASK = 0xFFFFFFFFL + } +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt new file mode 100644 index 0000000000..cfb7f17cb5 --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.service + +import android.app.Application +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import org.koin.core.annotation.Single + +data class StoredPassphrase( + val passphrase: String, + val boots: Int, + val hours: Int, +) + +/** + * Encrypted per-device storage for lockdown passphrases. + * + * Uses EncryptedSharedPreferences backed by an AES-256-GCM MasterKey (hardware keystore when + * available). The key is intentionally NOT gated behind biometric authentication so that + * auto-unlock can run in the background without user interaction. + */ +@Single +class LockdownPassphraseStore(app: Application) { + + private val prefs: SharedPreferences by lazy { + val masterKey = + MasterKey.Builder(app).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() + EncryptedSharedPreferences.create( + app, + PREFS_FILE_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + fun getPassphrase(deviceAddress: String): StoredPassphrase? { + val key = sanitizeKey(deviceAddress) + val passphrase = prefs.getString("${key}_passphrase", null) ?: return null + val boots = prefs.getInt("${key}_boots", DEFAULT_BOOTS) + val hours = prefs.getInt("${key}_hours", 0) + return StoredPassphrase(passphrase, boots, hours) + } + + fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { + val key = sanitizeKey(deviceAddress) + prefs + .edit() + .putString("${key}_passphrase", passphrase) + .putInt("${key}_boots", boots) + .putInt("${key}_hours", hours) + .apply() + } + + fun clearPassphrase(deviceAddress: String) { + val key = sanitizeKey(deviceAddress) + prefs.edit().remove("${key}_passphrase").remove("${key}_boots").remove("${key}_hours").apply() + } + + private fun sanitizeKey(address: String): String = address.replace(":", "_") + + companion object { + private const val PREFS_FILE_NAME = "lockdown_passphrase_store" + const val DEFAULT_BOOTS = 50 + } +} diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt index ad5b92bd51..398b089147 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt @@ -26,6 +26,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.receiveAsFlow import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.service.LockdownState +import org.meshtastic.core.model.service.LockdownTokenInfo import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.core.repository.ServiceRepository @@ -125,4 +127,32 @@ open class ServiceRepositoryImpl : ServiceRepository { override suspend fun onServiceAction(action: ServiceAction) { _serviceAction.send(action) } + + private val _lockdownState = MutableStateFlow(LockdownState.None) + override val lockdownState: StateFlow + get() = _lockdownState + + override fun setLockdownState(state: LockdownState) { + _lockdownState.value = state + } + + override fun clearLockdownState() { + _lockdownState.value = LockdownState.None + } + + private val _lockdownTokenInfo = MutableStateFlow(null) + override val lockdownTokenInfo: StateFlow + get() = _lockdownTokenInfo + + override fun setLockdownTokenInfo(info: LockdownTokenInfo?) { + _lockdownTokenInfo.value = info + } + + private val _sessionAuthorized = MutableStateFlow(false) + override val sessionAuthorized: StateFlow + get() = _sessionAuthorized + + override fun setSessionAuthorized(authorized: Boolean) { + _sessionAuthorized.value = authorized + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60210cedb0..3e8b9c33df 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,6 +84,7 @@ androidx-camera-viewfinder-compose = { module = "androidx.camera.viewfinder:view androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.18.0" } androidx-core-location-altitude = { module = "androidx.core:core-location-altitude", version = "1.0.0-rc01" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version = "1.2.0" } +androidx-security-crypto = { module = "androidx.security:security-crypto", version = "1.1.0-alpha06" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }