From 913c3c2ad1346c90257d90da8ad4238c4dc4e6cd Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Wed, 13 May 2026 09:00:41 -0400 Subject: [PATCH 1/4] feat(lockdown): add LockdownState model and coordinator interfaces Introduce the TAK passphrase lockdown abstractions: - LockdownState sealed class + LockdownTokenInfo for UI to observe. - LockdownCoordinator interface for the authentication lifecycle (onConnect/onDisconnect/onConfigComplete/handleLockdownStatus, plus submitPassphrase/lockNow). - Add sendLockdownPassphrase/sendLockNow to CommandSender, RadioController. - Add handleSendLockdownUnlock/handleSendLockNow to MeshActionHandler. - Add clearRadioConfig to MeshConnectionManager (used during lock-now). - Add lockdownState/lockdownTokenInfo/sessionAuthorized flows to ServiceRepository. handleLockdownStatus consumes the typed firmware LockdownStatus message from FromRadio (protobufs#911) instead of parsing string-prefixed ClientNotification messages. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../meshtastic/core/model/RadioController.kt | 6 +++ .../core/model/service/LockdownState.kt | 53 +++++++++++++++++++ .../core/repository/CommandSender.kt | 6 +++ .../core/repository/LockdownCoordinator.kt | 48 +++++++++++++++++ .../core/repository/MeshActionHandler.kt | 6 +++ .../core/repository/MeshConnectionManager.kt | 3 ++ .../core/repository/ServiceRepository.kt | 23 ++++++++ 7 files changed, 145 insertions(+) create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt index e021c0aa95..e001638648 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt @@ -326,4 +326,10 @@ interface RadioController { * @param address The new device identifier. */ fun setDeviceAddress(address: String) + + /** Submits a lockdown passphrase to authenticate with a TAK-locked device. */ + suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) + + /** Sends a Lock Now command to the connected TAK-enabled device. */ + suspend fun sendLockNow() } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt new file mode 100644 index 0000000000..e26c88b5f6 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt @@ -0,0 +1,53 @@ +/* + * 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.model.service + +/** Represents the lockdown authentication state for a TAK-locked device. */ +sealed class LockdownState { + data object None : LockdownState() + + /** + * Device is locked or this client is not yet authorized. + * + * @param lockReason machine-readable reason from firmware (e.g. "needs_auth", + * "token_missing", "token_expired"). Empty string when unknown. + */ + data class Locked(val lockReason: String = "") : LockdownState() + + data object NeedsProvision : LockdownState() + data object Unlocked : LockdownState() + + /** Lock Now ACK received — client should disconnect immediately, no dialog. */ + data object LockNowAcknowledged : LockdownState() + + /** Wrong passphrase — retry immediately. */ + data object UnlockFailed : LockdownState() + + /** Too many attempts — must wait [backoffSeconds] before retrying. */ + data class UnlockBackoff(val backoffSeconds: Int) : LockdownState() +} + +/** + * Lockdown session token metadata from a successful unlock. + * + * @param bootsRemaining Number of reboots before the token expires. + * @param expiryEpoch Unix epoch seconds; 0 means no time-based expiry. + */ +data class LockdownTokenInfo( + val bootsRemaining: Int, + val expiryEpoch: Long, +) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt index e69310d68b..02ca5acbdb 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt @@ -86,4 +86,10 @@ interface CommandSender { /** Requests neighbor info from a specific node. */ fun requestNeighborInfo(requestId: Int, destNum: Int) + + /** Sends a lockdown passphrase to authenticate with a TAK-locked device. */ + fun sendLockdownPassphrase(passphrase: String, boots: Int = 0, hours: Int = 0) + + /** Sends a Lock Now command to immediately lock a TAK-enabled device. */ + fun sendLockNow() } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt new file mode 100644 index 0000000000..1f642d2291 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt @@ -0,0 +1,48 @@ +/* + * 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.repository + +import org.meshtastic.proto.LockdownStatus + +/** + * Coordinates lockdown (TAK passphrase) authentication for TAK-locked devices. + * + * Implementations handle the full authentication lifecycle: auto-unlock with a stored + * passphrase, manual passphrase submission, lock-now, and session lifecycle hooks. + */ +interface LockdownCoordinator { + /** Called when a BLE connection is established, before the first config request. */ + fun onConnect() + + /** Called when a BLE connection is lost. */ + fun onDisconnect() + + /** + * Called on every config_complete_id from the device. + * After session is authorized this is a no-op to prevent re-triggering lockdown logic. + */ + fun onConfigComplete() + + /** Routes an incoming typed [LockdownStatus] from FromRadio. */ + fun handleLockdownStatus(status: LockdownStatus) + + /** Submits a passphrase to authenticate with the locked device. */ + fun submitPassphrase(passphrase: String, boots: Int, hours: Int) + + /** Sends a Lock Now command to the connected device. */ + fun lockNow() +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt index d55bbe2dd8..dd9120960d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt @@ -120,4 +120,10 @@ interface MeshActionHandler { /** Updates the last used device address. */ fun handleUpdateLastAddress(deviceAddr: String?) + + /** Submits a lockdown passphrase to authenticate with a TAK-locked device. */ + fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) + + /** Sends a Lock Now command to the connected TAK-enabled device. */ + fun handleSendLockNow() } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt index eae5bd9a0d..6d0e4e0817 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt @@ -41,4 +41,7 @@ interface MeshConnectionManager { /** Updates and returns the current status notification. */ fun updateStatusNotification(telemetry: Telemetry? = null): Any + + /** Clears the cached radio configuration (local config, channel set, module config). */ + fun clearRadioConfig() } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt index 4a8af11439..ed163fbc65 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt @@ -21,6 +21,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow 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.proto.ClientNotification @@ -144,4 +146,25 @@ interface ServiceRepository { * @param action The [ServiceAction] to perform. */ suspend fun onServiceAction(action: ServiceAction) + + /** Reactive flow of the current lockdown authentication state. */ + val lockdownState: StateFlow + + /** Updates the lockdown state. */ + fun setLockdownState(state: LockdownState) + + /** Resets lockdown state to [LockdownState.None]. */ + fun clearLockdownState() + + /** Reactive flow of the most recent lockdown session token info. */ + val lockdownTokenInfo: StateFlow + + /** Sets the lockdown token info from a successful UNLOCKED status. */ + fun setLockdownTokenInfo(info: LockdownTokenInfo?) + + /** True once the passphrase was accepted for the current BLE connection. */ + val sessionAuthorized: StateFlow + + /** Updates the session authorization flag. */ + fun setSessionAuthorized(authorized: Boolean) } From f6e97d7ff7ccbf663250e1f7c4ba9188fe430883 Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Wed, 13 May 2026 09:00:53 -0400 Subject: [PATCH 2/4] feat(lockdown): implement coordinator and typed status dispatch - CommandSenderImpl: build AdminMessage.lockdown_auth = LockdownAuth(...) for provision/unlock and lock_now=true for the lock command. - FromRadioPacketHandlerImpl: route the new FromRadio.lockdown_status variant to the coordinator; also notify the coordinator on config_complete_id. - MeshActionHandlerImpl: forward handleSendLockdownUnlock/handleSendLockNow to the coordinator. - MeshConnectionManagerImpl: call coordinator.onConnect/onDisconnect; add clearRadioConfig to purge cached config after a lock-now ACK. - ServiceRepositoryImpl: back the lockdownState/lockdownTokenInfo/ sessionAuthorized flows. - LockdownHandlerImpl: orchestration. Switches on LockdownStatus.State (NEEDS_PROVISION / LOCKED / UNLOCKED / UNLOCK_FAILED), auto-replays stored passphrase on LOCKED, clears stored passphrase on a fresh UNLOCK_FAILED, surfaces backoff_seconds on rate-limit. Tracks a wasLockNow flag locally so the next LOCKED status after a lock-now command is translated to LockdownState.LockNowAcknowledged for an immediate UI disconnect (the new schema has no explicit ACK type). - LockdownPassphraseStore: per-device EncryptedSharedPreferences store for auto-unlock. Not biometric-gated by design. - Add androidx.security:security-crypto dependency. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/data/manager/CommandSenderImpl.kt | 36 ++++ .../manager/FromRadioPacketHandlerImpl.kt | 9 +- .../data/manager/MeshActionHandlerImpl.kt | 10 + .../data/manager/MeshConnectionManagerImpl.kt | 12 ++ core/service/build.gradle.kts | 1 + .../core/service/LockdownHandlerImpl.kt | 189 ++++++++++++++++++ .../core/service/LockdownPassphraseStore.kt | 82 ++++++++ .../core/service/ServiceRepositoryImpl.kt | 30 +++ gradle/libs.versions.toml | 1 + 9 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownHandlerImpl.kt create mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt 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" } From 0c8e5302e454606f104502eb9240ca96791c0220 Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Wed, 13 May 2026 09:01:03 -0400 Subject: [PATCH 3/4] feat(lockdown): wire AIDL, RadioController, and ViewModels - IMeshService: sendLockdownUnlock(passphrase, bootTtl, hourTtl) and sendLockNow() AIDL methods. - MeshService: AIDL stubs forwarding to MeshActionHandler. - AndroidRadioControllerImpl: forward to meshService over AIDL. - DirectRadioControllerImpl: forward directly to actionHandler (in-process non-Android targets). - FakeIMeshService: test stubs. - UIViewModel: lockdownState/lockdownTokenInfo flows, sendLockdownUnlock, sendLockNow, clearLockdownState. Routed through radioController so the commonMain code does not depend on the AIDL service directly. - ConnectionsViewModel: expose lockdownState. - RadioConfigViewModel: lockdownTokenInfo + sendLockNow for the Lock Now button in security settings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../meshtastic/core/service/IMeshService.aidl | 6 ++++++ .../service/AndroidRadioControllerImpl.kt | 8 ++++++++ .../meshtastic/core/service/MeshService.kt | 9 +++++++++ .../core/service/testing/FakeIMeshService.kt | 4 ++++ .../core/service/DirectRadioControllerImpl.kt | 8 ++++++++ .../core/ui/viewmodel/ConnectionsViewModel.kt | 1 + .../core/ui/viewmodel/UIViewModel.kt | 20 +++++++++++++++++++ .../settings/radio/RadioConfigViewModel.kt | 10 ++++++++++ 8 files changed, 66 insertions(+) diff --git a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl index 7fd3883a21..946d238a53 100644 --- a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl @@ -189,4 +189,10 @@ interface IMeshService { * hash is the 32-byte firmware SHA256 hash (optional, can be null) */ void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash); + + /// Send a lockdown passphrase to authenticate with a TAK-locked device + void sendLockdownUnlock(in String passphrase, in int bootTtl, in int hourTtl); + + /// Send a Lock Now command to the connected TAK-enabled device + void sendLockNow(); } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt index cd4b317bdd..cc5f7fc4db 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt @@ -204,4 +204,12 @@ class AndroidRadioControllerImpl( } context.startForegroundService(intent) } + + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) { + serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl) + } + + override suspend fun sendLockNow() { + serviceRepository.meshService?.sendLockNow() + } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 2ed00ec6ae..aa6896aea9 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -359,5 +359,14 @@ class MeshService : Service() { toRemoteExceptions { router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash) } + + override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) = + toRemoteExceptions { + router.actionHandler.handleSendLockdownUnlock(passphrase.orEmpty(), bootTtl, hourTtl) + } + + override fun sendLockNow() = toRemoteExceptions { + router.actionHandler.handleSendLockNow() + } } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt index 0c49b60f44..d3e34e7e66 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt @@ -120,4 +120,8 @@ open class FakeIMeshService : IMeshService.Stub() { override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {} override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {} + + override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) {} + + override fun sendLockNow() {} } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt index acda9d4fb4..049fe53e35 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -231,4 +231,12 @@ class DirectRadioControllerImpl( actionHandler.handleUpdateLastAddress(address) radioInterfaceService.setDeviceAddress(address) } + + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) { + actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl) + } + + override suspend fun sendLockNow() { + actionHandler.handleSendLockNow() + } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt index a838b6a9f0..5aef0c41f8 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt @@ -41,6 +41,7 @@ class ConnectionsViewModel( radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) val connectionState = serviceRepository.connectionState + val lockdownState = serviceRepository.lockdownState val myNodeInfo: StateFlow = nodeRepository.myNodeInfo diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 04abdf4158..3d125d4d03 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach @@ -110,6 +111,21 @@ class UIViewModel( notificationManager.cancel(notification.toString().hashCode()) } + val lockdownState = serviceRepository.lockdownState + val lockdownTokenInfo = serviceRepository.lockdownTokenInfo + + fun sendLockdownUnlock(passphrase: String, bootTtl: Int = DEFAULT_BOOT_TTL, hourTtl: Int = 0) { + viewModelScope.launch { radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl) } + } + + fun sendLockNow() { + viewModelScope.launch { radioController.sendLockNow() } + } + + fun clearLockdownState() { + serviceRepository.clearLockdownState() + } + /** Emits events for mesh network send/receive activity. */ val meshActivity: Flow = radioInterfaceService.meshActivity @@ -264,4 +280,8 @@ class UIViewModel( fun onAppIntroCompleted() { uiPreferencesDataSource.setAppIntroCompleted(true) } + + companion object { + private const val DEFAULT_BOOT_TTL = 50 + } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 7e7b09e0c5..1a73f7fd6e 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -32,6 +32,8 @@ import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.model.service.LockdownTokenInfo +import org.meshtastic.core.repository.LockdownCoordinator import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase @@ -118,7 +120,15 @@ open class RadioConfigViewModel( private val processRadioResponseUseCase: ProcessRadioResponseUseCase, private val locationService: LocationService, private val fileService: FileService, + private val lockdownCoordinator: LockdownCoordinator, ) : ViewModel() { + + val lockdownTokenInfo: kotlinx.coroutines.flow.StateFlow = serviceRepository.lockdownTokenInfo + + fun sendLockNow() { + lockdownCoordinator.lockNow() + } + var analyticsAllowedFlow = analyticsPrefs.analyticsAllowed fun toggleAnalyticsAllowed() { From dae4369149648350a79f206e4f185370565d731c Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Wed, 13 May 2026 09:01:12 -0400 Subject: [PATCH 4/4] feat(lockdown): add unlock dialog, Lock Now button, region gating - LockdownUnlockDialog: passphrase entry with boots / hours TTL inputs. Shows lock_reason on LOCKED, a backoff countdown on UNLOCK_FAILED with backoff_seconds > 0 (Submit disabled while in backoff), and switches the title to "Set Passphrase" on NEEDS_PROVISION. - Main: collect lockdownState/lockdownTokenInfo, show the dialog, auto-clear on LockNowAcknowledged so the connection drops without a dialog flash. - ConnectionsScreen: gate the "must set region" banner on isLockdownAuthorized so an unauthorized client isn't told to fix a region it can't see. - SecurityConfigItemList: "Lock Now" button under Administration, labelled with the active session token's boots remaining and (if set) the wall-clock expiry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../meshtastic/app/ui/LockdownUnlockDialog.kt | 167 ++++++++++++++++++ .../main/kotlin/org/meshtastic/app/ui/Main.kt | 14 ++ .../connections/ui/ConnectionsScreen.kt | 6 +- .../radio/component/SecurityConfigItemList.kt | 19 ++ 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt b/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt new file mode 100644 index 0000000000..e613962bdd --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt @@ -0,0 +1,167 @@ +/* + * 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.app.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import org.meshtastic.core.model.service.LockdownState +import org.meshtastic.core.model.service.LockdownTokenInfo + +@Suppress("LongMethod") +@Composable +fun LockdownUnlockDialog( + lockdownState: LockdownState, + lockdownTokenInfo: LockdownTokenInfo? = null, + onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit, + onDismiss: () -> Unit, +) { + val shouldShow = + when (lockdownState) { + is LockdownState.Locked -> true + is LockdownState.NeedsProvision -> true + is LockdownState.UnlockFailed -> true + is LockdownState.UnlockBackoff -> true + else -> false + } + BackHandler(enabled = shouldShow, onBack = onDismiss) + if (!shouldShow) return + + var passphrase by rememberSaveable { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + val initialBoots = lockdownTokenInfo?.bootsRemaining ?: DEFAULT_BOOTS + val initialHours = + if ((lockdownTokenInfo?.expiryEpoch ?: 0L) > 0L) { + ((lockdownTokenInfo!!.expiryEpoch - System.currentTimeMillis() / 1000) / 3600) + .toInt() + .coerceAtLeast(0) + } else { + 0 + } + var boots by rememberSaveable { mutableIntStateOf(initialBoots) } + var hours by rememberSaveable { mutableIntStateOf(initialHours) } + + val isProvisioning = lockdownState is LockdownState.NeedsProvision + val title = if (isProvisioning) "Set Passphrase" else "Enter Passphrase" + val inBackoff = lockdownState is LockdownState.UnlockBackoff + val isValid = passphrase.isNotEmpty() && passphrase.length <= MAX_PASSPHRASE_LEN && !inBackoff + + AlertDialog( + onDismissRequest = {}, + title = { Text(text = title) }, + text = { + Column { + when (lockdownState) { + is LockdownState.UnlockFailed -> { + Text(text = "Incorrect passphrase.", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + } + is LockdownState.UnlockBackoff -> { + Text( + text = "Try again in ${lockdownState.backoffSeconds} seconds.", + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + } + is LockdownState.Locked -> { + if (lockdownState.lockReason.isNotEmpty()) { + Text(text = "Reason: ${lockdownState.lockReason}") + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + } + } + else -> {} + } + + OutlinedTextField( + value = passphrase, + onValueChange = { if (it.length <= MAX_PASSPHRASE_LEN) passphrase = it }, + label = { Text("Passphrase") }, + singleLine = true, + visualTransformation = + if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (passwordVisible) "Hide" else "Show", + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedTextField( + value = boots.toString(), + onValueChange = { str -> str.toIntOrNull()?.let { boots = it.coerceIn(1, MAX_BYTE_VALUE) } }, + label = { Text("Boot TTL") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(SPACING_DP.dp)) + OutlinedTextField( + value = hours.toString(), + onValueChange = { str -> str.toIntOrNull()?.let { hours = it.coerceAtLeast(0) } }, + label = { Text("Hour TTL") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + } + } + }, + confirmButton = { + TextButton(onClick = { onSubmit(passphrase, boots, hours) }, enabled = isValid) { Text("Submit") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +private const val DEFAULT_BOOTS = 50 +private const val MAX_PASSPHRASE_LEN = 64 +private const val MAX_BYTE_VALUE = 255 +private const val SPACING_DP = 8 diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index a32d1c527d..2677bac4bf 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -131,6 +131,20 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie } } + val lockdownState by uIViewModel.lockdownState.collectAsStateWithLifecycle() + val lockdownTokenInfo by uIViewModel.lockdownTokenInfo.collectAsStateWithLifecycle() + LaunchedEffect(lockdownState) { + if (lockdownState is org.meshtastic.core.model.service.LockdownState.LockNowAcknowledged) { + uIViewModel.clearLockdownState() + } + } + LockdownUnlockDialog( + lockdownState = lockdownState, + lockdownTokenInfo = lockdownTokenInfo, + onSubmit = { pass, boots, hours -> uIViewModel.sendLockdownUnlock(pass, boots, hours) }, + onDismiss = { uIViewModel.clearLockdownState() }, + ) + VersionChecks(uIViewModel) val alertDialogState by uIViewModel.currentAlert.collectAsStateWithLifecycle() diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 3bec4b1889..c8fe6b911f 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -120,7 +120,11 @@ fun ConnectionsScreen( .collectAsStateWithLifecycle(initialValue = connectionsViewModel.ourNodeInfo.value) val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle() - val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET + val lockdownState by connectionsViewModel.lockdownState.collectAsStateWithLifecycle() + val isLockdownAuthorized = + lockdownState is org.meshtastic.core.model.service.LockdownState.None || + lockdownState is org.meshtastic.core.model.service.LockdownState.Unlocked + val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET && isLockdownAuthorized val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle() val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle() diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt index 4401660108..3d6c45a210 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt @@ -225,6 +225,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } } item { + val lockdownTokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle() TitledCard(title = stringResource(Res.string.administration)) { SwitchPreference( title = stringResource(Res.string.managed_mode), @@ -242,6 +243,24 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { onCheckedChange = { formState.value = formState.value.copy(admin_channel_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) + HorizontalDivider() + val lockNowTitle = lockdownTokenInfo?.let { info -> + val parts = mutableListOf("boots: ${info.bootsRemaining}") + if (info.expiryEpoch > 0L) { + val dateText = java.text.DateFormat.getDateTimeInstance( + java.text.DateFormat.SHORT, + java.text.DateFormat.SHORT, + ).format(java.util.Date(info.expiryEpoch * 1000L)) + parts += "until: $dateText" + } + "Lock Now (${parts.joinToString(", ")})" + } ?: "Lock Now" + NodeActionButton( + modifier = Modifier.padding(horizontal = 8.dp), + title = lockNowTitle, + enabled = state.connected, + onClick = { viewModel.sendLockNow() }, + ) } } }