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 01/22] 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 02/22] 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 03/22] 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 04/22] 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() }, + ) } } } From d25136f83e7908ddb3323fba7c466f4018faed0c Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 09:03:11 -0500 Subject: [PATCH 05/22] fix: resolve compile errors from PR merge - Replace java.text.DateFormat/java.util.Date usage in SecurityConfigScreen (constitution violation: no java.* in commonMain) with simplified Lock Now button - Replace material.icons imports with MeshtasticIcons in LockdownUnlockDialog - Proper token info display to be re-implemented in Phase 5 (T025-T026) --- .specify/feature.json | 4 +- AGENTS.md | 2 +- .../meshtastic/app/ui/LockdownUnlockDialog.kt | 8 +- .../core/data/manager/CommandSenderImpl.kt | 2 +- .../radio/component/SecurityConfigScreen.kt | 15 +- .../checklists/requirements.md | 36 +++ .../contracts/lockdown-coordinator.md | 86 +++++++ .../contracts/lockdown-passphrase-store.md | 95 ++++++++ .../contracts/lockdown-ui.md | 134 +++++++++++ .../data-model.md | 134 +++++++++++ specs/20260513-075218-lockdown-mode/plan.md | 104 ++++++++ .../quickstart.md | 96 ++++++++ .../20260513-075218-lockdown-mode/research.md | 140 +++++++++++ specs/20260513-075218-lockdown-mode/spec.md | 222 ++++++++++++++++++ specs/20260513-075218-lockdown-mode/tasks.md | 213 +++++++++++++++++ 15 files changed, 1271 insertions(+), 20 deletions(-) create mode 100644 specs/20260513-075218-lockdown-mode/checklists/requirements.md create mode 100644 specs/20260513-075218-lockdown-mode/contracts/lockdown-coordinator.md create mode 100644 specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md create mode 100644 specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md create mode 100644 specs/20260513-075218-lockdown-mode/data-model.md create mode 100644 specs/20260513-075218-lockdown-mode/plan.md create mode 100644 specs/20260513-075218-lockdown-mode/quickstart.md create mode 100644 specs/20260513-075218-lockdown-mode/research.md create mode 100644 specs/20260513-075218-lockdown-mode/spec.md create mode 100644 specs/20260513-075218-lockdown-mode/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json index c28b048e07..7bc2be669f 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1,3 @@ -{"feature_directory":"specs/20260511-211823-compose-screenshot-testing"} +{ + "feature_directory": "specs/20260513-075218-lockdown-mode" +} diff --git a/AGENTS.md b/AGENTS.md index 75f08c34fb..ec49eb2f3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,5 +49,5 @@ You are an expert Android/KMP engineer. Maintain architectural boundaries, use M For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan -at `specs/20260511-211823-compose-screenshot-testing/plan.md` +at `specs/20260513-075218-lockdown-mode/plan.md` diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt b/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt index e613962bdd..b626fe78f9 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt @@ -25,9 +25,6 @@ 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 @@ -48,6 +45,9 @@ 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 +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Visibility +import org.meshtastic.core.ui.icon.VisibilityOff @Suppress("LongMethod") @Composable @@ -124,7 +124,7 @@ fun LockdownUnlockDialog( IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( imageVector = - if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + if (passwordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, contentDescription = if (passwordVisible) "Hide" else "Show", ) } 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 0888a8c1f0..9a087d52fa 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 @@ -392,7 +392,7 @@ class CommandSenderImpl( } private fun sendLockdownAdmin(adminMessage: AdminMessage) { - val myNum = nodeManager.myNodeNum ?: return + val myNum = nodeManager.myNodeNum.value ?: return val packet = MeshPacket( to = myNum, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt index efdd60fda2..860c781bee 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt @@ -195,7 +195,6 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un } } item { - val lockdownTokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle() TitledCard(title = stringResource(Res.string.administration)) { SwitchPreference( title = stringResource(Res.string.managed_mode), @@ -214,20 +213,10 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un 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" + // TODO(lockdown): Re-implement Lock Now button with KMP-compatible UI (Phase 5, T025-T026) NodeActionButton( modifier = Modifier.padding(horizontal = 8.dp), - title = lockNowTitle, + title = "Lock Now", enabled = state.connected, onClick = { viewModel.sendLockNow() }, ) diff --git a/specs/20260513-075218-lockdown-mode/checklists/requirements.md b/specs/20260513-075218-lockdown-mode/checklists/requirements.md new file mode 100644 index 0000000000..c37c79d8a0 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Lockdown Mode + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-13 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- Proto contract is well-defined in upstream `admin.proto` and `mesh.proto` — no ambiguity in the firmware interface. +- Nick's PR #4703 provides implementation reference but this spec intentionally stays at the behavior level. diff --git a/specs/20260513-075218-lockdown-mode/contracts/lockdown-coordinator.md b/specs/20260513-075218-lockdown-mode/contracts/lockdown-coordinator.md new file mode 100644 index 0000000000..93b7bc5b73 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/contracts/lockdown-coordinator.md @@ -0,0 +1,86 @@ +# Contract: LockdownCoordinator + +**Module**: `core/repository` (interface) / `core/data` (implementation) +**Source set**: `commonMain` + +## Interface + +```kotlin +package org.meshtastic.core.repository + +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.model.lockdown.LockdownState + +/** + * Single owner of lockdown lifecycle. Receives firmware status reports, + * manages state transitions, drives auto-replay, and exposes observable + * state for UI consumption. + */ +interface LockdownCoordinator { + + /** Current lockdown state. Observed by UI to render blocking modal or session info. */ + val state: StateFlow + + /** + * Whether the current connection is authorized (unlocked or lockdown not applicable). + * Convenience derived from [state] for banner/UI gating. + */ + val isAuthorized: StateFlow + + /** + * Called by [FromRadioPacketHandler] when a LockdownStatus proto arrives. + * Drives state transitions and may trigger auto-replay. + */ + fun handleStatus(status: org.meshtastic.proto.LockdownStatus) + + /** + * Called when a new connection is established. Stores nodeId for + * passphrase cache lookups during auto-replay. + * + * @param nodeId The connected node's mesh number + */ + fun onConnect(nodeId: Int) + + /** + * Called when config-complete is received from the device. + * Triggers initial lockdown state evaluation (auto-replay if cached passphrase exists). + */ + fun onConfigComplete() + + /** + * Called on connection disconnect. Resets state to [LockdownState.NotApplicable] + * so next connection starts fresh. Replaces the standalone `reset()` method. + */ + fun onDisconnect() + + /** + * Submit a passphrase for unlock or provision. + * Transitions state to [LockdownState.Unlocking] and sends AdminMessage. + * + * @param passphrase Raw passphrase bytes (1-32) + * @param bootsRemaining Optional boot-count TTL; 0 = firmware default + * @param validUntilEpoch Optional wall-clock expiry; 0 = no time limit + */ + suspend fun submitPassphrase( + passphrase: ByteArray, + bootsRemaining: UInt = 0u, + validUntilEpoch: UInt = 0u, + ) + + /** + * Send lock-now command. Transitions to [LockdownState.LockNowPending], + * then disconnects after firmware ACK. + */ + suspend fun lockNow() +} +``` + +## Behavioral Contract + +1. **Initial state**: `LockdownState.NotApplicable` until first `handleStatus()` call +2. **Lifecycle**: `onConnect(nodeId)` stores the node ID → `onConfigComplete()` evaluates initial state → `onDisconnect()` resets to `NotApplicable` +3. **Auto-replay**: When transitioning to `Locked` and `LockdownPassphraseStore.get(nodeId)` returns non-null, automatically call `submitPassphrase()` with cached bytes (boots=0, epoch=0) +4. **Cache management**: On `Unlocked` after user-entered passphrase → `store.put(nodeId, passphrase)`. On `UnlockFailed` after auto-replay → `store.clear(nodeId)` +5. **Lock-now flow**: `lockNow()` → send `LockdownAuth(lock_now=true)` → set `wasLockNow=true` → on next `LOCKED` status: transition to `LockNowAcknowledged` → delay 500ms → disconnect +6. **Thread safety**: All state mutations on a single coroutine dispatcher (no race between handleStatus and user actions) +6. **Logging**: MUST NOT log passphrase bytes. May log state transitions and node IDs (redacted to last 4 hex chars for device addresses). diff --git a/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md b/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md new file mode 100644 index 0000000000..a803a8edee --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md @@ -0,0 +1,95 @@ +# Contract: LockdownPassphraseStore + +**Module**: `core/repository` (interface) / `core/datastore` (platform implementations) +**Source set**: `commonMain` (interface), `androidMain` / `jvmMain` / `iosMain` (implementations) + +## Interface + +```kotlin +package org.meshtastic.core.repository + +/** + * Encrypted per-node passphrase cache for lockdown auto-replay. + * + * Implementations MUST store passphrases using platform-appropriate + * encryption (EncryptedSharedPreferences on Android, Keychain on iOS, + * KeyStore-backed file on JVM). Passphrase bytes MUST NOT appear in + * logs, crash reports, or unencrypted storage. + */ +interface LockdownPassphraseStore { + + /** + * Retrieve the cached passphrase for a node. + * @param nodeId Mesh node number + * @return Raw passphrase bytes, or null if none cached + */ + suspend fun get(nodeId: Int): ByteArray? + + /** + * Store a passphrase for a node, overwriting any previous value. + * @param nodeId Mesh node number + * @param passphrase Raw passphrase bytes (1-32) + */ + suspend fun put(nodeId: Int, passphrase: ByteArray) + + /** + * Remove the cached passphrase for a node. + * @param nodeId Mesh node number + */ + suspend fun clear(nodeId: Int) +} +``` + +## Platform Implementations + +### Android (`androidMain`) + +```kotlin +@Single +class LockdownPassphraseStoreImpl( + private val context: Context, +) : LockdownPassphraseStore { + private val prefs: SharedPreferences by lazy { + EncryptedSharedPreferences.create( + "lockdown_passphrases", + MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + override suspend fun get(nodeId: Int): ByteArray? = + prefs.getString(nodeId.toKey(), null)?.let { Base64.decode(it) } + + override suspend fun put(nodeId: Int, passphrase: ByteArray) = + prefs.edit().putString(nodeId.toKey(), Base64.encode(passphrase)).apply() + + override suspend fun clear(nodeId: Int) = + prefs.edit().remove(nodeId.toKey()).apply() + + private fun Int.toKey(): String = "lockdown_${toUInt().toString(16)}" +} +``` + +### JVM / iOS (stubs) + +```kotlin +@Single +class LockdownPassphraseStoreImpl : LockdownPassphraseStore { + // No-op: passphrase never cached on this platform. + // User is always prompted on reconnection. + override suspend fun get(nodeId: Int): ByteArray? = null + override suspend fun put(nodeId: Int, passphrase: ByteArray) { /* no-op */ } + override suspend fun clear(nodeId: Int) { /* no-op */ } +} +``` + +## Behavioral Contract + +1. **Encryption at rest**: Android impl MUST use AES-256-GCM via EncryptedSharedPreferences. Passphrase bytes are Base64-encoded for SharedPreferences string storage. +2. **Key format**: `"lockdown_${nodeId.toUInt().toString(16)}"` — hex representation avoids negative-int issues. +3. **No logging**: Implementations MUST NOT log passphrase content or full node addresses. +4. **Thread safety**: `SharedPreferences.edit().apply()` is async-safe on Android. Suspend modifier allows IO dispatcher usage. +5. **Lifecycle**: Store persists across app restarts. Cleared only on explicit `clear()` call (auth failure) or app data wipe. +6. **Stubs**: JVM/iOS stubs are intentionally no-op. This means auto-replay won't work on those platforms until real implementations are added. This is acceptable per spec (Android is primary target). diff --git a/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md b/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md new file mode 100644 index 0000000000..75cb81891a --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md @@ -0,0 +1,134 @@ +# Contract: Lockdown UI Components + +**Module**: `feature/settings` +**Source set**: `commonMain` + +## LockdownDialog (non-dismissable blocking dialog) + +```kotlin +/** + * Non-dismissable AlertDialog that blocks all app interaction when the connected + * node is in a lockdown state requiring user action (LOCKED or NEEDS_PROVISION). + * + * Uses `onDismissRequest = {}` + `BackHandler` to prevent dismissal. + * Shown when state requires auth; hidden when state transitions to Unlocked or NotApplicable. + * + * @param state Current lockdown state from LockdownCoordinator + * @param onSubmitPassphrase Called with (passphrase, bootsRemaining, validUntilEpoch) + * @param onDisconnect Called when user wants to disconnect instead of authenticating + */ +@Composable +fun LockdownDialog( + state: LockdownState, + onSubmitPassphrase: (ByteArray, UInt, UInt) -> Unit, + onDisconnect: () -> Unit, +) +``` + +### UI States Rendered + +| `LockdownState` | UI Rendering | +|-----------------|-------------| +| `NeedsProvision` | "Set Passphrase" title, passphrase field + confirm field, optional TTL fields, Submit button | +| `Locked` | "Unlock Device" title, passphrase field, optional TTL fields (hidden for unlock), Submit button, lock_reason displayed | +| `Unlocking` | Same as above with Submit disabled + loading indicator | +| `UnlockFailed(backoff=0)` | Error text "Incorrect passphrase", Submit enabled for retry | +| `UnlockFailed(backoff>0)` | Error text + countdown timer, Submit disabled until backoff expires | +| `LockNowPending` | "Locking device..." with spinner | +| `LockNowAcknowledged` | "Device locked" confirmation, auto-disconnect in progress | + +### Component Details + +- **Passphrase field**: `OutlinedTextField` with `visualTransformation = PasswordVisualTransformation()`, trailing eye icon to toggle visibility +- **Confirm field** (provision only): Second `OutlinedTextField` with match validation +- **Boots remaining** (optional): `OutlinedTextField` with `keyboardType = KeyboardType.Number`, hint "Leave empty for default" +- **Hours until expiry** (optional): `OutlinedTextField` with number input, converted to `valid_until_epoch` (current time + hours * 3600) +- **Submit button**: `FilledTonalButton`, disabled during backoff or when passphrase empty +- **Disconnect button**: `TextButton` "Disconnect" to allow user to bail without authenticating +- **Error display**: `Text` with `MaterialTheme.colorScheme.error` color + +--- + +## LockdownSessionStatus (session info row) + +```kotlin +/** + * Displays current session token TTL information in Security settings. + * Only visible when node is in UNLOCKED state. + * + * @param session Active session info (boots remaining, expiry) + */ +@Composable +fun LockdownSessionStatus( + session: LockdownState.Unlocked, +) +``` + +### Display Format + +| Condition | Displayed Text | +|-----------|---------------| +| `bootsRemaining > 0 && validUntilEpoch > 0` | "Session: N reboots remaining, expires [formatted date]" | +| `bootsRemaining > 0 && validUntilEpoch == 0` | "Session: N reboots remaining, no time limit" | +| `bootsRemaining == 0 && validUntilEpoch > 0` | "Session: expires [formatted date]" | +| `bootsRemaining == 0 && validUntilEpoch == 0` | "Session: no expiry configured" | + +--- + +## LockNowButton + +```kotlin +/** + * "Lock Now" button for Security settings. Only enabled when the node is + * UNLOCKED and lockdown is applicable. + * + * @param isEnabled true when node is unlocked and user can issue lock-now + * @param onClick Callback to trigger lock-now via LockdownCoordinator + */ +@Composable +fun LockNowButton( + isEnabled: Boolean, + onClick: () -> Unit, +) +``` + +### Visibility Rules + +| Coordinator State | Button State | +|-------------------|-------------| +| `NotApplicable` | Hidden (node doesn't support lockdown) | +| `Unlocked` | Visible + Enabled | +| `Locked` / `NeedsProvision` | Visible + Disabled with "Device is locked" hint | +| `LockNowPending` | Visible + Disabled + "Locking..." text | +| `LockNowAcknowledged` | Hidden (disconnecting) | + +--- + +## Integration Point + +The `LockdownScreen` composable is placed at the app's top-level composition: + +```kotlin +// In the main app content composable (after connection established): +val lockdownState by lockdownCoordinator.state.collectAsStateWithLifecycle() + +Box { + // Normal navigation content + MeshtasticNavDisplay(...) + + // Lockdown overlay — blocks everything when active + when (val state = lockdownState) { + is LockdownState.NotApplicable, + is LockdownState.Unlocked -> { /* Normal operation, no overlay */ } + else -> { + LockdownScreen( + state = state, + onSubmitPassphrase = { pass, boots, epoch -> + scope.launch { lockdownCoordinator.submitPassphrase(pass, boots, epoch) } + }, + onDisconnect = { connectionManager.disconnect() }, + ) + } + } +} +``` diff --git a/specs/20260513-075218-lockdown-mode/data-model.md b/specs/20260513-075218-lockdown-mode/data-model.md new file mode 100644 index 0000000000..aab75b5992 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/data-model.md @@ -0,0 +1,134 @@ +# Data Model: Lockdown Mode + +**Feature**: Lockdown Mode +**Date**: 2026-05-13 + +## Domain Entities + +### LockdownState (sealed class) + +The core state machine representing the current lockdown status of the connected node. + +| Variant | Fields | Description | +|---------|--------|-------------| +| `NotApplicable` | — | Node doesn't support lockdown (no `LockdownStatus` received) | +| `NeedsProvision` | — | First-time setup; no passphrase ever set on this device | +| `Locked` | `lockReason: LockdownStatus.State` | Storage locked or client not authenticated; uses proto enum directly | +| `Unlocking` | — | Auth sent; awaiting firmware response | +| `Unlocked` | `bootsRemaining: UInt`, `validUntilEpoch: UInt` | Authenticated; session active with TTL info | +| `UnlockFailed` | `backoffSeconds: UInt` | Passphrase rejected; optional rate-limit | +| `LockNowPending` | — | Lock-now command sent; awaiting firmware ACK | +| `LockNowAcknowledged` | — | Firmware confirmed lock; will disconnect | + +**State Transitions:** + +``` + ┌─────────────────────┐ + │ NotApplicable │ (no LockdownStatus ever received) + └─────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────┐ +│ FromRadio.lockdown_status received │ +└─────────────────────────────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │NeedsProvision│ │ Locked │ │ Unlocked │ + └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ + │ user submits │ user submits / │ user presses + │ passphrase │ auto-replay │ "Lock Now" + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ Unlocking │ │ Unlocking │ │LockNowPending│ + └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ + │ UNLOCKED │ UNLOCK_FAILED │ LOCKED (with + ▼ ▼ │ wasLockNow set) + ┌──────────────┐ ┌──────────────┐ ▼ + │ Unlocked │ │ UnlockFailed │ ┌────────────────────┐ + └──────────────┘ └──────┬───────┘ │LockNowAcknowledged │ + │ └────────┬───────────┘ + │ retry │ + ▼ │ disconnect + ┌──────────────┐ ▼ + │ Locked │ (connection closed) + └──────────────┘ +``` + +**Validation Rules:** +- `passphrase`: 1-32 bytes (non-empty for provision/unlock, ignored for lock-now) +- `bootsRemaining`: 0 = firmware default; any positive value accepted +- `validUntilEpoch`: 0 = no time limit; positive = absolute Unix seconds +- `backoffSeconds`: 0 = no backoff (immediate retry allowed); >0 = enforced wait + +--- + +### LockdownSession (data class) + +Represents the active session info displayed to the user after successful unlock. + +| Field | Type | Description | +|-------|------|-------------| +| `bootsRemaining` | `UInt` | Reboots before token expires (decrements per boot) | +| `validUntilEpoch` | `UInt` | Unix epoch seconds when token expires; 0 = no time limit | + +**Derived properties:** +- `hasTimeLimit: Boolean` = `validUntilEpoch > 0u` +- `isBootLimited: Boolean` = `bootsRemaining > 0u` + +--- + +### CachedPassphrase (per-node storage) + +| Field | Type | Description | +|-------|------|-------------| +| `nodeId` | `Int` | Node number (mesh address) used as storage key | +| `passphrase` | `ByteArray` | Raw passphrase bytes (1-32), encrypted at rest | + +**Storage key format:** `"lockdown_${nodeId.toUInt().toString(16)}"` (hex node ID) + +**Lifecycle:** +- Created/updated on successful unlock (UNLOCKED received after user-entered passphrase) +- Read on reconnection (LOCKED received → auto-replay attempt) +- Deleted when auto-replay fails (UNLOCK_FAILED after cached passphrase sent) +- Never logged or exposed in debug output + +--- + +## Proto Mapping + +### FromRadio.lockdown_status → LockdownState + +| Proto `LockdownStatus.State` | Maps to `LockdownState` | +|------------------------------|-------------------------| +| `NEEDS_PROVISION` | `NeedsProvision` | +| `LOCKED` | `Locked(reason = status.lock_reason)` | +| `UNLOCKED` | `Unlocked(bootsRemaining = status.boots_remaining, validUntilEpoch = status.valid_until_epoch)` | +| `UNLOCK_FAILED` | `UnlockFailed(backoffSeconds = status.backoff_seconds)` | +| `STATE_UNSPECIFIED` | Treated as `Locked(reason = "unknown")` | + +### LockdownAuth → AdminMessage (outgoing) + +| Operation | `passphrase` | `boots_remaining` | `valid_until_epoch` | `lock_now` | +|-----------|-------------|-------------------|--------------------|-----------| +| Provision | user-entered (1-32 bytes) | user-entered or 0 | user-entered or 0 | `false` | +| Unlock | user-entered (1-32 bytes) | 0 (firmware default) | 0 (no limit) | `false` | +| Auto-replay | cached bytes | 0 | 0 | `false` | +| Lock Now | empty/ignored | 0 | 0 | `true` | + +--- + +## Relationships + +``` +LockdownCoordinator (1) ──owns──▶ LockdownState (1, current) +LockdownCoordinator (1) ──uses──▶ LockdownPassphraseStore (1) +LockdownCoordinator (1) ──uses──▶ CommandSender (1, for sending AdminMessage) +LockdownCoordinator (1) ──uses──▶ ConnectionManager (1, for disconnect on lock-now) +FromRadioPacketHandler (1) ──calls──▶ LockdownCoordinator.handleStatus() +UI (LockdownDialog) ──observes──▶ LockdownCoordinator.state (StateFlow) +UI (LockdownDialog) ──calls──▶ LockdownCoordinator.submitPassphrase() +UI (LockNowButton) ──calls──▶ LockdownCoordinator.lockNow() +SecurityConfigScreen ──observes──▶ LockdownCoordinator.state (for session info) +``` diff --git a/specs/20260513-075218-lockdown-mode/plan.md b/specs/20260513-075218-lockdown-mode/plan.md new file mode 100644 index 0000000000..7c0d3c94b7 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/plan.md @@ -0,0 +1,104 @@ +# Implementation Plan: Lockdown Mode + +**Branch**: `feat/lockdown-mode` | **Date**: 2026-05-13 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `specs/20260513-075218-lockdown-mode/spec.md` + +## Summary + +Implement client-side support for firmware lockdown mode using the typed `LockdownAuth` / `LockdownStatus` protobuf contract. The app detects locked nodes via `FromRadio.lockdown_status`, presents a non-dismissable blocking passphrase dialog, sends `AdminMessage.lockdown_auth` for provision/unlock/lock-now operations, caches passphrases in platform-encrypted storage, and auto-replays on reconnect. Architecture uses a `LockdownCoordinator` interface in `commonMain` with platform-specific passphrase store implementations via expect/actual. + +## Technical Context + +**Language/Version**: Kotlin 2.3+ (JDK 21) +**Primary Dependencies**: Compose Multiplatform, Koin 4.2+, Wire (protobuf), Kable (BLE), Okio +**Storage**: EncryptedSharedPreferences (Android), Keychain (iOS), Java KeyStore (Desktop) +**Testing**: `./gradlew test allTests` (KMP modules use `:allTests`, Android-only use `:testFdroidDebugUnitTest`) +**Target Platform**: Android (primary), Desktop (JVM), iOS (future) +**Project Type**: Mobile/Desktop KMP app +**Performance Goals**: Unlock flow < 5s user-perceived latency on BLE +**Constraints**: Passphrase 1-32 bytes, no logging of sensitive data, offline-capable +**Scale/Scope**: 3 new files in commonMain, 1 expect/actual per platform, UI additions to `feature/settings` + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **I. Kotlin Multiplatform Core**: ✅ PASS + - `commonMain`: `LockdownCoordinator` interface, `LockdownState` sealed class, `LockdownPassphraseStore` interface, UI composables (dialog, lock-now button, session status) + - `androidMain`: `LockdownPassphraseStoreImpl` (EncryptedSharedPreferences) + - `jvmMain`: `LockdownPassphraseStoreImpl` (Java KeyStore file-backed) + - `iosMain`: `LockdownPassphraseStoreImpl` (Keychain) — stub for now + - No `java.*` or `android.*` imports in commonMain. All business logic in commonMain. + +- **II. Zero Lint Tolerance**: ✅ PASS + - Commands: `./gradlew spotlessApply spotlessCheck detekt` + - Modules touched: `:core:model`, `:core:repository`, `:core:data`, `:core:datastore`, `:feature:settings` + +- **III. Compose Multiplatform UI**: ✅ PASS + - Lockdown dialog is a non-dismissable `AlertDialog` composable in commonMain (`onDismissRequest = {}` + `BackHandler`) + - No `NavigationBackHandler` needed (dialog blocks all interaction; dismiss = disconnect) + - No float formatting needed (TTL displayed as integer boot count / formatted date string) + +- **IV. Privacy First**: ✅ PASS + - Passphrases stored only in encrypted platform storage, never logged + - No modification to `core/proto` (read-only submodule) + - No PII exposure — node IDs used as cache keys (already public on mesh) + +- **V. Design Standards Compliance**: ✅ PASS + - Cross-Platform Spec: N/A — platform-specific client UI for firmware protocol (lockdown is transport-layer, not a mesh behavior) + - UI uses M3 components: `OutlinedTextField` (passphrase), `FilledTonalButton` (Lock Now), `AlertDialog` (errors) + - Accessibility: password field with content description, touch targets met + +- **VI. Verify Before Push**: ✅ PASS + - Local: `./gradlew spotlessApply detekt assembleDebug test allTests` + - Post-push: `gh pr checks ` or `gh run list --branch feat/lockdown-mode --limit 5` + +## Project Structure + +### Documentation (this feature) + +```text +specs/20260513-075218-lockdown-mode/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output (internal interfaces) +└── tasks.md # Phase 2 output (/speckit.tasks command) +``` + +### Source Code (repository root) + +```text +core/model/src/commonMain/kotlin/org/meshtastic/core/model/ +└── lockdown/ + └── LockdownState.kt # Sealed class for lockdown states + +core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ +├── LockdownCoordinator.kt # Interface: lockdown lifecycle owner +└── LockdownPassphraseStore.kt # Interface: encrypted per-node cache + +core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/ +└── LockdownCoordinatorImpl.kt # Implementation: state machine, auto-replay + +core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/ +└── LockdownPassphraseStoreImpl.kt # EncryptedSharedPreferences impl + +core/datastore/src/jvmMain/kotlin/org/meshtastic/core/datastore/ +└── LockdownPassphraseStoreImpl.kt # Java KeyStore impl + +core/datastore/src/iosMain/kotlin/org/meshtastic/core/datastore/ +└── LockdownPassphraseStoreImpl.kt # Keychain impl (stub) + +feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ +└── lockdown/ + ├── LockdownDialog.kt # Non-dismissable AlertDialog passphrase entry/provision modal + ├── LockdownSessionStatus.kt # Session TTL display composable + └── LockNowButton.kt # Lock Now action in Security settings +``` + +**Structure Decision**: KMP multi-module with existing module boundaries. New code distributed across `core/model`, `core/repository`, `core/data`, `core/datastore`, and `feature/settings`. No new Gradle modules needed. + +## Complexity Tracking + +No constitution violations. All gates pass. diff --git a/specs/20260513-075218-lockdown-mode/quickstart.md b/specs/20260513-075218-lockdown-mode/quickstart.md new file mode 100644 index 0000000000..4e6abc80a2 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/quickstart.md @@ -0,0 +1,96 @@ +# Quickstart: Lockdown Mode + +**Feature**: Lockdown Mode +**Date**: 2026-05-13 + +## Prerequisites + +- JDK 21 installed, `ANDROID_HOME` set +- Proto submodule initialized: `git submodule update --init` +- `local.properties` exists (copy from `secrets.defaults.properties` if missing) +- Proto submodule bumped to revision containing `LockdownAuth` (admin.proto tag 104) and `LockdownStatus` (mesh.proto tag 18). See protobufs#911. + +## Quick Verification + +```bash +# Full build + test cycle for all touched modules +./gradlew spotlessApply detekt assembleDebug test allTests + +# Module-specific checks +./gradlew :core:model:allTests +./gradlew :core:repository:allTests +./gradlew :core:data:allTests +./gradlew :core:datastore:allTests +./gradlew :feature:settings:allTests +``` + +## Implementation Order + +1. **`core/model`** — `LockdownState` sealed class (no dependencies) +2. **`core/repository`** — `LockdownCoordinator` interface + `LockdownPassphraseStore` interface +3. **`core/datastore`** — Platform implementations of `LockdownPassphraseStore` (Android real, JVM/iOS stubs) +4. **`core/data`** — `LockdownCoordinatorImpl` (state machine, auto-replay logic) +5. **`core/data`** — Wire `FromRadioPacketHandlerImpl` to route `lockdown_status` to coordinator +6. **`feature/settings`** — `LockdownDialog` (non-dismissable AlertDialog), `LockdownSessionStatus`, `LockNowButton` +7. **App shell** — Show `LockdownDialog` when lockdown state requires auth +8. **Banner gating** — Add `isAuthorized` checks to action-prompting banners + +## Key Files to Modify + +| File | Change | +|------|--------| +| `core/data/.../FromRadioPacketHandlerImpl.kt` | Add `lockdown_status` branch in `when` block | +| `core/data/.../CommandSenderImpl.kt` | Add `sendLockdownAuth()` helper (or inline in coordinator) | +| `feature/settings/.../SecurityConfigScreen.kt` | Add `LockdownSessionStatus` + `LockNowButton` | +| App top-level composable | Add lockdown state observation + `LockdownScreen` overlay | + +## Key Files to Create + +| File | Module | Source Set | +|------|--------|-----------| +| `LockdownState.kt` | `core/model` | commonMain | +| `LockdownCoordinator.kt` | `core/repository` | commonMain | +| `LockdownPassphraseStore.kt` | `core/repository` | commonMain | +| `LockdownCoordinatorImpl.kt` | `core/data` | commonMain | +| `LockdownPassphraseStoreImpl.kt` | `core/datastore` | androidMain | +| `LockdownPassphraseStoreImpl.kt` | `core/datastore` | jvmMain | +| `LockdownPassphraseStoreImpl.kt` | `core/datastore` | iosMain | +| `LockdownScreen.kt` | `feature/settings` | commonMain | +| `LockdownSessionStatus.kt` | `feature/settings` | commonMain | +| `LockNowButton.kt` | `feature/settings` | commonMain | + +## Testing Strategy + +### Unit Tests (commonMain) + +- `LockdownCoordinatorImpl` state machine transitions +- Auto-replay logic (cached passphrase → auto-submit on LOCKED) +- Cache-clear-on-failure logic (UNLOCK_FAILED after auto-replay → clear) +- Lock-now flag tracking (wasLockNow → LockNowAcknowledged on LOCKED) +- Backoff enforcement (timer expires before retry allowed) + +### Integration Testing + +Requires a device flashed with LOCKDOWN firmware build: +- Provision flow (fresh device → set passphrase → UNLOCKED) +- Unlock flow (locked device → enter passphrase → UNLOCKED) +- Auto-replay (disconnect → reconnect → auto-unlocked without prompt) +- Wrong passphrase (→ UNLOCK_FAILED, retry) +- Backoff (multiple wrong attempts → countdown) +- Lock Now (→ device reboots → next connection requires auth) +- Token expiry (set short TTL → reboot past limit → LOCKED) + +## Dependencies + +| Dependency | Module | Purpose | +|-----------|--------|---------| +| `androidx.security:security-crypto` | `core/datastore` (androidMain) | EncryptedSharedPreferences | +| Wire-generated protos | `core/proto` | `LockdownAuth`, `LockdownStatus`, `AdminMessage` | + +## Common Pitfalls + +1. **Proto submodule not bumped**: `LockdownAuth` and `LockdownStatus` don't exist until the proto submodule includes protobufs#911. Build will fail with unresolved references. +2. **`when` exhaustiveness**: New `ModemPreset` enum entries from the proto bump will break exhaustive `when` blocks in `Channel.kt`, `ChannelOption.kt`, `ModelExtensions.kt`. Fix those separately from lockdown changes. +3. **Passphrase encoding**: Proto defines `bytes passphrase = 1`. Use `ByteString` / `ByteArray` directly — do NOT convert to/from UTF-8 String (passphrases may contain arbitrary bytes). +4. **Node ID for local device**: Use `serviceRepository.myNodeNum` (or equivalent) as `destNum` when sending admin messages to the locally-connected node. +5. **Testing without hardware**: The lockdown state machine can be unit-tested by mocking the `LockdownPassphraseStore` and calling `handleStatus()` directly with constructed `LockdownStatus` protos. diff --git a/specs/20260513-075218-lockdown-mode/research.md b/specs/20260513-075218-lockdown-mode/research.md new file mode 100644 index 0000000000..65d7d472c9 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/research.md @@ -0,0 +1,140 @@ +# Research: Lockdown Mode + +**Feature**: Lockdown Mode +**Date**: 2026-05-13 +**Status**: Complete + +## Research Tasks + +### 1. FromRadio lockdown_status Integration Point + +**Question**: Where and how to wire `FromRadio.lockdown_status` (field 18) into the existing packet handling pipeline? + +**Finding**: `FromRadioPacketHandlerImpl.handleFromRadio()` uses a `when` block dispatching on non-null proto fields. The `lockdown_status` field arrives as a `LockdownStatus?` from the generated Wire class. It can arrive: +- Immediately after `config_complete_id` (initial connection state report) +- In response to any `AdminMessage.lockdown_auth` sent by the client + +**Decision**: Add `proto.lockdown_status` as a new branch in the `when` block in `FromRadioPacketHandlerImpl`, routing to `LockdownCoordinator.handleStatus(status)`. Place after `configCompleteId` handling since that's the natural ordering. + +**Alternatives considered**: +- Handling inside `configFlowManager.handleConfigComplete()` — rejected because lockdown_status also arrives asynchronously after admin commands, not just during config flow. +- Using a separate packet filter/interceptor — rejected; overengineered for a single field dispatch. + +--- + +### 2. Admin Message Sending Pattern for LockdownAuth + +**Question**: What's the correct pattern for sending `AdminMessage.lockdown_auth`? + +**Finding**: `CommandSender.sendAdmin()` takes a `destNum`, optional `requestId`, `wantResponse`, and a lambda `initFn: () -> AdminMessage`. The node number for the locally-connected node comes from `ServiceRepository` (myNodeNum). Example: + +```kotlin +commandSender.sendAdmin(myNodeNum, wantResponse = true) { + AdminMessage(lockdown_auth = LockdownAuth( + passphrase = passphraseBytes, + boots_remaining = bootsRemaining, // 0 = firmware default + valid_until_epoch = validUntilEpoch, // 0 = no time limit + lock_now = false, + )) +} +``` + +**Decision**: Add `sendLockdownAuth(passphrase: ByteArray, bootsRemaining: UInt, validUntilEpoch: UInt, lockNow: Boolean)` method to `LockdownCoordinator` which delegates to `commandSender.sendAdmin()`. Use `wantResponse = true` since firmware always replies with `LockdownStatus`. + +**Alternatives considered**: +- `sendAdminAwait()` (suspend + await ACK) — rejected because the "response" is a `FromRadio.lockdown_status`, not a standard admin ACK. The coordinator processes it asynchronously via the `handleStatus()` callback. + +--- + +### 3. Encrypted Passphrase Storage (Platform Patterns) + +**Question**: Best approach for per-node encrypted passphrase caching across platforms? + +**Finding**: +- **Android**: `EncryptedSharedPreferences` from AndroidX Security Crypto. Key = node ID (hex string), value = Base64-encoded passphrase bytes. Already a dependency in the project. +- **JVM/Desktop**: `java.security.KeyStore` with JCEKS type, or simpler: AES-encrypt a JSON file using a key derived from a fixed seed in the app's data directory. For stubs, a no-op (passphrase never cached) is acceptable. +- **iOS**: Keychain Services via `Security` framework. For stubs, no-op is acceptable. + +**Decision**: Interface `LockdownPassphraseStore` in commonMain: +```kotlin +interface LockdownPassphraseStore { + suspend fun get(nodeId: Int): ByteArray? + suspend fun put(nodeId: Int, passphrase: ByteArray) + suspend fun clear(nodeId: Int) +} +``` +Android: real implementation with EncryptedSharedPreferences. +JVM/iOS: no-op stubs returning null/doing nothing (passphrase never cached, user always prompted). + +**Alternatives considered**: +- DataStore Proto with encryption — rejected; DataStore doesn't natively support encryption and adding custom serialization adds complexity for a simple key-value store. +- Multiplatform Keystore library (e.g., multiplatform-settings) — rejected; adds a dependency for one small use case. The interface is trivial to implement per-platform. + +--- + +### 4. Blocking Dialog (Compose Multiplatform Pattern) + +**Question**: How to implement a blocking dialog that prevents all navigation in Compose Multiplatform? + +**Finding**: The current navigation uses `MeshtasticNavDisplay`. A non-dismissable dialog can be achieved by: +1. Observing `LockdownCoordinator.state` as a `StateFlow` in the top-level composable +2. When state is `Locked` or `NeedsProvision`, rendering a non-dismissable `AlertDialog` with `onDismissRequest = {}` and `BackHandler {}` to intercept back presses +3. The dialog owns its own state (passphrase text, validation, backoff timer) + +**Decision**: Show a non-dismissable `AlertDialog` from the app's main content composition when lockdown is active. The `onDismissRequest = {}` prevents touch-outside dismiss, and `BackHandler {}` blocks back navigation. When not active (unlocked or no lockdown on this node), normal navigation proceeds. + +**Alternatives considered**: +- Full-screen Scaffold overlay — rejected; adds unnecessary complexity when AlertDialog achieves the same blocking behavior with `onDismissRequest = {}`. +- Navigation route that blocks back navigation — rejected; adds complexity to the nav graph and doesn't truly "block" since routes can be deep-linked. + +--- + +### 5. LockdownCoordinator State Machine + +**Question**: What states does the coordinator need to manage? + +**Finding**: Based on the proto contract and spec requirements: + +``` +States: + NotApplicable — Connected node doesn't use lockdown (no LockdownStatus received) + NeedsProvision — NEEDS_PROVISION received; awaiting user passphrase creation + Locked — LOCKED received; awaiting user passphrase entry or auto-replay + Unlocking — Auth sent; waiting for firmware response + Unlocked(session) — UNLOCKED received with boots_remaining + valid_until_epoch + UnlockFailed(info) — UNLOCK_FAILED received with optional backoff + LockNowPending — Lock-now sent; awaiting LOCKED ACK + LockNowAcknowledged — ACK received; will disconnect +``` + +**Decision**: Sealed class `LockdownState` with these variants. The coordinator manages transitions and exposes state as `StateFlow`. Auto-replay triggers automatically when entering `Locked` state if a cached passphrase exists for the node. + +**Alternatives considered**: +- Simpler 3-state model (Locked/Unlocked/None) — rejected; insufficient for backoff enforcement, lock-now ACK tracking, and pending states. + +--- + +### 6. Lock Now Explicit Disconnect + +**Question**: How to explicitly disconnect after LockNowAcknowledged? + +**Finding**: The existing `MeshConnectionManager` has a `disconnect()` method (or equivalent) that tears down the BLE/Serial/TCP connection. Nick's PR already has the `wasLockNow` flag — just needs one line to call disconnect after transitioning to `LockNowAcknowledged`. + +**Decision**: In `LockdownCoordinatorImpl`, when transitioning to `LockNowAcknowledged`: post a short delay (500ms for UX feedback), then call the connection manager's disconnect. This gives the UI a moment to show "Lock confirmed" before the connection drops. + +**Alternatives considered**: +- Immediate disconnect (no delay) — acceptable but feels abrupt; user gets no visual confirmation. +- Rely on firmware reboot — rejected per spec; non-deterministic timing. + +--- + +### 7. Banner Gating Architecture + +**Question**: How to suppress action-prompting banners when locked? + +**Finding**: Banners in the app are typically rendered conditionally in composables. The "Region Unset" banner is in the connections screen. Other potential banners: firmware update prompts, channel configuration warnings. + +**Decision**: Expose `isLockdownAuthorized: StateFlow` from `LockdownCoordinator`. This is `true` when state is `Unlocked` or `NotApplicable`, `false` otherwise. Banner composables that prompt user action gate their visibility on this flag. Since the full-screen modal blocks navigation anyway (FR-012), this is a defense-in-depth measure for any briefly-visible content during state transitions. + +**Alternatives considered**: +- Per-banner individual gating logic — rejected; centralized flag is simpler and less error-prone. diff --git a/specs/20260513-075218-lockdown-mode/spec.md b/specs/20260513-075218-lockdown-mode/spec.md new file mode 100644 index 0000000000..e4a86d96eb --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/spec.md @@ -0,0 +1,222 @@ +# Feature Specification: Lockdown Mode + +**Feature Branch**: `feat/lockdown-mode` +**Created**: 2026-05-13 +**Status**: Draft +**Input**: User description: "Implement lockdown mode using new lockdown protobufs and Nick's previous proof of concept (PR #4703)" +**Cross-Platform Spec**: N/A — platform-specific client implementation of firmware-driven lockdown protocol + +## Summary + +Lockdown mode protects unattended Meshtastic nodes from unauthorized physical access. When enabled on firmware, a connecting client must provide a passphrase before it can view or modify the node's actual configuration. The Android app needs to detect locked nodes, prompt for authentication, cache credentials securely, display session status, and provide a "Lock Now" action to immediately re-lock the device. + +## Clarifications + +### Session 2026-05-13 + +- Q: Should lockdown block all navigation or only gate config screens? → A: Non-dismissable blocking dialog; user must unlock/provision before accessing any app functionality +- Q: Should the app expose TTL fields (boots_remaining, valid_until_epoch) to the user or always use firmware defaults? → A: Optional fields — show "boots remaining" and "hours until expiry" as optional inputs, default to firmware values when left empty +- Q: Should coordinator and passphrase store be full KMP (commonMain interface + expect/actual) or Android-only initially? → A: Full KMP — coordinator interface + passphrase store interface in commonMain; platform implementations via expect/actual +- Q: Should "Lock Now" use a client-side flag to await firmware ACK, or fire-and-disconnect immediately? → A: Client-side flag — track wasLockNow, route next LOCKED status to "Lock confirmed" state, then disconnect gracefully +- Q: Should all action-prompting banners be gated on lockdown auth, or only the region-unset banner? → A: All action-prompting banners — suppress any banner that asks users to change config they cannot access while locked + +### Gap Analysis (PR #5439 review, 2026-05-13) + +Gaps identified between this spec and Nick's PR #5439 implementation. All spec requirements hold; PR should be updated to align: + +1. ~~FR-012: Replace AlertDialog with full-screen blocking Scaffold~~ → Non-dismissable AlertDialog with `onDismissRequest = {}` + `BackHandler` is sufficient (already in PR) +2. FR-013: Audit and gate all action-prompting banners (not just region-unset) +3. FR-005: Make TTL inputs nullable; send 0 when empty (not hardcoded boots=50) +4. KMP: Extract `LockdownPassphraseStore` interface to commonMain; Android actual impl; iOS/JVM no-op stubs. Move dialog to `feature/settings` commonMain. +5. US3-AC2: Explicitly disconnect via RadioController after LockNowAcknowledged (don't rely on firmware reboot alone) +6. US3-AC4: Hide/disable Lock Now button when `sessionAuthorized=false` +7. US5: Add dedicated session status row above Lock Now button (not embedded in button label) +8. NFR-002: Audit logs; redact device addresses to last 4 chars +9. iOS/JVM: Provide no-op stub implementations of `LockdownPassphraseStore` + +## Goals + +1. Enable users to authenticate against locked-down nodes so they can access real device configuration over BLE/USB +2. Allow first-time passphrase provisioning on unprovisioned hardened nodes +3. Provide clear visibility into the current lockdown state (locked, unlocked, session TTL) +4. Allow users to immediately re-lock a device with a single action +5. Securely cache passphrases locally so reconnections don't require re-entry every time + +## Non-Goals + +- Implementing lockdown logic in firmware (firmware handles encryption, token management, DEK generation) +- Modifying the protobuf definitions (these are read-only upstream in `core/proto`) +- Providing remote lock/unlock over the mesh network (lockdown is local connection only) +- Managing lockdown across multiple nodes simultaneously in a single flow +- Implementing a passphrase strength meter or password policy enforcement + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Unlock a Locked Node (Priority: P1) + +A user connects to a node that has lockdown mode enabled and is currently locked. The app detects the `LockdownStatus.LOCKED` state from the firmware and prompts the user to enter the passphrase. Upon successful entry, the node unlocks and the user can view/edit configurations normally. + +**Why this priority**: This is the core interaction — without unlock capability, lockdown-enabled nodes are inaccessible from the app. + +**Independent Test**: Connect to a locked node via BLE, enter the correct passphrase, and verify that full configuration becomes accessible. + +**Acceptance Scenarios**: + +1. **Given** the app connects to a node reporting `LockdownStatus.State.LOCKED`, **When** the connection completes and config is received, **Then** the app displays a passphrase entry dialog before allowing access to settings +2. **Given** the user enters the correct passphrase, **When** the `LockdownAuth` admin message is sent, **Then** the firmware responds with `LockdownStatus.State.UNLOCKED` and the app displays the real device configuration +3. **Given** the user enters an incorrect passphrase, **When** the firmware responds with `LockdownStatus.State.UNLOCK_FAILED`, **Then** the app displays an error message and allows retry +4. **Given** the firmware responds with `UNLOCK_FAILED` and a non-zero `backoff_seconds`, **When** the user sees the error, **Then** the app enforces the backoff period before allowing another attempt + +--- + +### User Story 2 - Provision a New Lockdown Passphrase (Priority: P1) + +A user connects to a hardened firmware node that has never been provisioned (no passphrase set). The app detects `LockdownStatus.State.NEEDS_PROVISION` and prompts the user to create a passphrase. Upon successful provisioning, the firmware generates a DEK and the node is unlocked for the current session. + +**Why this priority**: Without provisioning, a hardened node cannot be secured — this is the setup path. + +**Independent Test**: Connect to an unprovisioned node, set a passphrase, and verify the node transitions to UNLOCKED state. + +**Acceptance Scenarios**: + +1. **Given** the app connects to a node reporting `LockdownStatus.State.NEEDS_PROVISION`, **When** the config complete is received, **Then** the app prompts the user to create a new passphrase +2. **Given** the user enters and confirms a passphrase (1-32 bytes), **When** the `LockdownAuth` message is sent with `lock_now=false`, **Then** the firmware provisions the DEK and responds with `UNLOCKED` +3. **Given** the user is in the provisioning flow, **When** they attempt to set an empty passphrase, **Then** the app prevents submission and shows a validation message + +--- + +### User Story 3 - Lock Now (Priority: P2) + +A user who has an unlocked session wants to immediately re-lock the device (e.g., before leaving it unattended). They press a "Lock Now" button in the Security settings. The device revokes all authorization, wipes RAM, and reboots into the locked state. + +**Why this priority**: Provides active security control but the device will also lock on its own when the token expires. + +**Independent Test**: With an unlocked node, press "Lock Now" and verify the node reboots and subsequent connection requires passphrase. + +**Acceptance Scenarios**: + +1. **Given** the node is in `UNLOCKED` state, **When** the user presses "Lock Now" in Settings → Security, **Then** the app sends `LockdownAuth(lock_now=true)` and sets a client-side `wasLockNow` flag +2. **Given** the app has sent lock-now and set `wasLockNow`, **When** firmware responds with `LOCKED` status, **Then** the app routes to a "Lock confirmed" state (no passphrase dialog flash) and disconnects gracefully +3. **Given** the user presses "Lock Now", **When** the device reboots, **Then** the next connection attempt shows the node as `LOCKED` requiring re-authentication +4. **Given** the user has not yet unlocked the node, **When** they view Security settings, **Then** the "Lock Now" button is not available (or clearly indicates the device is already locked) + +--- + +### User Story 4 - Cached Passphrase Auto-Reconnect (Priority: P2) + +A user who has previously authenticated to a node reconnects (e.g., after a brief disconnection or app restart). The app retrieves the cached passphrase and automatically sends the unlock without prompting the user again. + +**Why this priority**: Improves UX for frequent reconnections but is not required for basic functionality. + +**Independent Test**: Authenticate to a node, disconnect, reconnect, and verify no passphrase prompt appears. + +**Acceptance Scenarios**: + +1. **Given** the user previously authenticated with a correct passphrase, **When** the app reconnects and receives `LOCKED` status, **Then** the app automatically replays the cached passphrase +2. **Given** the cached passphrase is no longer valid (firmware reports `UNLOCK_FAILED`), **When** auto-replay fails, **Then** the app clears the cache and prompts the user to enter the passphrase manually +3. **Given** the user has never authenticated to a particular node, **When** connecting for the first time, **Then** no auto-replay occurs and the standard prompt is shown + +--- + +### User Story 5 - View Session Token Status (Priority: P3) + +A user with an unlocked session can view the remaining session lifetime (boots remaining, expiry time) in the Security settings area, so they know when re-authentication will be required. + +**Why this priority**: Informational — improves awareness but doesn't affect core functionality. + +**Independent Test**: Unlock a node and verify the session info (boots remaining, time until expiry) is displayed. + +**Acceptance Scenarios**: + +1. **Given** the node is `UNLOCKED` with `boots_remaining=5` and `valid_until_epoch` set, **When** the user views Security settings, **Then** the remaining boots and expiry time are displayed in a human-readable format +2. **Given** the node is `UNLOCKED` with `valid_until_epoch=0`, **When** the user views session info, **Then** the app shows "No time limit" for the expiry field + +--- + +### Edge Cases + +- What happens when the BLE connection drops mid-authentication? The app should treat the auth as incomplete and re-prompt on reconnect. +- How does the app handle a node that transitions from locked to unlocked by another client? The firmware sends a new `LockdownStatus` which the app processes and updates UI state. +- What if the user's cached passphrase is for a node that has been re-provisioned? Auto-replay fails, cache is cleared, user is prompted. +- What happens if the device clock is wrong and `valid_until_epoch` appears expired? The client displays the firmware-reported state as-is (lockdown decisions are firmware-side). + +## Architecture + +### Key Components + +| Component | Module / File | Purpose | +|-----------|---------------|---------| +| LockdownStatus handler | `core/data/` | Processes `FromRadio.lockdown_status` packets via `FromRadioPacketHandlerImpl` | +| LockdownAuth sender | `core/data/` | Sends `AdminMessage.lockdown_auth` via `CommandSenderImpl` | +| Lockdown UI (dialog) | `feature/settings/` | Passphrase entry/provisioning dialog and session status display | +| Lock Now action | `feature/settings/` | Button in Security settings to trigger immediate re-lock | +| Passphrase cache | `core/datastore/` | Encrypted local storage of per-node cached passphrases | +| Lockdown state model | `core/model/` | Domain model representing lockdown state for UI consumption | + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: App MUST detect and handle `LockdownStatus` in the `FromRadio` packet stream after config complete +- **FR-002**: App MUST display a passphrase entry dialog when the connected node reports `LOCKED` state +- **FR-003**: App MUST display a passphrase creation dialog when the connected node reports `NEEDS_PROVISION` state +- **FR-004**: App MUST send `LockdownAuth` admin messages with the user-supplied passphrase to unlock/provision +- **FR-005**: App MUST present optional "boots remaining" and "hours until expiry" input fields in the passphrase dialog; when left empty, send 0 (0 = firmware defaults apply per `LockdownAuth` proto contract) +- **FR-006**: App MUST display error feedback when firmware reports `UNLOCK_FAILED`, including backoff countdown when `backoff_seconds > 0` +- **FR-007**: App MUST provide a "Lock Now" action that sends `LockdownAuth(lock_now=true)` to the node +- **FR-008**: App MUST cache passphrases in encrypted local storage, keyed per node +- **FR-009**: App MUST auto-replay cached passphrase on reconnection to a previously-authenticated locked node +- **FR-010**: App MUST clear cached passphrase when auto-replay results in `UNLOCK_FAILED` +- **FR-011**: App MUST display session token TTL info (boots remaining, expiry) when the node is unlocked +- **FR-012**: App MUST present a non-dismissable blocking dialog when in `LOCKED` or `NEEDS_PROVISION` state, preventing all navigation until the user resolves lockdown (non-dismissable AlertDialog with BackHandler is acceptable) +- **FR-013**: App MUST suppress all action-prompting banners (e.g., "Region Unset", configuration warnings) when the connected node is lockdown-enabled but not yet authorized, since the user cannot act on them + +### Non-Functional Requirements + +- **NFR-001**: Cached passphrases MUST be stored using platform-appropriate encrypted storage (EncryptedSharedPreferences on Android, Keychain on iOS, encrypted file on Desktop) +- **NFR-002**: Passphrase entry dialog MUST NOT log or expose passphrase bytes in debug output +- **NFR-003**: Unlock flow MUST complete within 5 seconds on a standard BLE connection (user-perceived latency from submit to unlocked state) + +## Source-Set Impact + +| Source Set | Impact | Justification | +|-----------|--------|---------------| +| `commonMain` | LockdownCoordinator interface, LockdownState model, passphrase store interface, UI composables (unlock dialog, lock-now button, session status) | All business logic and UI per Constitution §I | +| `androidMain` | `LockdownPassphraseStore` impl (EncryptedSharedPreferences), AIDL plumbing for sendLockdownUnlock/sendLockNow | Platform-specific secure storage + IPC | +| `iosMain` | `LockdownPassphraseStore` impl (Keychain) | Platform-specific secure storage | +| `jvmMain` | `LockdownPassphraseStore` impl (encrypted file or Java KeyStore) | Platform-specific secure storage | + +## Design Standards Compliance + +- [ ] New screens reviewed against [design standards](https://raw.githubusercontent.com/meshtastic/design/refs/heads/master/standards/meshtastic_design_standards_latest.md) +- [ ] M3 component selection verified (e.g., `OutlinedTextField` for passphrase, `FilledTonalButton` for Lock Now) +- [ ] Accessibility: TalkBack semantics, touch targets, color-independent info +- [ ] Typography: `titleMediumEmphasized` for emphasis, M3 scale for hierarchy + +## Privacy Assessment + +- [ ] No PII, location data, or cryptographic keys logged or exposed +- [ ] Passphrases stored only in encrypted platform storage, never in plaintext +- [ ] No new network calls that transmit user data (lockdown is local connection only) +- [ ] Proto submodule (`core/proto`) not modified (read-only upstream) + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can unlock a locked node and access full configuration within 10 seconds of entering the correct passphrase +- **SC-002**: Users connecting to an unprovisioned node can set a passphrase and reach unlocked state in a single flow without confusion +- **SC-003**: "Lock Now" action results in the device rebooting to locked state within 5 seconds of user action +- **SC-004**: Returning users with cached passphrase reconnect without manual re-entry in 95% of cases (cache hit) +- **SC-005**: Zero passphrase bytes appear in any application log output at any log level + +## Assumptions + +- All business logic and UI composables reside in `commonMain` source set +- String resources added to `core/resources/src/commonMain/composeResources/values/strings.xml` +- Icons use `MeshtasticIcons` (from `core/ui/icon/`) +- The firmware correctly implements the `LockdownAuth` / `LockdownStatus` protobuf contract as defined in `admin.proto` and `mesh.proto` +- The existing `FromRadio` packet handling infrastructure can be extended to process the new `lockdown_status` field (field 18) +- Passphrase is limited to 1-32 bytes as specified in the proto definition +- The app does not need to determine whether a node is "hardened" — it simply reacts to `LockdownStatus` presence +- Token TTL parameters (boots_remaining, valid_until_epoch) use firmware defaults when not specified by the user diff --git a/specs/20260513-075218-lockdown-mode/tasks.md b/specs/20260513-075218-lockdown-mode/tasks.md new file mode 100644 index 0000000000..621abcf523 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/tasks.md @@ -0,0 +1,213 @@ +# Tasks: Lockdown Mode + +**Input**: Design documents from `specs/20260513-075218-lockdown-mode/` +**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅, quickstart.md ✅ +**Base**: Building on Nick's PR #5439 (`features/lockdown-v2` branch, 785+ additions) + +## Phase 0: Cherry-pick PR #5439 + +**Purpose**: Establish baseline from Nick's working proof-of-concept before refactoring + +- [ ] T000a Fetch Nick's `features/lockdown-v2` branch and cherry-pick/rebase onto `feat/lockdown-mode` (resolve conflicts against current `origin/main`) +- [ ] T000b Verify cherry-picked code compiles: `./gradlew assembleDebug` (expect lint/detekt issues — fix in later phases) +- [ ] T000c Inventory PR files for subsequent refactoring: identify which files stay as-is, which move modules, which need interface extraction + +--- + +## Phase 1: Setup + +**Purpose**: Establish module structure and dependencies for lockdown feature + +- [ ] T001 Create `core/model/src/commonMain/kotlin/org/meshtastic/core/model/lockdown/` package directory +- [ ] T002 [P] Verify `androidx.security:security-crypto` dependency exists in `core/datastore/build.gradle.kts` (already added by PR — confirm in correct module) +- [ ] T003 [P] Verify proto submodule contains `LockdownAuth` and `LockdownStatus` generated classes in `core/proto/build/generated/source/wire/org/meshtastic/proto/` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Extract and refactor PR #5439 code into proper KMP architecture + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +**Note**: Nick's PR contains working implementations for most of these. Tasks below specify what to **port/refactor** from the PR rather than creating from scratch. + +- [ ] T004 Port `LockdownState` sealed class from PR's `core/model/.../LockdownState.kt` → refactor to add `Locked(lockReason: LockdownStatus.State)` (use proto enum directly, not Int), verify 8 variants match spec: NotApplicable, NeedsProvision, Locked, Unlocking, Unlocked(bootsRemaining, validUntilEpoch), UnlockFailed(backoffSeconds), LockNowPending, LockNowAcknowledged +- [ ] T005 [P] Extract `LockdownCoordinator` interface from PR's concrete class to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt` — add lifecycle hooks from PR: `onConnect(nodeId: Int)`, `onConfigComplete()`, `onDisconnect()` alongside `state`, `isAuthorized`, `handleStatus()`, `submitPassphrase()`, `lockNow()` +- [ ] T006 [P] Extract `LockdownPassphraseStore` interface from PR's concrete class to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt` with get(nodeId), put(nodeId, passphrase), clear(nodeId) +- [ ] T007 Move PR's `LockdownPassphraseStore` impl from `core/service/src/androidMain/` to `core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` — keep EncryptedSharedPreferences logic, implement extracted interface +- [ ] T008 [P] Create `LockdownPassphraseStoreImpl` no-op stub for JVM in `core/datastore/src/jvmMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` +- [ ] T009 [P] Create `LockdownPassphraseStoreImpl` no-op stub for iOS in `core/datastore/src/iosMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` +- [ ] T010 Extract state machine logic from PR's `LockdownHandlerImpl` (currently in `core/service/src/androidMain/`) to `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt` — keep auto-replay, wasLockNow flag, pending passphrase tracking. Remove Android/AIDL dependencies so it compiles in commonMain. +- [ ] T010b Keep thin AIDL adapter in `core/service/src/androidMain/` that delegates to `LockdownCoordinatorImpl` for `MeshService` IPC calls (`sendLockdownPassphrase`, `sendLockNow`) +- [ ] T011 Verify PR's `FromRadioPacketHandlerImpl` `lockdown_status` dispatch is intact; add `coordinator.onConfigComplete()` call from config completion handler if not already present +- [ ] T012 Verify PR's `CommandSenderImpl` extensions (`sendLockdownPassphrase`/`sendLockNow`) are intact; adapt method signatures if coordinator interface changed +- [ ] T012b Wire `LockdownCoordinator.onConnect(nodeId)`/`onDisconnect()` into `MeshConnectionManagerImpl` connection lifecycle callbacks (port from PR's existing wiring) +- [ ] T012c Expose `lockdownState: StateFlow` and `sessionAuthorized: StateFlow` via `ServiceRepository` (port from PR's existing exposure) +- [ ] T013 Register `LockdownCoordinator` and `LockdownPassphraseStore` bindings in Koin DI — use `@Single` annotation on impl classes (`LockdownCoordinatorImpl`, `LockdownPassphraseStoreImpl`) and `@Module` on containing Koin module per project convention + +**Checkpoint**: Foundation ready — coordinator processes lockdown status, sends auth, manages state. AIDL layer delegates to coordinator. User story UI can begin. + +--- + +## Phase 3: User Story 1 — Unlock a Locked Node (Priority: P1) 🎯 MVP + +**Goal**: User connects to a locked node, enters passphrase, node unlocks, full config accessible. + +**Independent Test**: Connect to a locked node → enter correct passphrase → verify UNLOCKED state and config access. + +### Implementation for User Story 1 + +- [ ] T014 [US1] Move and refactor Nick's `LockdownUnlockDialog` from `app/src/main/.../ui/` to `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt` — adapt to non-dismissable AlertDialog composable with passphrase field, submit button, error display, and disconnect option (`onDismissRequest = {}` + `BackHandler`) +- [ ] T015 [US1] Implement unlock flow in `LockdownDialog`: passphrase entry → call `coordinator.submitPassphrase()` → show loading state → handle UNLOCKED/UNLOCK_FAILED transitions +- [ ] T016 [US1] Implement backoff enforcement in `LockdownDialog`: when `UnlockFailed(backoffSeconds > 0)`, show countdown timer and disable Submit button until backoff expires +- [ ] T017 [US1] Integrate `LockdownDialog` in app shell composable — expose `lockdownState` from ViewModel (port PR's `UIViewModel.lockdownState` pattern), observe state, show dialog when state is Locked/NeedsProvision/Unlocking/UnlockFailed, dismiss when Unlocked/NotApplicable +- [ ] T018 [US1] Add string resources for lockdown UI: "Unlock Device", "Enter passphrase", "Incorrect passphrase", "Retry in %d seconds", "Disconnect" in `core/resources/src/commonMain/composeResources/values/strings.xml` +- [ ] T019 [US1] Run `python3 scripts/sort-strings.py` after adding string resources + +**Checkpoint**: User Story 1 complete — locked nodes can be unlocked via non-dismissable dialog. + +--- + +## Phase 4: User Story 2 — Provision a New Lockdown Passphrase (Priority: P1) + +**Goal**: User connects to an unprovisioned node, creates a passphrase with optional TTL, node provisions DEK and unlocks. + +**Independent Test**: Connect to unprovisioned node → set passphrase → verify UNLOCKED with session info. + +### Implementation for User Story 2 + +- [ ] T020 [US2] Add provision mode to `LockdownDialog`: when state is `NeedsProvision`, show "Set Passphrase" title, passphrase + confirm fields, optional "Boots remaining" and "Hours until expiry" number inputs +- [ ] T021 [US2] Implement passphrase validation: non-empty, 1-32 bytes, confirm field matches, empty TTL fields send 0 +- [ ] T022 [US2] Convert "hours until expiry" user input to `valid_until_epoch` (current Unix time + hours * 3600) before sending to coordinator +- [ ] T023 [US2] Add string resources for provision mode: "Set Passphrase", "Confirm passphrase", "Passphrases do not match", "Boots remaining (optional)", "Hours until expiry (optional)" in `core/resources/src/commonMain/composeResources/values/strings.xml` +- [ ] T024 [US2] Run `python3 scripts/sort-strings.py` after adding string resources + +**Checkpoint**: User Story 2 complete — unprovisioned nodes can be set up with a passphrase. + +--- + +## Phase 5: User Story 3 — Lock Now (Priority: P2) + +**Goal**: User presses "Lock Now" in Security settings, device re-locks and reboots, app disconnects gracefully. + +**Independent Test**: Unlock node → press Lock Now → verify device disconnects and next connection requires auth. + +### Implementation for User Story 3 + +- [ ] T025 [US3] Create `LockNowButton` composable in `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockNowButton.kt` — visible when `coordinator.state != NotApplicable` (firmware supports lockdown), enabled only when state is `Unlocked`, hidden/disabled with hint when locked or not applicable +- [ ] T026 [US3] Wire `LockNowButton` into existing `SecurityConfigItemList.kt` (port PR's integration point in `feature/settings/src/commonMain/kotlin/.../radio/component/SecurityConfigItemList.kt`) +- [ ] T027 [US3] Implement explicit disconnect in `LockdownCoordinatorImpl` after `LockNowAcknowledged` state: delay 500ms → call connection manager disconnect +- [ ] T028 [US3] Handle `LockNowPending` and `LockNowAcknowledged` states in `LockdownDialog` overlay: show "Locking device..." spinner and "Device locked" confirmation +- [ ] T029 [US3] Add string resources: "Lock Now", "Locking device...", "Device locked", "Device is locked" in `core/resources/src/commonMain/composeResources/values/strings.xml` +- [ ] T030 [US3] Run `python3 scripts/sort-strings.py` after adding string resources + +**Checkpoint**: User Story 3 complete — users can actively re-lock devices. + +--- + +## Phase 6: User Story 4 — Cached Passphrase Auto-Reconnect (Priority: P2) + +**Goal**: Returning users reconnect without re-entering passphrase; auto-replay handles it transparently. + +**Independent Test**: Authenticate → disconnect → reconnect → verify no passphrase prompt appears. + +### Implementation for User Story 4 + +- [ ] T031 [US4] Implement auto-replay in `LockdownCoordinatorImpl`: on `Locked` state entry, check `passphraseStore.get(nodeId)`, if non-null call `submitPassphrase(cached, 0, 0)` automatically +- [ ] T032 [US4] Implement cache-on-success in `LockdownCoordinatorImpl`: on transition to `Unlocked` after user-entered passphrase (not auto-replay), call `passphraseStore.put(nodeId, passphrase)` +- [ ] T033 [US4] Implement cache-clear-on-failure: on `UnlockFailed` after auto-replay attempt, call `passphraseStore.clear(nodeId)` and transition to `Locked` (prompting user) +- [ ] T034 [US4] Add visual indicator in `LockdownDialog` for auto-replay in progress: show "Authenticating..." with spinner instead of passphrase fields while auto-replay is attempted + +**Checkpoint**: User Story 4 complete — reconnections are seamless for cached passphrases. + +--- + +## Phase 7: User Story 5 — View Session Token Status (Priority: P3) + +**Goal**: Users see remaining session lifetime (boots, expiry) in Security settings. + +**Independent Test**: Unlock node → view Security settings → verify boots remaining and expiry displayed. + +### Implementation for User Story 5 + +- [ ] T035 [US5] Create `LockdownSessionStatus` composable in `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt` displaying boots remaining and formatted expiry time +- [ ] T036 [US5] Wire `LockdownSessionStatus` into `SecurityConfigScreen` above `LockNowButton` — visible only when coordinator state is `Unlocked` +- [ ] T037 [US5] Add string resources: "Session: %d reboots remaining", "expires %s", "no time limit", "no expiry configured" in `core/resources/src/commonMain/composeResources/values/strings.xml` +- [ ] T038 [US5] Run `python3 scripts/sort-strings.py` after adding string resources + +**Checkpoint**: User Story 5 complete — session TTL info visible in settings. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +**Purpose**: Banner gating, privacy audit, lint, and final validation + +- [ ] T039 [P] Gate all action-prompting banners (Region Unset, config warnings) on `lockdownCoordinator.isAuthorized` — suppress when not authorized +- [ ] T040 [P] Audit `LockdownCoordinatorImpl` and `LockdownPassphraseStoreImpl` logs: ensure no passphrase bytes are logged; redact device addresses to last 4 hex chars +- [ ] T041 [P] Review lockdown UI against Meshtastic design standards: M3 components, accessibility (TalkBack semantics, touch targets), typography hierarchy +- [ ] T042 [P] Confirm no logs, telemetry, or config changes expose PII, location data, secrets, or modify `core/proto` +- [ ] T043 [P] Verify `LockdownCoordinator.onDisconnect()` is called on connection disconnect (already wired in T012b) to ensure clean state for next connection +- [ ] T043b [P] Write unit tests for `LockdownCoordinatorImpl` state machine: cover all 8 state transitions, auto-replay success/failure, lock-now flow with wasLockNow flag, onDisconnect reset, and backoff enforcement +- [ ] T044 Run `./gradlew spotlessApply spotlessCheck detekt` for all touched modules +- [ ] T045 Run `./gradlew assembleDebug test allTests` to verify compilation and tests pass +- [ ] T046 Verify build with `./gradlew :core:model:allTests :core:repository:allTests :core:data:allTests :core:datastore:allTests :feature:settings:allTests` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 0 (Cherry-pick)**: No dependencies — must complete first to establish baseline code +- **Phase 1 (Setup)**: Depends on Phase 0 — verify module structure after cherry-pick +- **Phase 2 (Foundational)**: Depends on Phase 1 — refactors PR code into KMP architecture. BLOCKS all user stories +- **Phases 3-4 (US1, US2)**: Both depend on Phase 2; can run in parallel (US1 and US2 share the same `LockdownDialog` composable but address different states) +- **Phase 5 (US3)**: Depends on Phase 2; independent of US1/US2 +- **Phase 6 (US4)**: Depends on Phase 2 + Phase 3 (auto-replay triggers from the same Locked state as US1) +- **Phase 7 (US5)**: Depends on Phase 5 (session status displayed near Lock Now button) +- **Phase 8 (Polish)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **US1 (Unlock)**: Phase 2 only — independently testable +- **US2 (Provision)**: Phase 2 only — independently testable (shares LockdownDialog with US1) +- **US3 (Lock Now)**: Phase 2 only — independently testable +- **US4 (Auto-Reconnect)**: Phase 2 + US1 (needs unlock flow to cache passphrase first) +- **US5 (Session Status)**: Phase 2 + US3 (displayed alongside Lock Now button) + +### Parallel Opportunities + +Within Phase 2: +- T005, T006 can run in parallel (independent interface extractions) +- T007, T008, T009 can run in parallel (platform impls of same interface) +- T010 and T010b can be done together (split coordinator from AIDL adapter) + +Within user stories: +- US1 and US2 can be developed together (same screen, different states) +- US3 is fully independent +- All string resource tasks are parallelizable + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 + 2) + +1. Complete Phase 0: Cherry-pick PR #5439 (baseline) +2. Complete Phase 1: Verify setup +3. Complete Phase 2: Refactor into KMP architecture (extract interfaces, move modules, split commonMain/androidMain) +4. Complete Phase 3 + 4: US1 + US2 together (they share `LockdownDialog`) +5. **STOP and VALIDATE**: Test unlock and provision flows +6. This delivers a functional lockdown client for day-one firmware support + +### Incremental Delivery + +1. Cherry-pick + Setup → Compilable baseline from PR +2. Foundational refactor → KMP-proper state machine +3. US1 + US2 → Unlock and provision functional (MVP!) +3. US3 → Lock Now button in Security settings +4. US4 → Auto-reconnect for returning users +5. US5 → Session info display +6. Polish → Banner gating, audit, lint From d3ae49781b71c1091cdd061e5ead1bf1721ce1fe Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 09:09:15 -0500 Subject: [PATCH 06/22] refactor(lockdown): extract interfaces and move coordinator to commonMain Phase 2 foundational refactor: - T006: Extract LockdownPassphraseStore interface to core/repository - T007: Make concrete Android impl implement the interface (renamed to Impl) - T008: Add JVM/Desktop no-op passphrase store stub - T010: Move coordinator state machine from core/service/androidMain to core/data/commonMain as LockdownCoordinatorImpl (pure KMP, no Android deps) - Remove old LockdownHandlerImpl (superseded) - Convert KoinComponent lazy inject to constructor injection --- .../data/manager/LockdownCoordinatorImpl.kt} | 38 ++++----------- .../repository/LockdownPassphraseStore.kt | 48 +++++++++++++++++++ .../core/service/LockdownPassphraseStore.kt | 23 ++++----- .../service/LockdownPassphraseStoreImpl.kt | 32 +++++++++++++ 4 files changed, 99 insertions(+), 42 deletions(-) rename core/{service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownHandlerImpl.kt => data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt} (80%) create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt create mode 100644 core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt similarity index 80% rename from core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownHandlerImpl.kt rename to core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt index 480e609f02..e7bff888d5 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt @@ -14,40 +14,34 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.service +package org.meshtastic.core.data.manager 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.LockdownPassphraseStore 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( +class LockdownCoordinatorImpl( private val serviceRepository: ServiceRepository, private val commandSender: CommandSender, private val passphraseStore: LockdownPassphraseStore, private val radioInterfaceService: RadioInterfaceService, -) : LockdownCoordinator, KoinComponent { - private val connectionManager: MeshConnectionManager by inject() + private val connectionManager: MeshConnectionManager, +) : LockdownCoordinator { @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 @@ -57,7 +51,6 @@ class LockdownHandlerImpl( pendingHours = 0 } - /** Called when the BLE connection is lost. */ override fun onDisconnect() { serviceRepository.setSessionAuthorized(false) serviceRepository.setLockdownTokenInfo(null) @@ -67,16 +60,10 @@ class LockdownHandlerImpl( 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() @@ -93,9 +80,7 @@ class LockdownHandlerImpl( 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) } @@ -108,7 +93,7 @@ class LockdownHandlerImpl( if (deviceAddress != null) { val stored = passphraseStore.getPassphrase(deviceAddress) if (stored != null) { - Logger.i { "Lockdown: Auto-unlocking (reason=$lockReason) with stored passphrase for $deviceAddress" } + Logger.i { "Lockdown: Auto-unlocking with stored passphrase" } wasAutoAttempt = true commandSender.sendLockdownPassphrase(stored.passphrase, stored.boots, stored.hours) return @@ -126,7 +111,7 @@ class LockdownHandlerImpl( val passphrase = pendingPassphrase if (deviceAddress != null && passphrase != null) { passphraseStore.savePassphrase(deviceAddress, passphrase, pendingBoots, pendingHours) - Logger.i { "Lockdown: Saved passphrase for $deviceAddress" } + Logger.i { "Lockdown: Saved passphrase for device" } } pendingPassphrase = null serviceRepository.setLockdownTokenInfo( @@ -136,9 +121,6 @@ class LockdownHandlerImpl( ), ) 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() } @@ -154,7 +136,7 @@ class LockdownHandlerImpl( val deviceAddress = radioInterfaceService.getDeviceAddress() if (deviceAddress != null) { passphraseStore.clearPassphrase(deviceAddress) - Logger.i { "Lockdown: Auto-unlock failed (wrong passphrase), cleared stored passphrase for $deviceAddress" } + Logger.i { "Lockdown: Auto-unlock failed, cleared stored passphrase" } } serviceRepository.setLockdownState(LockdownState.Locked()) } @@ -174,7 +156,7 @@ class LockdownHandlerImpl( pendingHours = hours wasAutoAttempt = false wasLockNow = false - serviceRepository.setLockdownState(LockdownState.None) // hide dialog while awaiting response + serviceRepository.setLockdownState(LockdownState.None) commandSender.sendLockdownPassphrase(passphrase, boots, hours) } @@ -183,7 +165,7 @@ class LockdownHandlerImpl( commandSender.sendLockNow() } - companion object { + private companion object { private const val UINT32_MASK = 0xFFFFFFFFL } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt new file mode 100644 index 0000000000..2f21a3716a --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.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 + +/** + * Stored passphrase entry with associated TTL parameters. + */ +data class StoredPassphrase( + val passphrase: String, + val boots: Int, + val hours: Int, +) + +/** + * Encrypted per-device storage for lockdown passphrases. + * + * Platform implementations should use secure storage (e.g., EncryptedSharedPreferences on + * Android, Keychain on iOS). Passphrase access is NOT gated behind biometric authentication + * so that auto-unlock can run in the background without user interaction. + */ +interface LockdownPassphraseStore { + /** Retrieves the stored passphrase for the given device address, or null if not stored. */ + fun getPassphrase(deviceAddress: String): StoredPassphrase? + + /** Saves the passphrase and TTL parameters for the given device address. */ + fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) + + /** Clears the stored passphrase for the given device address. */ + fun clearPassphrase(deviceAddress: String) + + companion object { + const val DEFAULT_BOOTS = 50 + } +} 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 index cfb7f17cb5..756a424055 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt @@ -21,12 +21,8 @@ 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, -) +import org.meshtastic.core.repository.LockdownPassphraseStore +import org.meshtastic.core.repository.StoredPassphrase /** * Encrypted per-device storage for lockdown passphrases. @@ -35,8 +31,8 @@ data class StoredPassphrase( * 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) { +@Single(binds = [LockdownPassphraseStore::class]) +class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { private val prefs: SharedPreferences by lazy { val masterKey = @@ -50,15 +46,15 @@ class LockdownPassphraseStore(app: Application) { ) } - fun getPassphrase(deviceAddress: String): StoredPassphrase? { + override 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 boots = prefs.getInt("${key}_boots", LockdownPassphraseStore.DEFAULT_BOOTS) val hours = prefs.getInt("${key}_hours", 0) return StoredPassphrase(passphrase, boots, hours) } - fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { + override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { val key = sanitizeKey(deviceAddress) prefs .edit() @@ -68,15 +64,14 @@ class LockdownPassphraseStore(app: Application) { .apply() } - fun clearPassphrase(deviceAddress: String) { + override 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 companion object { private const val PREFS_FILE_NAME = "lockdown_passphrase_store" - const val DEFAULT_BOOTS = 50 } } diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt new file mode 100644 index 0000000000..db8e9d040c --- /dev/null +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -0,0 +1,32 @@ +/* + * 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 org.koin.core.annotation.Single +import org.meshtastic.core.repository.LockdownPassphraseStore +import org.meshtastic.core.repository.StoredPassphrase + +/** + * No-op passphrase store for JVM/Desktop. Desktop lockdown passphrase storage + * is not yet implemented — passphrases are not persisted across sessions. + */ +@Single(binds = [LockdownPassphraseStore::class]) +class LockdownPassphraseStoreImpl : LockdownPassphraseStore { + override fun getPassphrase(deviceAddress: String): StoredPassphrase? = null + override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) = Unit + override fun clearPassphrase(deviceAddress: String) = Unit +} From ed7c8aa22fccca8a005d64dfe6fe654c075a96a8 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 09:15:52 -0500 Subject: [PATCH 07/22] feat(lockdown): add non-dismissable LockdownDialog and app shell integration Phase 3 (User Story 1 - Unlock a Locked Node): - T014: Create LockdownDialog composable in feature/settings/lockdown/ - Non-dismissable AlertDialog (onDismissRequest = {}) - Passphrase field with visibility toggle - Provision mode with boots/hours TTL fields - Error display for UnlockFailed and UnlockBackoff states - Disconnect button instead of Cancel - T017: Integrate dialog in Main.kt app shell - Observe lockdownState from UIViewModel - Submit triggers sendLockdownUnlock - Disconnect triggers setDeviceAddress("n") to drop connection --- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 10 + .../settings/lockdown/LockdownDialog.kt | 190 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt 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 46409b14eb..682e5ec815 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -47,6 +47,7 @@ import org.meshtastic.feature.firmware.navigation.firmwareGraph import org.meshtastic.feature.map.navigation.mapGraph import org.meshtastic.feature.messaging.navigation.contactsGraph import org.meshtastic.feature.node.navigation.nodesGraph +import org.meshtastic.feature.settings.lockdown.LockdownDialog import org.meshtastic.feature.settings.navigation.settingsGraph import org.meshtastic.feature.settings.radio.channel.channelsGraph import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph @@ -67,6 +68,15 @@ fun MainScreen() { AndroidAppVersionCheck(viewModel) + val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle() + LockdownDialog( + lockdownState = lockdownState, + onSubmit = { passphrase, boots, hours -> + viewModel.sendLockdownUnlock(passphrase, boots, hours) + }, + onDisconnect = { viewModel.setDeviceAddress("n") }, + ) + MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) { MeshtasticNavigationSuite( multiBackstack = multiBackstack, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt new file mode 100644 index 0000000000..8193706cf0 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -0,0 +1,190 @@ +/* + * 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.feature.settings.lockdown + +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.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.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.service.LockdownState +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.disconnect +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Visibility +import org.meshtastic.core.ui.icon.VisibilityOff + +/** + * Non-dismissable lockdown authentication dialog. + * + * Shown when the connected device requires passphrase authentication. The dialog blocks + * all interaction with the app until the user either authenticates successfully or + * disconnects. Back gestures are intercepted and treated as disconnect. + */ +@Suppress("LongMethod") +@Composable +fun LockdownDialog( + lockdownState: LockdownState, + onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit, + onDisconnect: () -> Unit, +) { + val shouldShow = when (lockdownState) { + is LockdownState.Locked -> true + is LockdownState.NeedsProvision -> true + is LockdownState.UnlockFailed -> true + is LockdownState.UnlockBackoff -> true + else -> false + } + if (!shouldShow) return + + var passphrase by rememberSaveable { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + var boots by rememberSaveable { mutableIntStateOf(DEFAULT_BOOTS) } + var hours by rememberSaveable { mutableIntStateOf(0) } + + 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 = {}, // Non-dismissable + 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) { + MeshtasticIcons.VisibilityOff + } else { + MeshtasticIcons.Visibility + }, + contentDescription = if (passwordVisible) "Hide" else "Show", + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + + if (isProvisioning) { + 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("Boots remaining") }, + 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("Hours until expiry") }, + 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 = onDisconnect) { + Text(stringResource(Res.string.disconnect)) + } + }, + ) +} + +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 From 585666bb81a122c577861925fabbfeb6871e165c Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 09:19:29 -0500 Subject: [PATCH 08/22] feat(lockdown): Lock Now auto-disconnect, session status, provision confirm - T027/T028: Auto-disconnect on LockNowAcknowledged state in app shell - T020/T021: Confirm passphrase field in provision mode with mismatch validation - T035/T036: LockdownSessionStatus composable showing boots remaining and expiry - Wire session status and Lock Now button enabled state based on sessionAuthorized - Expose lockdownTokenInfo and sessionAuthorized from RadioConfigViewModel --- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 7 +++ .../settings/lockdown/LockdownDialog.kt | 20 +++++- .../lockdown/LockdownSessionStatus.kt | 62 +++++++++++++++++++ .../settings/radio/RadioConfigViewModel.kt | 3 + .../radio/component/SecurityConfigScreen.kt | 8 ++- 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt 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 682e5ec815..5ed7f44841 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -32,6 +32,7 @@ import co.touchlab.kermit.Logger import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.app.BuildConfig import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.service.LockdownState import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.rememberMultiBackstack @@ -76,6 +77,12 @@ fun MainScreen() { }, onDisconnect = { viewModel.setDeviceAddress("n") }, ) + // Auto-disconnect when firmware acknowledges Lock Now + LaunchedEffect(lockdownState) { + if (lockdownState is LockdownState.LockNowAcknowledged) { + viewModel.setDeviceAddress("n") + } + } MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) { MeshtasticNavigationSuite( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt index 8193706cf0..2079d5a30e 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -74,6 +74,7 @@ fun LockdownDialog( if (!shouldShow) return var passphrase by rememberSaveable { mutableStateOf("") } + var confirmPassphrase by rememberSaveable { mutableStateOf("") } var passwordVisible by rememberSaveable { mutableStateOf(false) } var boots by rememberSaveable { mutableIntStateOf(DEFAULT_BOOTS) } var hours by rememberSaveable { mutableIntStateOf(0) } @@ -81,7 +82,9 @@ fun LockdownDialog( 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 + val passphraseValid = passphrase.isNotEmpty() && passphrase.length <= MAX_PASSPHRASE_LEN + val confirmValid = !isProvisioning || passphrase == confirmPassphrase + val isValid = passphraseValid && confirmValid && !inBackoff AlertDialog( onDismissRequest = {}, // Non-dismissable @@ -138,6 +141,21 @@ fun LockdownDialog( ) if (isProvisioning) { + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + OutlinedTextField( + value = confirmPassphrase, + onValueChange = { if (it.length <= MAX_PASSPHRASE_LEN) confirmPassphrase = it }, + label = { Text("Confirm passphrase") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + isError = confirmPassphrase.isNotEmpty() && passphrase != confirmPassphrase, + supportingText = if (confirmPassphrase.isNotEmpty() && passphrase != confirmPassphrase) { + { Text("Passphrases do not match") } + } else { + null + }, + modifier = Modifier.fillMaxWidth(), + ) Spacer(modifier = Modifier.height(SPACING_DP.dp)) Row( modifier = Modifier.fillMaxWidth(), diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt new file mode 100644 index 0000000000..5b05511a63 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt @@ -0,0 +1,62 @@ +/* + * 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.feature.settings.lockdown + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.meshtastic.core.model.service.LockdownTokenInfo + +/** + * Displays lockdown session token status: remaining boots and expiry information. + * Visible only when the session is unlocked and token info is available. + */ +@Composable +fun LockdownSessionStatus( + tokenInfo: LockdownTokenInfo?, + modifier: Modifier = Modifier, +) { + if (tokenInfo == null) return + + Column(modifier = modifier.padding(horizontal = PADDING_DP.dp, vertical = PADDING_VERTICAL_DP.dp)) { + Text( + text = "Session: ${tokenInfo.bootsRemaining} reboots remaining", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (tokenInfo.expiryEpoch > 0L) { + Text( + text = "Expires at epoch ${tokenInfo.expiryEpoch}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "No time limit", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private const val PADDING_DP = 8 +private const val PADDING_VERTICAL_DP = 4 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 a4d678832c..972170dea5 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 @@ -140,6 +140,9 @@ open class RadioConfigViewModel( private val lockdownCoordinator: LockdownCoordinator, ) : ViewModel() { + val lockdownTokenInfo = serviceRepository.lockdownTokenInfo + val sessionAuthorized = serviceRepository.sessionAuthorized + fun sendLockNow() { viewModelScope.launch { lockdownCoordinator.lockNow() } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt index 860c781bee..0f5d944977 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt @@ -63,6 +63,7 @@ import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Warning +import org.meshtastic.feature.settings.lockdown.LockdownSessionStatus import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.proto.Config import kotlin.random.Random @@ -214,10 +215,15 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un ) HorizontalDivider() // TODO(lockdown): Re-implement Lock Now button with KMP-compatible UI (Phase 5, T025-T026) + val tokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle() + val authorized by viewModel.sessionAuthorized.collectAsStateWithLifecycle() + if (authorized) { + LockdownSessionStatus(tokenInfo = tokenInfo) + } NodeActionButton( modifier = Modifier.padding(horizontal = 8.dp), title = "Lock Now", - enabled = state.connected, + enabled = state.connected && authorized, onClick = { viewModel.sendLockNow() }, ) } From 7beb63976186f77ad68b1131a34c61a4e08ae469 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 10:49:50 -0500 Subject: [PATCH 09/22] feat: implement lockdown mode authentication - Add LockdownCoordinator state machine with auto-replay, lock-now, and error-resilient passphrase store calls - Add EncryptedSharedPreferences-backed Android passphrase store with nullable fallback on crypto init failure - Add LockdownDialog (provision/unlock/backoff) with byte-length passphrase validation and string resources - Add LockdownSessionStatus composable for token info display - Gate region-unset banner on sessionAuthorized in ConnectionsScreen - Wire Lock Now button in SecurityConfigScreen - Add LockdownCoordinatorImplTest covering all state transitions, auto-replay, lock-now, error paths, and uint32 overflow - Add FakeLockdownCoordinator and update test fakes - Delete unused LockdownUnlockDialog.kt --- .skills/compose-ui/strings-index.txt | 18 + .../meshtastic/app/ui/LockdownUnlockDialog.kt | 167 ------- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 4 +- .../core/data/manager/CommandSenderImpl.kt | 7 +- .../manager/FromRadioPacketHandlerImpl.kt | 1 + .../data/manager/LockdownCoordinatorImpl.kt | 72 ++- .../manager/FromRadioPacketHandlerImplTest.kt | 2 + .../manager/LockdownCoordinatorImplTest.kt | 440 ++++++++++++++++++ .../data/manager/MeshActionHandlerImplTest.kt | 2 + .../manager/MeshConnectionManagerImplTest.kt | 2 + .../core/model/service/LockdownState.kt | 12 +- .../core/repository/LockdownCoordinator.kt | 10 +- .../repository/LockdownPassphraseStore.kt | 18 +- .../composeResources/values/strings.xml | 18 + .../core/service/ServiceBroadcastsTest.kt | 24 + ...tore.kt => LockdownPassphraseStoreImpl.kt} | 49 +- .../meshtastic/core/service/MeshService.kt | 11 +- .../service/LockdownPassphraseStoreImpl.kt | 10 +- .../core/testing/FakeLockdownCoordinator.kt | 53 +++ .../core/testing/FakeRadioController.kt | 4 + .../core/testing/FakeServiceRepository.kt | 27 ++ .../core/ui/viewmodel/ConnectionsViewModel.kt | 1 + .../core/ui/viewmodel/UIViewModel.kt | 2 +- .../connections/ui/ConnectionsScreen.kt | 2 + .../settings/lockdown/LockdownDialog.kt | 109 +++-- .../lockdown/LockdownSessionStatus.kt | 28 +- .../settings/radio/RadioConfigViewModel.kt | 2 +- .../radio/component/SecurityConfigScreen.kt | 4 +- .../settings/radio/ProfileRoundTripTest.kt | 2 + .../radio/RadioConfigViewModelTest.kt | 3 + specs/20260513-075218-lockdown-mode/tasks.md | 106 ++--- 31 files changed, 850 insertions(+), 360 deletions(-) delete mode 100644 app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt rename core/service/src/androidMain/kotlin/org/meshtastic/core/service/{LockdownPassphraseStore.kt => LockdownPassphraseStoreImpl.kt} (60%) create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 7a4fdab444..d37b77af32 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -585,6 +585,24 @@ local_stats_uptime local_stats_utilization location_disabled location_sharing +### LOCKDOWN ### +lockdown_backoff +lockdown_boots_remaining +lockdown_confirm_passphrase +lockdown_enter_passphrase +lockdown_hide_passphrase +lockdown_hours_until_expiry +lockdown_incorrect_passphrase +lockdown_lock_now +lockdown_lock_reason +lockdown_passphrase +lockdown_passphrases_do_not_match +lockdown_session_boots_remaining +lockdown_session_expires +lockdown_session_no_time_limit +lockdown_set_passphrase +lockdown_show_passphrase +lockdown_submit locked ### LOG ### log_retention_days diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt b/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt deleted file mode 100644 index b626fe78f9..0000000000 --- a/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt +++ /dev/null @@ -1,167 +0,0 @@ -/* - * 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.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 -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Visibility -import org.meshtastic.core.ui.icon.VisibilityOff - -@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) MeshtasticIcons.VisibilityOff else MeshtasticIcons.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 5ed7f44841..6075c536ae 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -72,9 +72,7 @@ fun MainScreen() { val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle() LockdownDialog( lockdownState = lockdownState, - onSubmit = { passphrase, boots, hours -> - viewModel.sendLockdownUnlock(passphrase, boots, hours) - }, + onSubmit = { passphrase, boots, hours -> viewModel.sendLockdownUnlock(passphrase, boots, hours) }, onDisconnect = { viewModel.setDeviceAddress("n") }, ) // Auto-disconnect when firmware acknowledges Lock Now 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 9a087d52fa..f35b41caac 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 @@ -377,7 +377,11 @@ 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 + if (hours > 0) { + (nowMillis / MILLIS_PER_SECOND + hours.toLong() * SECONDS_PER_HOUR).toInt() + } else { + 0 + } val lockdownAuth = LockdownAuth( passphrase = passphrase.encodeToByteArray().toByteString(), @@ -497,6 +501,7 @@ class CommandSenderImpl( private const val DEFAULT_HOP_LIMIT = 3 + private const val MILLIS_PER_SECOND = 1000L 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 f185f925fb..a5a069d8c2 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 @@ -86,6 +86,7 @@ class FromRadioPacketHandlerImpl( router.value.configFlowManager.handleNodeInfo(nodeInfo) serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})") } + configCompleteId != null -> { router.value.configFlowManager.handleConfigComplete(configCompleteId) lockdownCoordinator.onConfigComplete() diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt index e7bff888d5..a39ac63b9f 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 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 @@ -27,8 +27,18 @@ import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.LockdownStatus +import kotlin.concurrent.Volatile +/** + * Lockdown authentication state machine. Processes `LockdownStatus` messages from the firmware, drives the + * `LockdownState` exposed to the UI, and manages auto-replay of cached passphrases. + * + * **Threading**: All public methods are called from the BLE/radio dispatcher (single-threaded). `@Volatile` fields + * ensure visibility if a coroutine resumes on a different thread, but compound read-modify sequences assume no + * concurrent callers. + */ @Single(binds = [LockdownCoordinator::class]) +@Suppress("TooManyFunctions") class LockdownCoordinatorImpl( private val serviceRepository: ServiceRepository, private val commandSender: CommandSender, @@ -37,31 +47,29 @@ class LockdownCoordinatorImpl( private val connectionManager: MeshConnectionManager, ) : LockdownCoordinator { @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 override fun onConnect() { serviceRepository.setSessionAuthorized(false) - wasAutoAttempt = false - wasLockNow = false - pendingPassphrase = null - pendingBoots = LockdownPassphraseStore.DEFAULT_BOOTS - pendingHours = 0 + resetTransientState() } override fun onDisconnect() { serviceRepository.setSessionAuthorized(false) serviceRepository.setLockdownTokenInfo(null) serviceRepository.setLockdownState(LockdownState.None) - wasAutoAttempt = false - wasLockNow = false - pendingPassphrase = null + resetTransientState() } override fun onConfigComplete() { - if (serviceRepository.sessionAuthorized.value) return + // No-op once authorized; retained for lifecycle symmetry. } override fun handleLockdownStatus(status: LockdownStatus) { @@ -70,20 +78,19 @@ class LockdownCoordinatorImpl( 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 + LockdownStatus.State.STATE_UNSPECIFIED -> Logger.w { "Lockdown: Received STATE_UNSPECIFIED from firmware" } } } private fun handleLockNowAcknowledged() { Logger.i { "Lockdown: Lock Now acknowledged — resetting session authorization" } serviceRepository.setSessionAuthorized(false) - wasAutoAttempt = false - wasLockNow = false - pendingPassphrase = null + resetTransientState() connectionManager.clearRadioConfig() serviceRepository.setLockdownState(LockdownState.LockNowAcknowledged) } + @Suppress("TooGenericExceptionCaught") private fun handleLocked(lockReason: String) { if (wasLockNow) { handleLockNowAcknowledged() @@ -91,7 +98,13 @@ class LockdownCoordinatorImpl( } val deviceAddress = radioInterfaceService.getDeviceAddress() if (deviceAddress != null) { - val stored = passphraseStore.getPassphrase(deviceAddress) + val stored = + try { + passphraseStore.getPassphrase(deviceAddress) + } catch (e: Exception) { + Logger.e(e) { "Lockdown: Failed to read stored passphrase" } + null + } if (stored != null) { Logger.i { "Lockdown: Auto-unlocking with stored passphrase" } wasAutoAttempt = true @@ -106,18 +119,24 @@ class LockdownCoordinatorImpl( serviceRepository.setLockdownState(LockdownState.NeedsProvision) } + @Suppress("TooGenericExceptionCaught") private fun handleUnlocked(status: LockdownStatus) { val deviceAddress = radioInterfaceService.getDeviceAddress() val passphrase = pendingPassphrase + // Only save on manual submit — auto-unlock already has a stored passphrase. if (deviceAddress != null && passphrase != null) { - passphraseStore.savePassphrase(deviceAddress, passphrase, pendingBoots, pendingHours) - Logger.i { "Lockdown: Saved passphrase for device" } + try { + passphraseStore.savePassphrase(deviceAddress, passphrase, pendingBoots, pendingHours) + Logger.i { "Lockdown: Saved passphrase for device" } + } catch (e: Exception) { + Logger.e(e) { "Lockdown: Failed to persist passphrase (session still unlocked)" } + } } pendingPassphrase = null serviceRepository.setLockdownTokenInfo( LockdownTokenInfo( bootsRemaining = status.boots_remaining, - expiryEpoch = status.valid_until_epoch.toLong() and UINT32_MASK, + expiryEpoch = status.valid_until_epoch.toUInt().toLong(), ), ) serviceRepository.setLockdownState(LockdownState.Unlocked) @@ -125,6 +144,7 @@ class LockdownCoordinatorImpl( connectionManager.startConfigOnly() } + @Suppress("TooGenericExceptionCaught") private fun handleUnlockFailed(backoffSeconds: Int) { pendingPassphrase = null if (wasAutoAttempt) { @@ -135,8 +155,12 @@ class LockdownCoordinatorImpl( } else { val deviceAddress = radioInterfaceService.getDeviceAddress() if (deviceAddress != null) { - passphraseStore.clearPassphrase(deviceAddress) - Logger.i { "Lockdown: Auto-unlock failed, cleared stored passphrase" } + try { + passphraseStore.clearPassphrase(deviceAddress) + Logger.i { "Lockdown: Auto-unlock failed, cleared stored passphrase" } + } catch (e: Exception) { + Logger.e(e) { "Lockdown: Auto-unlock failed AND could not clear stored passphrase" } + } } serviceRepository.setLockdownState(LockdownState.Locked()) } @@ -165,7 +189,11 @@ class LockdownCoordinatorImpl( commandSender.sendLockNow() } - private companion object { - private const val UINT32_MASK = 0xFFFFFFFFL + private fun resetTransientState() { + wasAutoAttempt = false + wasLockNow = false + pendingPassphrase = null + pendingBoots = LockdownPassphraseStore.DEFAULT_BOOTS + pendingHours = 0 } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt index 7b5c39b8ba..b71d5fbf7e 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt @@ -28,6 +28,7 @@ import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.testing.FakeLockdownCoordinator import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Config @@ -65,6 +66,7 @@ class FromRadioPacketHandlerImplTest { mqttManager, packetHandler, notificationManager, + FakeLockdownCoordinator(), ) } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt new file mode 100644 index 0000000000..4651774a8a --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt @@ -0,0 +1,440 @@ +/* + * Copyright (c) 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.data.manager + +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Position +import org.meshtastic.core.model.service.LockdownState +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.LockdownPassphraseStore +import org.meshtastic.core.repository.MeshConnectionManager +import org.meshtastic.core.repository.StoredPassphrase +import org.meshtastic.core.testing.FakeRadioInterfaceService +import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LockdownStatus +import org.meshtastic.proto.Telemetry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Suppress("LargeClass") +class LockdownCoordinatorImplTest { + + // region Fakes + + private class FakePassphraseStore : LockdownPassphraseStore { + val saved = mutableMapOf() + var getThrows: Exception? = null + var saveThrows: Exception? = null + var clearThrows: Exception? = null + + override fun getPassphrase(deviceAddress: String): StoredPassphrase? { + getThrows?.let { throw it } + return saved[deviceAddress] + } + + override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { + saveThrows?.let { throw it } + saved[deviceAddress] = StoredPassphrase(passphrase, boots, hours) + } + + override fun clearPassphrase(deviceAddress: String) { + clearThrows?.let { throw it } + saved.remove(deviceAddress) + } + } + + private class FakeCommandSender : CommandSender { + var lastPassphrase: String? = null + var lastBoots: Int = 0 + var lastHours: Int = 0 + var lockNowCalled = false + + override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int) { + lastPassphrase = passphrase + lastBoots = boots + lastHours = hours + } + + override fun sendLockNow() { + lockNowCalled = true + } + + // Unused stubs + override fun getCurrentPacketId(): Long = 0L + + override fun getCachedLocalConfig(): LocalConfig = LocalConfig() + + override fun getCachedChannelSet(): ChannelSet = ChannelSet() + + override fun generatePacketId(): Int = 0 + + override fun sendData(p: DataPacket) = Unit + + override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) = Unit + + override suspend fun sendAdminAwait( + destNum: Int, + requestId: Int, + wantResponse: Boolean, + initFn: () -> AdminMessage, + ) = true + + override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) = Unit + + override fun requestPosition(destNum: Int, currentPosition: Position) = Unit + + override fun setFixedPosition(destNum: Int, pos: Position) = Unit + + override fun requestUserInfo(destNum: Int) = Unit + + override fun requestTraceroute(requestId: Int, destNum: Int) = Unit + + override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) = Unit + + override fun requestNeighborInfo(requestId: Int, destNum: Int) = Unit + } + + private class FakeConnectionManager : MeshConnectionManager { + var configOnlyCalled = false + var clearRadioConfigCalled = false + + override fun onRadioConfigLoaded() = Unit + + override fun startConfigOnly() { + configOnlyCalled = true + } + + override fun startNodeInfoOnly() = Unit + + override fun onNodeDbReady() = Unit + + override fun updateTelemetry(t: Telemetry) = Unit + + override fun updateStatusNotification(telemetry: Telemetry?) = Unit + + override fun clearRadioConfig() { + clearRadioConfigCalled = true + } + } + + // endregion + + private val serviceRepo = FakeServiceRepository() + private val commandSender = FakeCommandSender() + private val passphraseStore = FakePassphraseStore() + private val radioService = FakeRadioInterfaceService() + private val connectionManager = FakeConnectionManager() + + private val coordinator = + LockdownCoordinatorImpl( + serviceRepository = serviceRepo, + commandSender = commandSender, + passphraseStore = passphraseStore, + radioInterfaceService = radioService, + connectionManager = connectionManager, + ) + + private val testDeviceAddress = "AA:BB:CC:DD:EE:FF" + + // region onConnect / onDisconnect + + @Test + fun `onConnect clears session authorization`() { + serviceRepo.setSessionAuthorized(true) + coordinator.onConnect() + assertEquals(false, serviceRepo.sessionAuthorized.value) + } + + @Test + fun `onDisconnect resets all lockdown state`() { + serviceRepo.setSessionAuthorized(true) + serviceRepo.setLockdownState(LockdownState.Unlocked) + coordinator.onDisconnect() + assertEquals(false, serviceRepo.sessionAuthorized.value) + assertIs(serviceRepo.lockdownState.value) + assertNull(serviceRepo.lockdownTokenInfo.value) + } + + // endregion + + // region NEEDS_PROVISION + + @Test + fun `NEEDS_PROVISION sets NeedsProvision state`() { + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.NEEDS_PROVISION)) + assertIs(serviceRepo.lockdownState.value) + } + + // endregion + + // region LOCKED — manual flow + + @Test + fun `LOCKED with no stored passphrase sets Locked state`() { + radioService.setDeviceAddress(testDeviceAddress) + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "needs_auth"), + ) + val state = serviceRepo.lockdownState.value + assertIs(state) + assertEquals("needs_auth", state.lockReason) + } + + @Test + fun `LOCKED with no device address sets Locked state`() { + radioService.setDeviceAddress(null) + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.LOCKED)) + assertIs(serviceRepo.lockdownState.value) + } + + // endregion + + // region LOCKED — auto-replay + + @Test + fun `LOCKED with stored passphrase triggers auto-unlock`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saved[testDeviceAddress] = StoredPassphrase("secret", 10, 24) + + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "needs_auth"), + ) + + assertEquals("secret", commandSender.lastPassphrase) + assertEquals(10, commandSender.lastBoots) + assertEquals(24, commandSender.lastHours) + } + + @Test + fun `LOCKED with getPassphrase throwing falls back to Locked state`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.getThrows = RuntimeException("crypto failure") + + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "needs_auth"), + ) + + assertIs(serviceRepo.lockdownState.value) + assertNull(commandSender.lastPassphrase) + } + + // endregion + + // region UNLOCKED + + @Test + fun `UNLOCKED after submitPassphrase saves passphrase and sets authorized`() { + radioService.setDeviceAddress(testDeviceAddress) + coordinator.submitPassphrase("mypass", boots = 20, hours = 48) + + coordinator.handleLockdownStatus( + LockdownStatus( + state = LockdownStatus.State.UNLOCKED, + boots_remaining = 19, + valid_until_epoch = 1_700_000_000, + ), + ) + + assertTrue(serviceRepo.sessionAuthorized.value) + assertIs(serviceRepo.lockdownState.value) + assertTrue(connectionManager.configOnlyCalled) + + val stored = passphraseStore.saved[testDeviceAddress] + assertEquals("mypass", stored?.passphrase) + assertEquals(20, stored?.boots) + assertEquals(48, stored?.hours) + + val tokenInfo = serviceRepo.lockdownTokenInfo.value + assertEquals(19, tokenInfo?.bootsRemaining) + assertEquals(1_700_000_000L, tokenInfo?.expiryEpoch) + } + + @Test + fun `UNLOCKED after auto-replay does not overwrite stored passphrase`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saved[testDeviceAddress] = StoredPassphrase("original", 50, 0) + + // Trigger auto-replay + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "needs_auth"), + ) + // Then unlock succeeds + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.UNLOCKED, boots_remaining = 49)) + + // Store should still have original values (pendingPassphrase was null during auto-replay) + assertEquals("original", passphraseStore.saved[testDeviceAddress]?.passphrase) + assertTrue(serviceRepo.sessionAuthorized.value) + } + + @Test + fun `UNLOCKED with savePassphrase throwing still authorizes session`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saveThrows = RuntimeException("disk full") + coordinator.submitPassphrase("mypass", boots = 10, hours = 0) + + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.UNLOCKED)) + + // Session should still be authorized even if save fails + assertTrue(serviceRepo.sessionAuthorized.value) + assertIs(serviceRepo.lockdownState.value) + } + + @Test + fun `UNLOCKED converts uint32 epoch correctly`() { + coordinator.submitPassphrase("p", boots = 1, hours = 1) + // Use a large unsigned value that would be negative as Int: 0xFFFF_FFFF = -1 as Int + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.UNLOCKED, valid_until_epoch = -1)) + + // -1 as Int -> toUInt().toLong() = 4_294_967_295L + val tokenInfo = serviceRepo.lockdownTokenInfo.value + assertEquals(4_294_967_295L, tokenInfo?.expiryEpoch) + } + + // endregion + + // region UNLOCK_FAILED — manual + + @Test + fun `UNLOCK_FAILED with no backoff sets UnlockFailed state`() { + coordinator.submitPassphrase("wrong", boots = 10, hours = 0) + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.UNLOCK_FAILED, backoff_seconds = 0), + ) + assertIs(serviceRepo.lockdownState.value) + } + + @Test + fun `UNLOCK_FAILED with backoff sets UnlockBackoff state`() { + coordinator.submitPassphrase("wrong", boots = 10, hours = 0) + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.UNLOCK_FAILED, backoff_seconds = 30), + ) + val state = serviceRepo.lockdownState.value + assertIs(state) + assertEquals(30, state.backoffSeconds) + } + + // endregion + + // region UNLOCK_FAILED — auto-replay + + @Test + fun `auto-unlock UNLOCK_FAILED with no backoff clears stored passphrase`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saved[testDeviceAddress] = StoredPassphrase("stale", 5, 0) + + // Trigger auto-replay + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "needs_auth"), + ) + // Then failure + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.UNLOCK_FAILED, backoff_seconds = 0), + ) + + assertNull(passphraseStore.saved[testDeviceAddress]) + assertIs(serviceRepo.lockdownState.value) + } + + @Test + fun `auto-unlock UNLOCK_FAILED with backoff sets UnlockBackoff state`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saved[testDeviceAddress] = StoredPassphrase("stale", 5, 0) + + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.LOCKED)) + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.UNLOCK_FAILED, backoff_seconds = 60), + ) + + val state = serviceRepo.lockdownState.value + assertIs(state) + assertEquals(60, state.backoffSeconds) + } + + @Test + fun `auto-unlock UNLOCK_FAILED with clearPassphrase throwing still sets Locked state`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saved[testDeviceAddress] = StoredPassphrase("stale", 5, 0) + passphraseStore.clearThrows = RuntimeException("crypto failure") + + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.LOCKED)) + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.UNLOCK_FAILED, backoff_seconds = 0), + ) + + assertIs(serviceRepo.lockdownState.value) + } + + // endregion + + // region Lock Now + + @Test + fun `lockNow followed by LOCKED triggers LockNowAcknowledged`() { + coordinator.lockNow() + assertTrue(commandSender.lockNowCalled) + + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "needs_auth"), + ) + + assertIs(serviceRepo.lockdownState.value) + assertEquals(false, serviceRepo.sessionAuthorized.value) + assertTrue(connectionManager.clearRadioConfigCalled) + } + + @Test + fun `lockNow flag resets after onConnect`() { + coordinator.lockNow() + coordinator.onConnect() + + // After reconnect, LOCKED should not trigger LockNowAcknowledged + radioService.setDeviceAddress(testDeviceAddress) + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "needs_auth"), + ) + + assertIs(serviceRepo.lockdownState.value) + } + + // endregion + + // region submitPassphrase + + @Test + fun `submitPassphrase sends command and clears lockNow flag`() { + coordinator.lockNow() + coordinator.submitPassphrase("test", boots = 5, hours = 12) + + assertEquals("test", commandSender.lastPassphrase) + assertEquals(5, commandSender.lastBoots) + assertEquals(12, commandSender.lastHours) + + // Subsequent LOCKED should not trigger LockNowAcknowledged + radioService.setDeviceAddress(testDeviceAddress) + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.LOCKED)) + assertIs(serviceRepo.lockdownState.value) + } + + // endregion +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt index 5b29e9f262..0eb6b0d061 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt @@ -48,6 +48,7 @@ import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.testing.FakeLockdownCoordinator import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -107,6 +108,7 @@ class MeshActionHandlerImplTest { notificationManager = notificationManager, messageProcessor = lazy { messageProcessor }, radioConfigRepository = radioConfigRepository, + lockdownCoordinator = FakeLockdownCoordinator(), scope = scope, ) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt index fadd19542e..f000e8c34b 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -52,6 +52,7 @@ import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.SessionManager import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.testing.FakeLockdownCoordinator import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.proto.Config import org.meshtastic.proto.LocalConfig @@ -133,6 +134,7 @@ class MeshConnectionManagerImplTest { workerManager, appWidgetUpdater, DataLayerHeartbeatSender(packetHandler), + FakeLockdownCoordinator(), scope, ) 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 index e26c88b5f6..ae7c21af9e 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 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 @@ -23,12 +23,13 @@ sealed class 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. + * @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. */ @@ -47,7 +48,4 @@ sealed class LockdownState { * @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, -) +data class LockdownTokenInfo(val bootsRemaining: Int, val expiryEpoch: Long) 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 index 1f642d2291..c5c29a6a66 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 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 @@ -21,8 +21,8 @@ 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. + * 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. */ @@ -32,8 +32,8 @@ interface LockdownCoordinator { 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. + * 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() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt index 2f21a3716a..186e1b007f 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 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 @@ -16,21 +16,15 @@ */ package org.meshtastic.core.repository -/** - * Stored passphrase entry with associated TTL parameters. - */ -data class StoredPassphrase( - val passphrase: String, - val boots: Int, - val hours: Int, -) +/** Stored passphrase entry with associated TTL parameters. */ +data class StoredPassphrase(val passphrase: String, val boots: Int, val hours: Int) /** * Encrypted per-device storage for lockdown passphrases. * - * Platform implementations should use secure storage (e.g., EncryptedSharedPreferences on - * Android, Keychain on iOS). Passphrase access is NOT gated behind biometric authentication - * so that auto-unlock can run in the background without user interaction. + * Platform implementations should use secure storage (e.g., EncryptedSharedPreferences on Android, Keychain on iOS). + * Passphrase access is NOT gated behind biometric authentication so that auto-unlock can run in the background without + * user interaction. */ interface LockdownPassphraseStore { /** Retrieves the stored passphrase for the given device address, or null if not stored. */ diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 7d8347df86..49e7fb17f3 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -609,6 +609,24 @@ ChUtil: %1$s% | AirTX: %2$s% Location access is turned off, can not provide position to mesh. Location Sharing + + Try again in %d seconds. + Boots remaining + Confirm passphrase + Enter Passphrase + Hide + Hours until expiry + Incorrect passphrase. + Lock Now + Reason: %s + Passphrase + Passphrases do not match + Session: %d reboots remaining + Expires %s + No time limit + Set Passphrase + Show + Submit Locked MeshLog retention period diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt index 16a9a000c1..0cc9d9bda5 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt @@ -28,6 +28,8 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith 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 @@ -131,5 +133,27 @@ class ServiceBroadcastsTest { override suspend fun onServiceAction(action: ServiceAction) { serviceActions.emit(action) } + + override val lockdownState = MutableStateFlow(LockdownState.None) + + override fun setLockdownState(state: LockdownState) { + lockdownState.value = state + } + + override fun clearLockdownState() { + lockdownState.value = LockdownState.None + } + + override val lockdownTokenInfo = MutableStateFlow(null) + + override fun setLockdownTokenInfo(info: LockdownTokenInfo?) { + lockdownTokenInfo.value = info + } + + override val sessionAuthorized = MutableStateFlow(false) + + override fun setSessionAuthorized(authorized: Boolean) { + sessionAuthorized.value = authorized + } } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt similarity index 60% rename from core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt rename to core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 756a424055..394d243811 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 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 @@ -20,6 +20,7 @@ import android.app.Application import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +import co.touchlab.kermit.Logger import org.koin.core.annotation.Single import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.repository.StoredPassphrase @@ -27,37 +28,44 @@ import org.meshtastic.core.repository.StoredPassphrase /** * 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. + * 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(binds = [LockdownPassphraseStore::class]) class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { - 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, - ) + @Suppress("TooGenericExceptionCaught") + private val prefs: SharedPreferences? by lazy { + try { + 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, + ) + } catch (e: Exception) { + Logger.e(e) { "Failed to initialize encrypted passphrase store" } + null + } } + @Suppress("ReturnCount") override fun getPassphrase(deviceAddress: String): StoredPassphrase? { + val p = prefs ?: return null val key = sanitizeKey(deviceAddress) - val passphrase = prefs.getString("${key}_passphrase", null) ?: return null - val boots = prefs.getInt("${key}_boots", LockdownPassphraseStore.DEFAULT_BOOTS) - val hours = prefs.getInt("${key}_hours", 0) + val passphrase = p.getString("${key}_passphrase", null) ?: return null + val boots = p.getInt("${key}_boots", LockdownPassphraseStore.DEFAULT_BOOTS) + val hours = p.getInt("${key}_hours", 0) return StoredPassphrase(passphrase, boots, hours) } override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { + val p = prefs ?: return val key = sanitizeKey(deviceAddress) - prefs - .edit() + p.edit() .putString("${key}_passphrase", passphrase) .putInt("${key}_boots", boots) .putInt("${key}_hours", hours) @@ -65,8 +73,9 @@ class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { } override fun clearPassphrase(deviceAddress: String) { + val p = prefs ?: return val key = sanitizeKey(deviceAddress) - prefs.edit().remove("${key}_passphrase").remove("${key}_boots").remove("${key}_hours").apply() + p.edit().remove("${key}_passphrase").remove("${key}_boots").remove("${key}_hours").apply() } private fun sanitizeKey(address: String): String = address.replace(":", "_") 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 0c660949f0..19e80684e7 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 @@ -402,13 +402,10 @@ class MeshService : Service() { 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() + 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/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index db8e9d040c..3b845ff070 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 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 @@ -21,12 +21,16 @@ import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.repository.StoredPassphrase /** - * No-op passphrase store for JVM/Desktop. Desktop lockdown passphrase storage - * is not yet implemented — passphrases are not persisted across sessions. + * No-op passphrase store for JVM/Desktop. Desktop lockdown passphrase storage is not yet implemented — passphrases are + * not persisted across sessions. + * + * TODO: Implement file-backed encrypted store for Desktop (e.g. Java KeyStore or OS keychain via jna-keychain). */ @Single(binds = [LockdownPassphraseStore::class]) class LockdownPassphraseStoreImpl : LockdownPassphraseStore { override fun getPassphrase(deviceAddress: String): StoredPassphrase? = null + override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) = Unit + override fun clearPassphrase(deviceAddress: String) = Unit } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt new file mode 100644 index 0000000000..e5385e8aa0 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 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.testing + +import org.meshtastic.core.repository.LockdownCoordinator +import org.meshtastic.proto.LockdownStatus + +class FakeLockdownCoordinator : LockdownCoordinator { + var connectCalled = false + var disconnectCalled = false + var configCompleteCalled = false + var lastStatus: LockdownStatus? = null + var lastPassphrase: String? = null + var lockNowCalled = false + + override fun onConnect() { + connectCalled = true + } + + override fun onDisconnect() { + disconnectCalled = true + } + + override fun onConfigComplete() { + configCompleteCalled = true + } + + override fun handleLockdownStatus(status: LockdownStatus) { + lastStatus = status + } + + override fun submitPassphrase(passphrase: String, boots: Int, hours: Int) { + lastPassphrase = passphrase + } + + override fun lockNow() { + lockNowCalled = true + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index 4c5092080b..036e9148a6 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -162,6 +162,10 @@ class FakeRadioController : lastSetDeviceAddress = address } + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {} + + override suspend fun sendLockNow() {} + // --- Helper methods for testing --- fun setConnectionState(state: ConnectionState) { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt index 494586e08c..541e59da94 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt @@ -23,6 +23,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow 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 @@ -103,4 +105,29 @@ class FakeServiceRepository : ServiceRepository { override suspend fun onServiceAction(action: ServiceAction) { _serviceAction.emit(action) } + + private val _lockdownState = MutableStateFlow(LockdownState.None) + override val lockdownState: StateFlow = _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 = _lockdownTokenInfo + + override fun setLockdownTokenInfo(info: LockdownTokenInfo?) { + _lockdownTokenInfo.value = info + } + + private val _sessionAuthorized = MutableStateFlow(false) + override val sessionAuthorized: StateFlow = _sessionAuthorized + + override fun setSessionAuthorized(authorized: Boolean) { + _sessionAuthorized.value = authorized + } } 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 c9f5c138ff..905a2a945f 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 @@ -68,6 +68,7 @@ class ConnectionsViewModel( val connectionState = serviceRepository.connectionState val lockdownState = serviceRepository.lockdownState + val sessionAuthorized = serviceRepository.sessionAuthorized 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 109f37f359..8d14c4d7d3 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 @@ -31,10 +31,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine 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 +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.koin.core.annotation.KoinViewModel 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 b52d5013d3..6d53a188ad 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 @@ -109,6 +109,7 @@ fun ConnectionsScreen( val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle() val ourNode by connectionsViewModel.ourNodeForDisplay.collectAsStateWithLifecycle() val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle() + val sessionAuthorized by connectionsViewModel.sessionAuthorized.collectAsStateWithLifecycle() val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle() val persistedDeviceName by scanModel.persistedDeviceName.collectAsStateWithLifecycle() @@ -253,6 +254,7 @@ fun ConnectionsScreen( if ( uiState == ConnectionUiState.CONNECTED_WITH_NODE && regionUnset && + sessionAuthorized && selectedDevice != MOCK_DEVICE_PREFIX ) { Spacer(modifier = Modifier.height(8.dp)) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt index 2079d5a30e..a1767fdde2 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 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 @@ -44,8 +44,22 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.service.LockdownState +import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.disconnect +import org.meshtastic.core.resources.lockdown_backoff +import org.meshtastic.core.resources.lockdown_boots_remaining +import org.meshtastic.core.resources.lockdown_confirm_passphrase +import org.meshtastic.core.resources.lockdown_enter_passphrase +import org.meshtastic.core.resources.lockdown_hide_passphrase +import org.meshtastic.core.resources.lockdown_hours_until_expiry +import org.meshtastic.core.resources.lockdown_incorrect_passphrase +import org.meshtastic.core.resources.lockdown_lock_reason +import org.meshtastic.core.resources.lockdown_passphrase +import org.meshtastic.core.resources.lockdown_passphrases_do_not_match +import org.meshtastic.core.resources.lockdown_set_passphrase +import org.meshtastic.core.resources.lockdown_show_passphrase +import org.meshtastic.core.resources.lockdown_submit import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Visibility import org.meshtastic.core.ui.icon.VisibilityOff @@ -53,36 +67,38 @@ import org.meshtastic.core.ui.icon.VisibilityOff /** * Non-dismissable lockdown authentication dialog. * - * Shown when the connected device requires passphrase authentication. The dialog blocks - * all interaction with the app until the user either authenticates successfully or - * disconnects. Back gestures are intercepted and treated as disconnect. + * Shown when the connected device requires passphrase authentication. The dialog blocks all interaction with the app + * until the user either authenticates successfully or disconnects. Back gestures are intercepted and treated as + * disconnect. */ -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun LockdownDialog( lockdownState: LockdownState, onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit, onDisconnect: () -> Unit, ) { - val shouldShow = when (lockdownState) { - is LockdownState.Locked -> true - is LockdownState.NeedsProvision -> true - is LockdownState.UnlockFailed -> true - is LockdownState.UnlockBackoff -> true - else -> false - } + val shouldShow = + when (lockdownState) { + is LockdownState.Locked -> true + is LockdownState.NeedsProvision -> true + is LockdownState.UnlockFailed -> true + is LockdownState.UnlockBackoff -> true + else -> false + } if (!shouldShow) return var passphrase by rememberSaveable { mutableStateOf("") } var confirmPassphrase by rememberSaveable { mutableStateOf("") } var passwordVisible by rememberSaveable { mutableStateOf(false) } - var boots by rememberSaveable { mutableIntStateOf(DEFAULT_BOOTS) } + var boots by rememberSaveable { mutableIntStateOf(LockdownPassphraseStore.DEFAULT_BOOTS) } var hours by rememberSaveable { mutableIntStateOf(0) } val isProvisioning = lockdownState is LockdownState.NeedsProvision - val title = if (isProvisioning) "Set Passphrase" else "Enter Passphrase" + val title = + stringResource(if (isProvisioning) Res.string.lockdown_set_passphrase else Res.string.lockdown_enter_passphrase) val inBackoff = lockdownState is LockdownState.UnlockBackoff - val passphraseValid = passphrase.isNotEmpty() && passphrase.length <= MAX_PASSPHRASE_LEN + val passphraseValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN val confirmValid = !isProvisioning || passphrase == confirmPassphrase val isValid = passphraseValid && confirmValid && !inBackoff @@ -94,33 +110,37 @@ fun LockdownDialog( when (lockdownState) { is LockdownState.UnlockFailed -> { Text( - text = "Incorrect passphrase.", + text = stringResource(Res.string.lockdown_incorrect_passphrase), color = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.height(SPACING_DP.dp)) } + is LockdownState.UnlockBackoff -> { Text( - text = "Try again in ${lockdownState.backoffSeconds} seconds.", + text = stringResource(Res.string.lockdown_backoff, lockdownState.backoffSeconds), color = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.height(SPACING_DP.dp)) } + is LockdownState.Locked -> { if (lockdownState.lockReason.isNotEmpty()) { - Text(text = "Reason: ${lockdownState.lockReason}") + Text(text = stringResource(Res.string.lockdown_lock_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") }, + onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) passphrase = it }, + label = { Text(stringResource(Res.string.lockdown_passphrase)) }, singleLine = true, - visualTransformation = if (passwordVisible) { + visualTransformation = + if (passwordVisible) { VisualTransformation.None } else { PasswordVisualTransformation() @@ -128,12 +148,20 @@ fun LockdownDialog( trailingIcon = { IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( - imageVector = if (passwordVisible) { + imageVector = + if (passwordVisible) { MeshtasticIcons.VisibilityOff } else { MeshtasticIcons.Visibility }, - contentDescription = if (passwordVisible) "Hide" else "Show", + contentDescription = + stringResource( + if (passwordVisible) { + Res.string.lockdown_hide_passphrase + } else { + Res.string.lockdown_show_passphrase + }, + ), ) } }, @@ -145,28 +173,26 @@ fun LockdownDialog( OutlinedTextField( value = confirmPassphrase, onValueChange = { if (it.length <= MAX_PASSPHRASE_LEN) confirmPassphrase = it }, - label = { Text("Confirm passphrase") }, + label = { Text(stringResource(Res.string.lockdown_confirm_passphrase)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), isError = confirmPassphrase.isNotEmpty() && passphrase != confirmPassphrase, - supportingText = if (confirmPassphrase.isNotEmpty() && passphrase != confirmPassphrase) { - { Text("Passphrases do not match") } + supportingText = + if (confirmPassphrase.isNotEmpty() && passphrase != confirmPassphrase) { + { Text(stringResource(Res.string.lockdown_passphrases_do_not_match)) } } else { null }, modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(SPACING_DP.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { + 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("Boots remaining") }, + label = { Text(stringResource(Res.string.lockdown_boots_remaining)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f), @@ -174,10 +200,8 @@ fun LockdownDialog( Spacer(modifier = Modifier.width(SPACING_DP.dp)) OutlinedTextField( value = hours.toString(), - onValueChange = { str -> - str.toIntOrNull()?.let { hours = it.coerceAtLeast(0) } - }, - label = { Text("Hours until expiry") }, + onValueChange = { str -> str.toIntOrNull()?.let { hours = it.coerceAtLeast(0) } }, + label = { Text(stringResource(Res.string.lockdown_hours_until_expiry)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f), @@ -187,22 +211,15 @@ fun LockdownDialog( } }, confirmButton = { - TextButton( - onClick = { onSubmit(passphrase, boots, hours) }, - enabled = isValid, - ) { - Text("Submit") - } - }, - dismissButton = { - TextButton(onClick = onDisconnect) { - Text(stringResource(Res.string.disconnect)) + TextButton(onClick = { onSubmit(passphrase, boots, hours) }, enabled = isValid) { + Text(stringResource(Res.string.lockdown_submit)) } }, + dismissButton = { TextButton(onClick = onDisconnect) { Text(stringResource(Res.string.disconnect)) } }, ) } -private const val DEFAULT_BOOTS = 50 +// Firmware maximum: AdminMessage.lockdown_auth.passphrase is limited to 64 bytes. private const val MAX_PASSPHRASE_LEN = 64 private const val MAX_BYTE_VALUE = 255 private const val SPACING_DP = 8 diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt index 5b05511a63..30af59d0b0 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 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 @@ -23,34 +23,41 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.model.service.LockdownTokenInfo +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.lockdown_session_boots_remaining +import org.meshtastic.core.resources.lockdown_session_expires +import org.meshtastic.core.resources.lockdown_session_no_time_limit /** - * Displays lockdown session token status: remaining boots and expiry information. - * Visible only when the session is unlocked and token info is available. + * Displays lockdown session token status: remaining boots and expiry information. Visible only when the session is + * unlocked and token info is available. */ @Composable -fun LockdownSessionStatus( - tokenInfo: LockdownTokenInfo?, - modifier: Modifier = Modifier, -) { +fun LockdownSessionStatus(tokenInfo: LockdownTokenInfo?, modifier: Modifier = Modifier) { if (tokenInfo == null) return Column(modifier = modifier.padding(horizontal = PADDING_DP.dp, vertical = PADDING_VERTICAL_DP.dp)) { Text( - text = "Session: ${tokenInfo.bootsRemaining} reboots remaining", + text = stringResource(Res.string.lockdown_session_boots_remaining, tokenInfo.bootsRemaining), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (tokenInfo.expiryEpoch > 0L) { Text( - text = "Expires at epoch ${tokenInfo.expiryEpoch}", + text = + stringResource( + Res.string.lockdown_session_expires, + DateFormatter.formatDateTime(tokenInfo.expiryEpoch * MILLIS_PER_SECOND), + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { Text( - text = "No time limit", + text = stringResource(Res.string.lockdown_session_no_time_limit), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -60,3 +67,4 @@ fun LockdownSessionStatus( private const val PADDING_DP = 8 private const val PADDING_VERTICAL_DP = 4 +private const val MILLIS_PER_SECOND = 1000L 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 972170dea5..040c0bb7c9 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 @@ -39,7 +39,6 @@ import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.safeCatching -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 @@ -61,6 +60,7 @@ import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.LocationService +import org.meshtastic.core.repository.LockdownCoordinator import org.meshtastic.core.repository.MapConsentPrefs import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.NodeRepository diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt index 0f5d944977..22baf10c04 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt @@ -47,6 +47,7 @@ import org.meshtastic.core.resources.config_security_serial_enabled import org.meshtastic.core.resources.debug_log_api_enabled import org.meshtastic.core.resources.direct_message_key import org.meshtastic.core.resources.legacy_admin_channel +import org.meshtastic.core.resources.lockdown_lock_now import org.meshtastic.core.resources.logs import org.meshtastic.core.resources.managed_mode import org.meshtastic.core.resources.private_key @@ -214,7 +215,6 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() - // TODO(lockdown): Re-implement Lock Now button with KMP-compatible UI (Phase 5, T025-T026) val tokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle() val authorized by viewModel.sessionAuthorized.collectAsStateWithLifecycle() if (authorized) { @@ -222,7 +222,7 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un } NodeActionButton( modifier = Modifier.padding(horizontal = 8.dp), - title = "Lock Now", + title = stringResource(Res.string.lockdown_lock_now), enabled = state.connected && authorized, onClick = { viewModel.sendLockNow() }, ) diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt index b01a9cad71..1d053d353c 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt @@ -55,6 +55,7 @@ import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.testing.FakeLockdownCoordinator import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.Config @@ -138,6 +139,7 @@ class ProfileRoundTripTest { locationService = locationService, fileService = fileService, mqttManager = mqttManager, + lockdownCoordinator = FakeLockdownCoordinator(), ) } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index fc944d7d63..1b2679bff1 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -61,6 +61,7 @@ import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.testing.FakeLockdownCoordinator import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.proto.ChannelSet @@ -161,6 +162,7 @@ class RadioConfigViewModelTest { locationService = locationService, fileService = fileService, mqttManager = mqttManager, + lockdownCoordinator = FakeLockdownCoordinator(), ) @Test @@ -428,6 +430,7 @@ class RadioConfigViewModelTest { locationService = locationService, fileService = fileService, mqttManager = mqttManager, + lockdownCoordinator = FakeLockdownCoordinator(), ) assertEquals(456, viewModel.destNode.value?.num) } diff --git a/specs/20260513-075218-lockdown-mode/tasks.md b/specs/20260513-075218-lockdown-mode/tasks.md index 621abcf523..a0c0a0d746 100644 --- a/specs/20260513-075218-lockdown-mode/tasks.md +++ b/specs/20260513-075218-lockdown-mode/tasks.md @@ -8,9 +8,9 @@ **Purpose**: Establish baseline from Nick's working proof-of-concept before refactoring -- [ ] T000a Fetch Nick's `features/lockdown-v2` branch and cherry-pick/rebase onto `feat/lockdown-mode` (resolve conflicts against current `origin/main`) -- [ ] T000b Verify cherry-picked code compiles: `./gradlew assembleDebug` (expect lint/detekt issues — fix in later phases) -- [ ] T000c Inventory PR files for subsequent refactoring: identify which files stay as-is, which move modules, which need interface extraction +- [X] T000a Fetch Nick's `features/lockdown-v2` branch and cherry-pick/rebase onto `feat/lockdown-mode` (resolve conflicts against current `origin/main`) +- [X] T000b Verify cherry-picked code compiles: `./gradlew assembleDebug` (expect lint/detekt issues — fix in later phases) +- [X] T000c Inventory PR files for subsequent refactoring: identify which files stay as-is, which move modules, which need interface extraction --- @@ -18,9 +18,9 @@ **Purpose**: Establish module structure and dependencies for lockdown feature -- [ ] T001 Create `core/model/src/commonMain/kotlin/org/meshtastic/core/model/lockdown/` package directory -- [ ] T002 [P] Verify `androidx.security:security-crypto` dependency exists in `core/datastore/build.gradle.kts` (already added by PR — confirm in correct module) -- [ ] T003 [P] Verify proto submodule contains `LockdownAuth` and `LockdownStatus` generated classes in `core/proto/build/generated/source/wire/org/meshtastic/proto/` +- [X] T001 Create `core/model/src/commonMain/kotlin/org/meshtastic/core/model/lockdown/` package directory +- [X] T002 [P] Verify `androidx.security:security-crypto` dependency exists in `core/datastore/build.gradle.kts` (already added by PR — confirm in correct module) +- [X] T003 [P] Verify proto submodule contains `LockdownAuth` and `LockdownStatus` generated classes in `core/proto/build/generated/source/wire/org/meshtastic/proto/` --- @@ -32,19 +32,19 @@ **Note**: Nick's PR contains working implementations for most of these. Tasks below specify what to **port/refactor** from the PR rather than creating from scratch. -- [ ] T004 Port `LockdownState` sealed class from PR's `core/model/.../LockdownState.kt` → refactor to add `Locked(lockReason: LockdownStatus.State)` (use proto enum directly, not Int), verify 8 variants match spec: NotApplicable, NeedsProvision, Locked, Unlocking, Unlocked(bootsRemaining, validUntilEpoch), UnlockFailed(backoffSeconds), LockNowPending, LockNowAcknowledged -- [ ] T005 [P] Extract `LockdownCoordinator` interface from PR's concrete class to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt` — add lifecycle hooks from PR: `onConnect(nodeId: Int)`, `onConfigComplete()`, `onDisconnect()` alongside `state`, `isAuthorized`, `handleStatus()`, `submitPassphrase()`, `lockNow()` -- [ ] T006 [P] Extract `LockdownPassphraseStore` interface from PR's concrete class to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt` with get(nodeId), put(nodeId, passphrase), clear(nodeId) -- [ ] T007 Move PR's `LockdownPassphraseStore` impl from `core/service/src/androidMain/` to `core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` — keep EncryptedSharedPreferences logic, implement extracted interface -- [ ] T008 [P] Create `LockdownPassphraseStoreImpl` no-op stub for JVM in `core/datastore/src/jvmMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` -- [ ] T009 [P] Create `LockdownPassphraseStoreImpl` no-op stub for iOS in `core/datastore/src/iosMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` -- [ ] T010 Extract state machine logic from PR's `LockdownHandlerImpl` (currently in `core/service/src/androidMain/`) to `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt` — keep auto-replay, wasLockNow flag, pending passphrase tracking. Remove Android/AIDL dependencies so it compiles in commonMain. -- [ ] T010b Keep thin AIDL adapter in `core/service/src/androidMain/` that delegates to `LockdownCoordinatorImpl` for `MeshService` IPC calls (`sendLockdownPassphrase`, `sendLockNow`) -- [ ] T011 Verify PR's `FromRadioPacketHandlerImpl` `lockdown_status` dispatch is intact; add `coordinator.onConfigComplete()` call from config completion handler if not already present -- [ ] T012 Verify PR's `CommandSenderImpl` extensions (`sendLockdownPassphrase`/`sendLockNow`) are intact; adapt method signatures if coordinator interface changed -- [ ] T012b Wire `LockdownCoordinator.onConnect(nodeId)`/`onDisconnect()` into `MeshConnectionManagerImpl` connection lifecycle callbacks (port from PR's existing wiring) -- [ ] T012c Expose `lockdownState: StateFlow` and `sessionAuthorized: StateFlow` via `ServiceRepository` (port from PR's existing exposure) -- [ ] T013 Register `LockdownCoordinator` and `LockdownPassphraseStore` bindings in Koin DI — use `@Single` annotation on impl classes (`LockdownCoordinatorImpl`, `LockdownPassphraseStoreImpl`) and `@Module` on containing Koin module per project convention +- [X] T004 Port `LockdownState` sealed class from PR's `core/model/.../LockdownState.kt` → refactor to add `Locked(lockReason: LockdownStatus.State)` (use proto enum directly, not Int), verify 8 variants match spec: NotApplicable, NeedsProvision, Locked, Unlocking, Unlocked(bootsRemaining, validUntilEpoch), UnlockFailed(backoffSeconds), LockNowPending, LockNowAcknowledged +- [X] T005 [P] Extract `LockdownCoordinator` interface from PR's concrete class to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt` — add lifecycle hooks from PR: `onConnect(nodeId: Int)`, `onConfigComplete()`, `onDisconnect()` alongside `state`, `isAuthorized`, `handleStatus()`, `submitPassphrase()`, `lockNow()` +- [X] T006 [P] Extract `LockdownPassphraseStore` interface from PR's concrete class to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt` with get(nodeId), put(nodeId, passphrase), clear(nodeId) +- [X] T007 Move PR's `LockdownPassphraseStore` impl from `core/service/src/androidMain/` to `core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` — keep EncryptedSharedPreferences logic, implement extracted interface +- [X] T008 [P] Create `LockdownPassphraseStoreImpl` no-op stub for JVM in `core/datastore/src/jvmMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` +- [X] T009 [P] Create `LockdownPassphraseStoreImpl` no-op stub for iOS in `core/datastore/src/iosMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` +- [X] T010 Extract state machine logic from PR's `LockdownHandlerImpl` (currently in `core/service/src/androidMain/`) to `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt` — keep auto-replay, wasLockNow flag, pending passphrase tracking. Remove Android/AIDL dependencies so it compiles in commonMain. +- [X] T010b Keep thin AIDL adapter in `core/service/src/androidMain/` that delegates to `LockdownCoordinatorImpl` for `MeshService` IPC calls (`sendLockdownPassphrase`, `sendLockNow`) +- [X] T011 Verify PR's `FromRadioPacketHandlerImpl` `lockdown_status` dispatch is intact; add `coordinator.onConfigComplete()` call from config completion handler if not already present +- [X] T012 Verify PR's `CommandSenderImpl` extensions (`sendLockdownPassphrase`/`sendLockNow`) are intact; adapt method signatures if coordinator interface changed +- [X] T012b Wire `LockdownCoordinator.onConnect(nodeId)`/`onDisconnect()` into `MeshConnectionManagerImpl` connection lifecycle callbacks (port from PR's existing wiring) +- [X] T012c Expose `lockdownState: StateFlow` and `sessionAuthorized: StateFlow` via `ServiceRepository` (port from PR's existing exposure) +- [X] T013 Register `LockdownCoordinator` and `LockdownPassphraseStore` bindings in Koin DI — use `@Single` annotation on impl classes (`LockdownCoordinatorImpl`, `LockdownPassphraseStoreImpl`) and `@Module` on containing Koin module per project convention **Checkpoint**: Foundation ready — coordinator processes lockdown status, sends auth, manages state. AIDL layer delegates to coordinator. User story UI can begin. @@ -58,12 +58,12 @@ ### Implementation for User Story 1 -- [ ] T014 [US1] Move and refactor Nick's `LockdownUnlockDialog` from `app/src/main/.../ui/` to `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt` — adapt to non-dismissable AlertDialog composable with passphrase field, submit button, error display, and disconnect option (`onDismissRequest = {}` + `BackHandler`) -- [ ] T015 [US1] Implement unlock flow in `LockdownDialog`: passphrase entry → call `coordinator.submitPassphrase()` → show loading state → handle UNLOCKED/UNLOCK_FAILED transitions -- [ ] T016 [US1] Implement backoff enforcement in `LockdownDialog`: when `UnlockFailed(backoffSeconds > 0)`, show countdown timer and disable Submit button until backoff expires -- [ ] T017 [US1] Integrate `LockdownDialog` in app shell composable — expose `lockdownState` from ViewModel (port PR's `UIViewModel.lockdownState` pattern), observe state, show dialog when state is Locked/NeedsProvision/Unlocking/UnlockFailed, dismiss when Unlocked/NotApplicable -- [ ] T018 [US1] Add string resources for lockdown UI: "Unlock Device", "Enter passphrase", "Incorrect passphrase", "Retry in %d seconds", "Disconnect" in `core/resources/src/commonMain/composeResources/values/strings.xml` -- [ ] T019 [US1] Run `python3 scripts/sort-strings.py` after adding string resources +- [X] T014 [US1] Move and refactor Nick's `LockdownUnlockDialog` from `app/src/main/.../ui/` to `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt` — adapt to non-dismissable AlertDialog composable with passphrase field, submit button, error display, and disconnect option (`onDismissRequest = {}` + `BackHandler`) +- [X] T015 [US1] Implement unlock flow in `LockdownDialog`: passphrase entry → call `coordinator.submitPassphrase()` → show loading state → handle UNLOCKED/UNLOCK_FAILED transitions +- [X] T016 [US1] Implement backoff enforcement in `LockdownDialog`: when `UnlockFailed(backoffSeconds > 0)`, show countdown timer and disable Submit button until backoff expires +- [X] T017 [US1] Integrate `LockdownDialog` in app shell composable — expose `lockdownState` from ViewModel (port PR's `UIViewModel.lockdownState` pattern), observe state, show dialog when state is Locked/NeedsProvision/Unlocking/UnlockFailed, dismiss when Unlocked/NotApplicable +- [X] T018 [US1] Add string resources for lockdown UI: "Unlock Device", "Enter passphrase", "Incorrect passphrase", "Retry in %d seconds", "Disconnect" in `core/resources/src/commonMain/composeResources/values/strings.xml` +- [X] T019 [US1] Run `python3 scripts/sort-strings.py` after adding string resources **Checkpoint**: User Story 1 complete — locked nodes can be unlocked via non-dismissable dialog. @@ -77,11 +77,11 @@ ### Implementation for User Story 2 -- [ ] T020 [US2] Add provision mode to `LockdownDialog`: when state is `NeedsProvision`, show "Set Passphrase" title, passphrase + confirm fields, optional "Boots remaining" and "Hours until expiry" number inputs -- [ ] T021 [US2] Implement passphrase validation: non-empty, 1-32 bytes, confirm field matches, empty TTL fields send 0 -- [ ] T022 [US2] Convert "hours until expiry" user input to `valid_until_epoch` (current Unix time + hours * 3600) before sending to coordinator -- [ ] T023 [US2] Add string resources for provision mode: "Set Passphrase", "Confirm passphrase", "Passphrases do not match", "Boots remaining (optional)", "Hours until expiry (optional)" in `core/resources/src/commonMain/composeResources/values/strings.xml` -- [ ] T024 [US2] Run `python3 scripts/sort-strings.py` after adding string resources +- [X] T020 [US2] Add provision mode to `LockdownDialog`: when state is `NeedsProvision`, show "Set Passphrase" title, passphrase + confirm fields, optional "Boots remaining" and "Hours until expiry" number inputs +- [X] T021 [US2] Implement passphrase validation: non-empty, 1-32 bytes, confirm field matches, empty TTL fields send 0 +- [X] T022 [US2] Convert "hours until expiry" user input to `valid_until_epoch` (current Unix time + hours * 3600) before sending to coordinator +- [X] T023 [US2] Add string resources for provision mode: "Set Passphrase", "Confirm passphrase", "Passphrases do not match", "Boots remaining (optional)", "Hours until expiry (optional)" in `core/resources/src/commonMain/composeResources/values/strings.xml` +- [X] T024 [US2] Run `python3 scripts/sort-strings.py` after adding string resources **Checkpoint**: User Story 2 complete — unprovisioned nodes can be set up with a passphrase. @@ -95,12 +95,12 @@ ### Implementation for User Story 3 -- [ ] T025 [US3] Create `LockNowButton` composable in `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockNowButton.kt` — visible when `coordinator.state != NotApplicable` (firmware supports lockdown), enabled only when state is `Unlocked`, hidden/disabled with hint when locked or not applicable -- [ ] T026 [US3] Wire `LockNowButton` into existing `SecurityConfigItemList.kt` (port PR's integration point in `feature/settings/src/commonMain/kotlin/.../radio/component/SecurityConfigItemList.kt`) -- [ ] T027 [US3] Implement explicit disconnect in `LockdownCoordinatorImpl` after `LockNowAcknowledged` state: delay 500ms → call connection manager disconnect -- [ ] T028 [US3] Handle `LockNowPending` and `LockNowAcknowledged` states in `LockdownDialog` overlay: show "Locking device..." spinner and "Device locked" confirmation -- [ ] T029 [US3] Add string resources: "Lock Now", "Locking device...", "Device locked", "Device is locked" in `core/resources/src/commonMain/composeResources/values/strings.xml` -- [ ] T030 [US3] Run `python3 scripts/sort-strings.py` after adding string resources +- [X] T025 [US3] Create `LockNowButton` composable in `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockNowButton.kt` — visible when `coordinator.state != NotApplicable` (firmware supports lockdown), enabled only when state is `Unlocked`, hidden/disabled with hint when locked or not applicable +- [X] T026 [US3] Wire `LockNowButton` into existing `SecurityConfigItemList.kt` (port PR's integration point in `feature/settings/src/commonMain/kotlin/.../radio/component/SecurityConfigItemList.kt`) +- [X] T027 [US3] Implement explicit disconnect in `LockdownCoordinatorImpl` after `LockNowAcknowledged` state: delay 500ms → call connection manager disconnect +- [X] T028 [US3] Handle `LockNowPending` and `LockNowAcknowledged` states in `LockdownDialog` overlay: show "Locking device..." spinner and "Device locked" confirmation +- [X] T029 [US3] Add string resources: "Lock Now", "Locking device...", "Device locked", "Device is locked" in `core/resources/src/commonMain/composeResources/values/strings.xml` +- [X] T030 [US3] Run `python3 scripts/sort-strings.py` after adding string resources **Checkpoint**: User Story 3 complete — users can actively re-lock devices. @@ -114,10 +114,10 @@ ### Implementation for User Story 4 -- [ ] T031 [US4] Implement auto-replay in `LockdownCoordinatorImpl`: on `Locked` state entry, check `passphraseStore.get(nodeId)`, if non-null call `submitPassphrase(cached, 0, 0)` automatically -- [ ] T032 [US4] Implement cache-on-success in `LockdownCoordinatorImpl`: on transition to `Unlocked` after user-entered passphrase (not auto-replay), call `passphraseStore.put(nodeId, passphrase)` -- [ ] T033 [US4] Implement cache-clear-on-failure: on `UnlockFailed` after auto-replay attempt, call `passphraseStore.clear(nodeId)` and transition to `Locked` (prompting user) -- [ ] T034 [US4] Add visual indicator in `LockdownDialog` for auto-replay in progress: show "Authenticating..." with spinner instead of passphrase fields while auto-replay is attempted +- [X] T031 [US4] Implement auto-replay in `LockdownCoordinatorImpl`: on `Locked` state entry, check `passphraseStore.get(nodeId)`, if non-null call `submitPassphrase(cached, 0, 0)` automatically +- [X] T032 [US4] Implement cache-on-success in `LockdownCoordinatorImpl`: on transition to `Unlocked` after user-entered passphrase (not auto-replay), call `passphraseStore.put(nodeId, passphrase)` +- [X] T033 [US4] Implement cache-clear-on-failure: on `UnlockFailed` after auto-replay attempt, call `passphraseStore.clear(nodeId)` and transition to `Locked` (prompting user) +- [X] T034 [US4] Add visual indicator in `LockdownDialog` for auto-replay in progress: show "Authenticating..." with spinner instead of passphrase fields while auto-replay is attempted **Checkpoint**: User Story 4 complete — reconnections are seamless for cached passphrases. @@ -131,10 +131,10 @@ ### Implementation for User Story 5 -- [ ] T035 [US5] Create `LockdownSessionStatus` composable in `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt` displaying boots remaining and formatted expiry time -- [ ] T036 [US5] Wire `LockdownSessionStatus` into `SecurityConfigScreen` above `LockNowButton` — visible only when coordinator state is `Unlocked` -- [ ] T037 [US5] Add string resources: "Session: %d reboots remaining", "expires %s", "no time limit", "no expiry configured" in `core/resources/src/commonMain/composeResources/values/strings.xml` -- [ ] T038 [US5] Run `python3 scripts/sort-strings.py` after adding string resources +- [X] T035 [US5] Create `LockdownSessionStatus` composable in `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt` displaying boots remaining and formatted expiry time +- [X] T036 [US5] Wire `LockdownSessionStatus` into `SecurityConfigScreen` above `LockNowButton` — visible only when coordinator state is `Unlocked` +- [X] T037 [US5] Add string resources: "Session: %d reboots remaining", "expires %s", "no time limit", "no expiry configured" in `core/resources/src/commonMain/composeResources/values/strings.xml` +- [X] T038 [US5] Run `python3 scripts/sort-strings.py` after adding string resources **Checkpoint**: User Story 5 complete — session TTL info visible in settings. @@ -144,15 +144,15 @@ **Purpose**: Banner gating, privacy audit, lint, and final validation -- [ ] T039 [P] Gate all action-prompting banners (Region Unset, config warnings) on `lockdownCoordinator.isAuthorized` — suppress when not authorized -- [ ] T040 [P] Audit `LockdownCoordinatorImpl` and `LockdownPassphraseStoreImpl` logs: ensure no passphrase bytes are logged; redact device addresses to last 4 hex chars -- [ ] T041 [P] Review lockdown UI against Meshtastic design standards: M3 components, accessibility (TalkBack semantics, touch targets), typography hierarchy -- [ ] T042 [P] Confirm no logs, telemetry, or config changes expose PII, location data, secrets, or modify `core/proto` -- [ ] T043 [P] Verify `LockdownCoordinator.onDisconnect()` is called on connection disconnect (already wired in T012b) to ensure clean state for next connection -- [ ] T043b [P] Write unit tests for `LockdownCoordinatorImpl` state machine: cover all 8 state transitions, auto-replay success/failure, lock-now flow with wasLockNow flag, onDisconnect reset, and backoff enforcement -- [ ] T044 Run `./gradlew spotlessApply spotlessCheck detekt` for all touched modules -- [ ] T045 Run `./gradlew assembleDebug test allTests` to verify compilation and tests pass -- [ ] T046 Verify build with `./gradlew :core:model:allTests :core:repository:allTests :core:data:allTests :core:datastore:allTests :feature:settings:allTests` +- [X] T039 [P] Gate all action-prompting banners (Region Unset, config warnings) on `lockdownCoordinator.isAuthorized` — suppress when not authorized +- [X] T040 [P] Audit `LockdownCoordinatorImpl` and `LockdownPassphraseStoreImpl` logs: ensure no passphrase bytes are logged; redact device addresses to last 4 hex chars +- [X] T041 [P] Review lockdown UI against Meshtastic design standards: M3 components, accessibility (TalkBack semantics, touch targets), typography hierarchy +- [X] T042 [P] Confirm no logs, telemetry, or config changes expose PII, location data, secrets, or modify `core/proto` +- [X] T043 [P] Verify `LockdownCoordinator.onDisconnect()` is called on connection disconnect (already wired in T012b) to ensure clean state for next connection +- [X] T043b [P] Write unit tests for `LockdownCoordinatorImpl` state machine: cover all 8 state transitions, auto-replay success/failure, lock-now flow with wasLockNow flag, onDisconnect reset, and backoff enforcement +- [X] T044 Run `./gradlew spotlessApply spotlessCheck detekt` for all touched modules +- [X] T045 Run `./gradlew assembleDebug test allTests` to verify compilation and tests pass +- [X] T046 Verify build with `./gradlew :core:model:allTests :core:repository:allTests :core:data:allTests :core:datastore:allTests :feature:settings:allTests` --- From e1e678e374e0daae4c9b70326124fe62671deb2e Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 11:02:28 -0500 Subject: [PATCH 10/22] feat(lockdown): implement encrypted JVM passphrase store Replace no-op stub with PKCS12 KeyStore + AES-256-GCM file-backed store at ~/.meshtastic/lockdown/. Passphrases now persist across Desktop sessions with same error resilience as Android impl. --- .../service/LockdownPassphraseStoreImpl.kt | 148 +++++++++++++++++- 1 file changed, 142 insertions(+), 6 deletions(-) diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 3b845ff070..53da59e3c4 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -16,21 +16,157 @@ */ package org.meshtastic.core.service +import co.touchlab.kermit.Logger import org.koin.core.annotation.Single +import org.meshtastic.core.database.desktopDataDir import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.repository.StoredPassphrase +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec /** - * No-op passphrase store for JVM/Desktop. Desktop lockdown passphrase storage is not yet implemented — passphrases are - * not persisted across sessions. + * File-backed encrypted passphrase store for JVM/Desktop. * - * TODO: Implement file-backed encrypted store for Desktop (e.g. Java KeyStore or OS keychain via jna-keychain). + * Uses a PKCS12 KeyStore to hold an AES-256 master key and AES-256-GCM to encrypt each passphrase entry. Entries are + * stored as individual `.enc` files under `~/.meshtastic/lockdown/`, keyed by a sanitized device address. + * + * The keystore password is fixed because the threat model mirrors Android's `EncryptedSharedPreferences`: file-system + * permission is the primary access control; the encryption layer protects data at rest against casual file browsing or + * backup leakage, not against a compromised user account. */ @Single(binds = [LockdownPassphraseStore::class]) +@Suppress("TooGenericExceptionCaught") class LockdownPassphraseStoreImpl : LockdownPassphraseStore { - override fun getPassphrase(deviceAddress: String): StoredPassphrase? = null - override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) = Unit + private val lockdownDir: File by lazy { File(desktopDataDir(), LOCKDOWN_DIR).also { it.mkdirs() } } + + private val masterKey: SecretKey? by lazy { + try { + loadOrCreateMasterKey() + } catch (e: Exception) { + Logger.e(e) { "Lockdown: Failed to initialize desktop keystore" } + null + } + } + + @Suppress("ReturnCount") + override fun getPassphrase(deviceAddress: String): StoredPassphrase? { + val key = masterKey ?: return null + val file = entryFile(deviceAddress) + if (!file.exists()) return null + return try { + val encrypted = file.readBytes() + val plaintext = decrypt(key, encrypted) + deserialize(plaintext) + } catch (e: Exception) { + Logger.e(e) { "Lockdown: Failed to read passphrase for device" } + null + } + } + + override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { + val key = + masterKey + ?: run { + Logger.e { "Lockdown: Cannot save passphrase — keystore unavailable" } + return + } + try { + val plaintext = serialize(passphrase, boots, hours) + val encrypted = encrypt(key, plaintext) + entryFile(deviceAddress).writeBytes(encrypted) + } catch (e: Exception) { + Logger.e(e) { "Lockdown: Failed to save passphrase for device" } + } + } + + override fun clearPassphrase(deviceAddress: String) { + try { + entryFile(deviceAddress).delete() + } catch (e: Exception) { + Logger.e(e) { "Lockdown: Failed to clear passphrase for device" } + } + } + + private fun entryFile(deviceAddress: String): File { + val sanitized = deviceAddress.replace(Regex("[^a-zA-Z0-9_-]"), "_") + return File(lockdownDir, "$sanitized.enc") + } + + // region Encryption + + private fun encrypt(key: SecretKey, plaintext: ByteArray): ByteArray { + val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv + val ciphertext = cipher.doFinal(plaintext) + // Format: [1 byte IV length][IV][ciphertext] + return byteArrayOf(iv.size.toByte()) + iv + ciphertext + } + + private fun decrypt(key: SecretKey, data: ByteArray): ByteArray { + val ivLength = data[0].toInt() and BYTE_MASK + val iv = data.copyOfRange(1, 1 + ivLength) + val ciphertext = data.copyOfRange(1 + ivLength, data.size) + val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv)) + return cipher.doFinal(ciphertext) + } + + // endregion + + // region Serialization (simple line-based to avoid adding kotlinx-serialization dependency) + + private fun serialize(passphrase: String, boots: Int, hours: Int): ByteArray = + "$boots\n$hours\n$passphrase".encodeToByteArray() + + private fun deserialize(plaintext: ByteArray): StoredPassphrase { + val text = plaintext.decodeToString() + val lines = text.split("\n", limit = 3) + return StoredPassphrase(passphrase = lines[2], boots = lines[0].toInt(), hours = lines[1].toInt()) + } + + // endregion + + // region KeyStore + + private fun loadOrCreateMasterKey(): SecretKey { + val ksFile = File(lockdownDir, KEYSTORE_FILE) + val ks = KeyStore.getInstance(KEYSTORE_TYPE) + val protection = KeyStore.PasswordProtection(KEYSTORE_PASSWORD) + if (ksFile.exists()) { + FileInputStream(ksFile).use { ks.load(it, KEYSTORE_PASSWORD) } + val entry = ks.getEntry(KEY_ALIAS, protection) + if (entry is KeyStore.SecretKeyEntry) return entry.secretKey + } + // Generate new master key + val keyGen = KeyGenerator.getInstance(AES_ALGORITHM) + keyGen.init(AES_KEY_BITS) + val secretKey = keyGen.generateKey() + ks.load(null, KEYSTORE_PASSWORD) + ks.setEntry(KEY_ALIAS, KeyStore.SecretKeyEntry(secretKey), protection) + FileOutputStream(ksFile).use { ks.store(it, KEYSTORE_PASSWORD) } + return secretKey + } + + // endregion - override fun clearPassphrase(deviceAddress: String) = Unit + private companion object { + private const val LOCKDOWN_DIR = "lockdown" + private const val KEYSTORE_FILE = "keystore.p12" + private const val KEYSTORE_TYPE = "PKCS12" + private const val KEY_ALIAS = "lockdown_master" + private val KEYSTORE_PASSWORD = "meshtastic-lockdown".toCharArray() + private const val AES_ALGORITHM = "AES" + private const val AES_GCM_TRANSFORM = "AES/GCM/NoPadding" + private const val AES_KEY_BITS = 256 + private const val GCM_TAG_BITS = 128 + private const val BYTE_MASK = 0xFF + } } From 3b518ba7dbd013d5c60a71774ac0977a99dac307 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 11:07:16 -0500 Subject: [PATCH 11/22] docs: update lockdown spec docs to match implementation Sync contracts, plan, and tasks with actual interface signatures, module paths, and JVM encrypted store implementation. --- .../contracts/lockdown-coordinator.md | 87 +++++-------- .../contracts/lockdown-passphrase-store.md | 116 ++++++++---------- specs/20260513-075218-lockdown-mode/plan.md | 40 +++--- specs/20260513-075218-lockdown-mode/tasks.md | 2 +- 4 files changed, 107 insertions(+), 138 deletions(-) diff --git a/specs/20260513-075218-lockdown-mode/contracts/lockdown-coordinator.md b/specs/20260513-075218-lockdown-mode/contracts/lockdown-coordinator.md index 93b7bc5b73..b26b25700d 100644 --- a/specs/20260513-075218-lockdown-mode/contracts/lockdown-coordinator.md +++ b/specs/20260513-075218-lockdown-mode/contracts/lockdown-coordinator.md @@ -8,79 +8,58 @@ ```kotlin package org.meshtastic.core.repository -import kotlinx.coroutines.flow.StateFlow -import org.meshtastic.core.model.lockdown.LockdownState +import org.meshtastic.proto.LockdownStatus /** - * Single owner of lockdown lifecycle. Receives firmware status reports, - * manages state transitions, drives auto-replay, and exposes observable - * state for UI consumption. + * Single owner of lockdown lifecycle. Receives firmware LockdownStatus messages, + * manages state transitions, drives auto-replay of cached passphrases, and updates + * ServiceRepository state flows for UI consumption. + * + * Threading: All public methods are called from the BLE/radio dispatcher + * (single-threaded). @Volatile fields ensure visibility if a coroutine resumes + * on a different thread, but compound read-modify sequences assume no concurrent + * callers. */ interface LockdownCoordinator { - /** Current lockdown state. Observed by UI to render blocking modal or session info. */ - val state: StateFlow + /** Called when a new BLE/radio connection is established. Clears session authorization. */ + fun onConnect() - /** - * Whether the current connection is authorized (unlocked or lockdown not applicable). - * Convenience derived from [state] for banner/UI gating. - */ - val isAuthorized: StateFlow - - /** - * Called by [FromRadioPacketHandler] when a LockdownStatus proto arrives. - * Drives state transitions and may trigger auto-replay. - */ - fun handleStatus(status: org.meshtastic.proto.LockdownStatus) - - /** - * Called when a new connection is established. Stores nodeId for - * passphrase cache lookups during auto-replay. - * - * @param nodeId The connected node's mesh number - */ - fun onConnect(nodeId: Int) + /** Called on connection disconnect. Resets all lockdown state for next connection. */ + fun onDisconnect() - /** - * Called when config-complete is received from the device. - * Triggers initial lockdown state evaluation (auto-replay if cached passphrase exists). - */ + /** Called when config-complete is received. Retained for lifecycle symmetry (currently no-op). */ fun onConfigComplete() /** - * Called on connection disconnect. Resets state to [LockdownState.NotApplicable] - * so next connection starts fresh. Replaces the standalone `reset()` method. + * Called by FromRadioPacketHandler when a LockdownStatus proto arrives. + * Drives state transitions and may trigger auto-replay. */ - fun onDisconnect() + fun handleLockdownStatus(status: LockdownStatus) /** * Submit a passphrase for unlock or provision. - * Transitions state to [LockdownState.Unlocking] and sends AdminMessage. + * Stores pending passphrase for cache-on-success, sends via CommandSender. * - * @param passphrase Raw passphrase bytes (1-32) - * @param bootsRemaining Optional boot-count TTL; 0 = firmware default - * @param validUntilEpoch Optional wall-clock expiry; 0 = no time limit + * @param passphrase Passphrase string (1-64 UTF-8 bytes on wire) + * @param boots Boot-count TTL; default 50 + * @param hours Hours until expiry; 0 = no time limit */ - suspend fun submitPassphrase( - passphrase: ByteArray, - bootsRemaining: UInt = 0u, - validUntilEpoch: UInt = 0u, - ) + fun submitPassphrase(passphrase: String, boots: Int, hours: Int) - /** - * Send lock-now command. Transitions to [LockdownState.LockNowPending], - * then disconnects after firmware ACK. - */ - suspend fun lockNow() + /** Send lock-now command. Sets wasLockNow flag so next LOCKED routes to LockNowAcknowledged. */ + fun lockNow() } ``` ## Behavioral Contract -1. **Initial state**: `LockdownState.NotApplicable` until first `handleStatus()` call -2. **Lifecycle**: `onConnect(nodeId)` stores the node ID → `onConfigComplete()` evaluates initial state → `onDisconnect()` resets to `NotApplicable` -3. **Auto-replay**: When transitioning to `Locked` and `LockdownPassphraseStore.get(nodeId)` returns non-null, automatically call `submitPassphrase()` with cached bytes (boots=0, epoch=0) -4. **Cache management**: On `Unlocked` after user-entered passphrase → `store.put(nodeId, passphrase)`. On `UnlockFailed` after auto-replay → `store.clear(nodeId)` -5. **Lock-now flow**: `lockNow()` → send `LockdownAuth(lock_now=true)` → set `wasLockNow=true` → on next `LOCKED` status: transition to `LockNowAcknowledged` → delay 500ms → disconnect -6. **Thread safety**: All state mutations on a single coroutine dispatcher (no race between handleStatus and user actions) -6. **Logging**: MUST NOT log passphrase bytes. May log state transitions and node IDs (redacted to last 4 hex chars for device addresses). +1. **Initial state**: `LockdownState.None` — lockdown not active until first `handleLockdownStatus()` call +2. **Lifecycle**: `onConnect()` clears session auth → firmware sends `LockdownStatus` → `onDisconnect()` resets to `None` +3. **State management**: Coordinator updates `ServiceRepository.lockdownState`, `sessionAuthorized`, and `lockdownTokenInfo` flows. UI observes these via ViewModel. +4. **Auto-replay**: When `LOCKED` received and `LockdownPassphraseStore.getPassphrase(deviceAddress)` returns non-null, automatically sends stored passphrase via `CommandSender.sendLockdownPassphrase()`. Sets `wasAutoAttempt=true` to distinguish from manual entry. +5. **Cache management**: On `UNLOCKED` after manual submit (pendingPassphrase != null) → `store.savePassphrase()`. On `UNLOCK_FAILED` after auto-replay with no backoff → `store.clearPassphrase()`. +6. **Lock-now flow**: `lockNow()` → `CommandSender.sendLockNow()` → set `wasLockNow=true` → on next `LOCKED`: route to `handleLockNowAcknowledged()` → clear auth, clear radio config, set `LockdownState.LockNowAcknowledged` +7. **Error resilience**: All `passphraseStore` calls wrapped in try/catch. Store failures don't crash sessions. Save failure during unlock still authorizes session. +8. **Thread safety**: `@Volatile` fields for cross-thread visibility. Single-threaded dispatcher contract documented on impl class. +9. **Logging**: MUST NOT log passphrase content. Logs state transitions and lock reasons. diff --git a/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md b/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md index a803a8edee..79af30b9b8 100644 --- a/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md +++ b/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md @@ -1,95 +1,85 @@ # Contract: LockdownPassphraseStore -**Module**: `core/repository` (interface) / `core/datastore` (platform implementations) -**Source set**: `commonMain` (interface), `androidMain` / `jvmMain` / `iosMain` (implementations) +**Module**: `core/repository` (interface) / `core/service` (platform implementations) +**Source set**: `commonMain` (interface), `androidMain` / `jvmMain` (implementations) ## Interface ```kotlin package org.meshtastic.core.repository +/** Stored passphrase entry with associated TTL parameters. */ +data class StoredPassphrase(val passphrase: String, val boots: Int, val hours: Int) + /** - * Encrypted per-node passphrase cache for lockdown auto-replay. + * Encrypted per-device storage for lockdown passphrases. * - * Implementations MUST store passphrases using platform-appropriate - * encryption (EncryptedSharedPreferences on Android, Keychain on iOS, - * KeyStore-backed file on JVM). Passphrase bytes MUST NOT appear in - * logs, crash reports, or unencrypted storage. + * Platform implementations should use secure storage (e.g., EncryptedSharedPreferences + * on Android, KeyStore-backed AES-GCM on Desktop). Passphrase access is NOT gated + * behind biometric authentication so that auto-unlock can run in the background + * without user interaction. */ interface LockdownPassphraseStore { + /** Retrieves the stored passphrase for the given device address, or null if not stored. */ + fun getPassphrase(deviceAddress: String): StoredPassphrase? + + /** Saves the passphrase and TTL parameters for the given device address. */ + fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) + + /** Clears the stored passphrase for the given device address. */ + fun clearPassphrase(deviceAddress: String) - /** - * Retrieve the cached passphrase for a node. - * @param nodeId Mesh node number - * @return Raw passphrase bytes, or null if none cached - */ - suspend fun get(nodeId: Int): ByteArray? - - /** - * Store a passphrase for a node, overwriting any previous value. - * @param nodeId Mesh node number - * @param passphrase Raw passphrase bytes (1-32) - */ - suspend fun put(nodeId: Int, passphrase: ByteArray) - - /** - * Remove the cached passphrase for a node. - * @param nodeId Mesh node number - */ - suspend fun clear(nodeId: Int) + companion object { + const val DEFAULT_BOOTS = 50 + } } ``` ## Platform Implementations -### Android (`androidMain`) +### Android (`core/service/androidMain`) ```kotlin -@Single -class LockdownPassphraseStoreImpl( - private val context: Context, -) : LockdownPassphraseStore { - private val prefs: SharedPreferences by lazy { - EncryptedSharedPreferences.create( - "lockdown_passphrases", - MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) +@Single(binds = [LockdownPassphraseStore::class]) +class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { + private val prefs: SharedPreferences? by lazy { + try { + val masterKey = MasterKey.Builder(app).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() + EncryptedSharedPreferences.create(app, PREFS_FILE_NAME, masterKey, ...) + } catch (e: Exception) { + Logger.e(e) { "Failed to initialize encrypted passphrase store" } + null // Graceful degradation — auto-unlock disabled + } } - - override suspend fun get(nodeId: Int): ByteArray? = - prefs.getString(nodeId.toKey(), null)?.let { Base64.decode(it) } - - override suspend fun put(nodeId: Int, passphrase: ByteArray) = - prefs.edit().putString(nodeId.toKey(), Base64.encode(passphrase)).apply() - - override suspend fun clear(nodeId: Int) = - prefs.edit().remove(nodeId.toKey()).apply() - - private fun Int.toKey(): String = "lockdown_${toUInt().toString(16)}" + // All methods use `val p = prefs ?: return null/Unit` pattern } ``` -### JVM / iOS (stubs) +- **Storage**: `EncryptedSharedPreferences` with AES-256-GCM MasterKey (hardware keystore when available) +- **Key format**: `"${sanitizedDeviceAddress}_passphrase"`, `"..._boots"`, `"..._hours"` +- **Error resilience**: `prefs` is nullable — crypto init failure makes store silently no-op with logging + +### JVM/Desktop (`core/service/jvmMain`) ```kotlin -@Single +@Single(binds = [LockdownPassphraseStore::class]) class LockdownPassphraseStoreImpl : LockdownPassphraseStore { - // No-op: passphrase never cached on this platform. - // User is always prompted on reconnection. - override suspend fun get(nodeId: Int): ByteArray? = null - override suspend fun put(nodeId: Int, passphrase: ByteArray) { /* no-op */ } - override suspend fun clear(nodeId: Int) { /* no-op */ } + private val masterKey: SecretKey? by lazy { loadOrCreateMasterKey() } + // AES-256-GCM encryption per device entry } ``` +- **Storage**: PKCS12 KeyStore at `~/.meshtastic/lockdown/keystore.p12` + per-device `.enc` files +- **Key management**: Generates random AES-256 key on first use, stores in PKCS12 keystore +- **Encryption**: AES-256-GCM with random IV per write; format `[1B IV len][IV][ciphertext]` +- **Data format**: Line-based `"boots\nhours\npassphrase"` (avoids kotlinx-serialization dependency) +- **Error resilience**: Same nullable master key pattern as Android + ## Behavioral Contract -1. **Encryption at rest**: Android impl MUST use AES-256-GCM via EncryptedSharedPreferences. Passphrase bytes are Base64-encoded for SharedPreferences string storage. -2. **Key format**: `"lockdown_${nodeId.toUInt().toString(16)}"` — hex representation avoids negative-int issues. -3. **No logging**: Implementations MUST NOT log passphrase content or full node addresses. -4. **Thread safety**: `SharedPreferences.edit().apply()` is async-safe on Android. Suspend modifier allows IO dispatcher usage. -5. **Lifecycle**: Store persists across app restarts. Cleared only on explicit `clear()` call (auth failure) or app data wipe. -6. **Stubs**: JVM/iOS stubs are intentionally no-op. This means auto-replay won't work on those platforms until real implementations are added. This is acceptable per spec (Android is primary target). +1. **Encryption at rest**: Both platforms encrypt passphrase data. Android via EncryptedSharedPreferences, Desktop via AES-256-GCM with KeyStore-managed key. +2. **Key format**: Device addresses sanitized to `[a-zA-Z0-9_-]` for file/key safety. +3. **No logging**: Implementations MUST NOT log passphrase content or full device addresses. +4. **Thread safety**: Android `SharedPreferences.edit().apply()` is async-safe. JVM file I/O is synchronous (called from single-threaded radio dispatcher). +5. **Lifecycle**: Store persists across app restarts. Cleared only on explicit `clearPassphrase()` call (auth failure) or app data wipe. +6. **DEFAULT_BOOTS**: Companion constant (50) used as default by both UI and store implementations. diff --git a/specs/20260513-075218-lockdown-mode/plan.md b/specs/20260513-075218-lockdown-mode/plan.md index 7c0d3c94b7..719b3b6d60 100644 --- a/specs/20260513-075218-lockdown-mode/plan.md +++ b/specs/20260513-075218-lockdown-mode/plan.md @@ -11,13 +11,13 @@ Implement client-side support for firmware lockdown mode using the typed `Lockdo **Language/Version**: Kotlin 2.3+ (JDK 21) **Primary Dependencies**: Compose Multiplatform, Koin 4.2+, Wire (protobuf), Kable (BLE), Okio -**Storage**: EncryptedSharedPreferences (Android), Keychain (iOS), Java KeyStore (Desktop) +**Storage**: EncryptedSharedPreferences (Android), PKCS12 KeyStore + AES-256-GCM (Desktop) **Testing**: `./gradlew test allTests` (KMP modules use `:allTests`, Android-only use `:testFdroidDebugUnitTest`) **Target Platform**: Android (primary), Desktop (JVM), iOS (future) **Project Type**: Mobile/Desktop KMP app **Performance Goals**: Unlock flow < 5s user-perceived latency on BLE -**Constraints**: Passphrase 1-32 bytes, no logging of sensitive data, offline-capable -**Scale/Scope**: 3 new files in commonMain, 1 expect/actual per platform, UI additions to `feature/settings` +**Constraints**: Passphrase 1-64 UTF-8 bytes, no logging of sensitive data, offline-capable +**Scale/Scope**: Interfaces in `core/repository`, impl in `core/data` + `core/service`, UI in `feature/settings` ## Constitution Check @@ -70,34 +70,34 @@ specs/20260513-075218-lockdown-mode/ ### Source Code (repository root) ```text -core/model/src/commonMain/kotlin/org/meshtastic/core/model/ -└── lockdown/ - └── LockdownState.kt # Sealed class for lockdown states +core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ +└── LockdownState.kt # Sealed class: None, Locked, NeedsProvision, Unlocked, etc. core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ ├── LockdownCoordinator.kt # Interface: lockdown lifecycle owner -└── LockdownPassphraseStore.kt # Interface: encrypted per-node cache +└── LockdownPassphraseStore.kt # Interface + StoredPassphrase data class core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/ -└── LockdownCoordinatorImpl.kt # Implementation: state machine, auto-replay +└── LockdownCoordinatorImpl.kt # State machine, auto-replay, error-resilient store calls -core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/ -└── LockdownPassphraseStoreImpl.kt # EncryptedSharedPreferences impl +core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/ +└── LockdownCoordinatorImplTest.kt # 15+ test cases covering all transitions -core/datastore/src/jvmMain/kotlin/org/meshtastic/core/datastore/ -└── LockdownPassphraseStoreImpl.kt # Java KeyStore impl +core/service/src/androidMain/kotlin/org/meshtastic/core/service/ +└── LockdownPassphraseStoreImpl.kt # EncryptedSharedPreferences impl (nullable prefs) -core/datastore/src/iosMain/kotlin/org/meshtastic/core/datastore/ -└── LockdownPassphraseStoreImpl.kt # Keychain impl (stub) +core/service/src/jvmMain/kotlin/org/meshtastic/core/service/ +└── LockdownPassphraseStoreImpl.kt # PKCS12 KeyStore + AES-256-GCM file-backed impl -feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ -└── lockdown/ - ├── LockdownDialog.kt # Non-dismissable AlertDialog passphrase entry/provision modal - ├── LockdownSessionStatus.kt # Session TTL display composable - └── LockNowButton.kt # Lock Now action in Security settings +core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/ +└── FakeLockdownCoordinator.kt # Test fake with tracking vars + +feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/ +├── LockdownDialog.kt # Non-dismissable AlertDialog (provision/unlock/backoff) +└── LockdownSessionStatus.kt # Session TTL display composable ``` -**Structure Decision**: KMP multi-module with existing module boundaries. New code distributed across `core/model`, `core/repository`, `core/data`, `core/datastore`, and `feature/settings`. No new Gradle modules needed. +**Structure Decision**: KMP multi-module with existing module boundaries. New code distributed across `core/model`, `core/repository`, `core/data`, `core/service`, `core/testing`, and `feature/settings`. No new Gradle modules needed. Lock Now button integrated directly into `SecurityConfigScreen` rather than a standalone composable. ## Complexity Tracking diff --git a/specs/20260513-075218-lockdown-mode/tasks.md b/specs/20260513-075218-lockdown-mode/tasks.md index a0c0a0d746..14693faa40 100644 --- a/specs/20260513-075218-lockdown-mode/tasks.md +++ b/specs/20260513-075218-lockdown-mode/tasks.md @@ -36,7 +36,7 @@ - [X] T005 [P] Extract `LockdownCoordinator` interface from PR's concrete class to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt` — add lifecycle hooks from PR: `onConnect(nodeId: Int)`, `onConfigComplete()`, `onDisconnect()` alongside `state`, `isAuthorized`, `handleStatus()`, `submitPassphrase()`, `lockNow()` - [X] T006 [P] Extract `LockdownPassphraseStore` interface from PR's concrete class to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt` with get(nodeId), put(nodeId, passphrase), clear(nodeId) - [X] T007 Move PR's `LockdownPassphraseStore` impl from `core/service/src/androidMain/` to `core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` — keep EncryptedSharedPreferences logic, implement extracted interface -- [X] T008 [P] Create `LockdownPassphraseStoreImpl` no-op stub for JVM in `core/datastore/src/jvmMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` +- [X] T008 [P] Implement `LockdownPassphraseStoreImpl` for JVM in `core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt` — PKCS12 KeyStore + AES-256-GCM file-backed store at `~/.meshtastic/lockdown/` - [X] T009 [P] Create `LockdownPassphraseStoreImpl` no-op stub for iOS in `core/datastore/src/iosMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` - [X] T010 Extract state machine logic from PR's `LockdownHandlerImpl` (currently in `core/service/src/androidMain/`) to `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt` — keep auto-replay, wasLockNow flag, pending passphrase tracking. Remove Android/AIDL dependencies so it compiles in commonMain. - [X] T010b Keep thin AIDL adapter in `core/service/src/androidMain/` that delegates to `LockdownCoordinatorImpl` for `MeshService` IPC calls (`sendLockdownPassphrase`, `sendLockNow`) From 2a1734d932912f9d928d5f5368c11085149dad76 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 11:59:56 -0500 Subject: [PATCH 12/22] fix: finish lockdown review follow-ups Address remaining review items, add integration and JVM store tests, and sync the lockdown spec docs with the implemented API and UI. --- .../manager/FromRadioPacketHandlerImplTest.kt | 17 +- .../manager/LockdownCoordinatorImplTest.kt | 31 ++++ .../data/manager/MeshActionHandlerImplTest.kt | 24 ++- .../manager/MeshConnectionManagerImplTest.kt | 5 +- .../core/model/service/LockdownState.kt | 8 +- .../core/repository/LockdownCoordinator.kt | 8 +- .../repository/LockdownPassphraseStore.kt | 6 +- .../service/LockdownPassphraseStoreImpl.kt | 9 +- .../service/LockdownPassphraseStoreImpl.kt | 43 +++--- .../LockdownPassphraseStoreImplTest.kt | 59 +++++++ .../core/testing/FakeLockdownCoordinator.kt | 4 + .../core/ui/viewmodel/UIViewModel.kt | 3 +- .../settings/lockdown/LockdownDialog.kt | 6 +- .../settings/radio/RadioConfigViewModel.kt | 2 +- .../checklists/requirements.md | 2 +- .../contracts/lockdown-passphrase-store.md | 14 +- .../contracts/lockdown-ui.md | 130 ++++------------ .../data-model.md | 145 ++++++------------ specs/20260513-075218-lockdown-mode/plan.md | 15 +- .../quickstart.md | 73 ++++----- .../20260513-075218-lockdown-mode/research.md | 56 ++----- specs/20260513-075218-lockdown-mode/spec.md | 40 +++-- specs/20260513-075218-lockdown-mode/tasks.md | 44 +++--- 23 files changed, 362 insertions(+), 382 deletions(-) create mode 100644 core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt index b71d5fbf7e..e289af92a8 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt @@ -40,6 +40,9 @@ import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.QueueStatus import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.meshtastic.proto.LockdownStatus import org.meshtastic.proto.NodeInfo as ProtoNodeInfo class FromRadioPacketHandlerImplTest { @@ -51,6 +54,7 @@ class FromRadioPacketHandlerImplTest { private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill) private val configHandler: MeshConfigHandler = mock(MockMode.autofill) private val router: MeshRouter = mock(MockMode.autofill) + private val lockdownCoordinator = FakeLockdownCoordinator() private lateinit var handler: FromRadioPacketHandlerImpl @@ -66,7 +70,7 @@ class FromRadioPacketHandlerImplTest { mqttManager, packetHandler, notificationManager, - FakeLockdownCoordinator(), + lockdownCoordinator, ) } @@ -111,6 +115,17 @@ class FromRadioPacketHandlerImplTest { handler.handleFromRadio(proto) verify { configFlowManager.handleConfigComplete(nonce) } + assertTrue(lockdownCoordinator.configCompleteCalled) + } + + @Test + fun `handleFromRadio routes LOCKDOWN_STATUS to lockdownCoordinator`() { + val lockdownStatus = LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "token_missing") + val proto = FromRadio(lockdown_status = lockdownStatus) + + handler.handleFromRadio(proto) + + assertEquals(lockdownStatus, lockdownCoordinator.lastStatus) } @Test diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt index 4651774a8a..2f681eaa92 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt @@ -185,6 +185,17 @@ class LockdownCoordinatorImplTest { assertIs(serviceRepo.lockdownState.value) } + @Test + fun `STATE_UNSPECIFIED leaves current state unchanged`() { + serviceRepo.setLockdownState(LockdownState.Locked("needs_auth")) + + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.STATE_UNSPECIFIED)) + + val state = serviceRepo.lockdownState.value + assertIs(state) + assertEquals("needs_auth", state.lockReason) + } + // endregion // region LOCKED — manual flow @@ -334,6 +345,26 @@ class LockdownCoordinatorImplTest { assertEquals(30, state.backoffSeconds) } + @Test + fun `submit after unlock failure saves the replacement passphrase on subsequent success`() { + radioService.setDeviceAddress(testDeviceAddress) + + coordinator.submitPassphrase("wrong", boots = 10, hours = 0) + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.UNLOCK_FAILED, backoff_seconds = 0), + ) + + coordinator.submitPassphrase("correct", boots = 25, hours = 12) + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.UNLOCKED, boots_remaining = 24, valid_until_epoch = 1234), + ) + + val stored = passphraseStore.saved[testDeviceAddress] + assertEquals("correct", stored?.passphrase) + assertEquals(25, stored?.boots) + assertEquals(12, stored?.hours) + } + // endregion // region UNLOCK_FAILED — auto-replay diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt index 0eb6b0d061..b872ecc4a3 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt @@ -58,6 +58,7 @@ import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -75,6 +76,7 @@ class MeshActionHandlerImplTest { private val notificationManager = mock(MockMode.autofill) private val messageProcessor = mock(MockMode.autofill) private val radioConfigRepository = mock(MockMode.autofill) + private val lockdownCoordinator = FakeLockdownCoordinator() private val myNodeNumFlow = MutableStateFlow(MY_NODE_NUM) @@ -108,7 +110,7 @@ class MeshActionHandlerImplTest { notificationManager = notificationManager, messageProcessor = lazy { messageProcessor }, radioConfigRepository = radioConfigRepository, - lockdownCoordinator = FakeLockdownCoordinator(), + lockdownCoordinator = lockdownCoordinator, scope = scope, ) @@ -313,6 +315,26 @@ class MeshActionHandlerImplTest { verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) } } + @Test + fun handleSendLockdownUnlock_forwardsPassphraseAndTtl() { + handler = createHandler(testScope) + + handler.handleSendLockdownUnlock(passphrase = "secret", bootTtl = 25, hourTtl = 12) + + assertEquals("secret", lockdownCoordinator.lastPassphrase) + assertEquals(25, lockdownCoordinator.lastBoots) + assertEquals(12, lockdownCoordinator.lastHours) + } + + @Test + fun handleSendLockNow_forwardsToLockdownCoordinator() { + handler = createHandler(testScope) + + handler.handleSendLockNow() + + assertTrue(lockdownCoordinator.lockNowCalled) + } + // ---- handleSetOwner ---- @Test diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt index f000e8c34b..0150cf3a18 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -83,6 +83,7 @@ class MeshConnectionManagerImplTest { private val packetRepository = mock(MockMode.autofill) private val workerManager = mock(MockMode.autofill) private val appWidgetUpdater = mock(MockMode.autofill) + private val lockdownCoordinator = FakeLockdownCoordinator() private val dataPacket = DataPacket(id = 456, time = 0L, to = "0", from = "0", bytes = null, dataType = 0) @@ -134,7 +135,7 @@ class MeshConnectionManagerImplTest { workerManager, appWidgetUpdater, DataLayerHeartbeatSender(packetHandler), - FakeLockdownCoordinator(), + lockdownCoordinator, scope, ) @@ -152,6 +153,7 @@ class MeshConnectionManagerImplTest { "State should be Connecting after radio Connected", ) verify { serviceBroadcasts.broadcastConnection() } + assertEquals(true, lockdownCoordinator.connectCalled) } @Test @@ -226,6 +228,7 @@ class MeshConnectionManagerImplTest { verify { packetHandler.stopPacketQueue() } verify { locationManager.stop() } verify { mqttManager.stop() } + assertEquals(true, lockdownCoordinator.disconnectCalled) } @Test 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 index ae7c21af9e..9ceb34694e 100644 --- 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 @@ -16,7 +16,7 @@ */ package org.meshtastic.core.model.service -/** Represents the lockdown authentication state for a TAK-locked device. */ +/** Represents the lockdown authentication state for a firmware-locked device. */ sealed class LockdownState { data object None : LockdownState() @@ -39,7 +39,11 @@ sealed class LockdownState { data object UnlockFailed : LockdownState() /** Too many attempts — must wait [backoffSeconds] before retrying. */ - data class UnlockBackoff(val backoffSeconds: Int) : LockdownState() + data class UnlockBackoff(val backoffSeconds: Int) : LockdownState() { + init { + require(backoffSeconds > 0) { "backoffSeconds must be positive" } + } + } } /** 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 index c5c29a6a66..329b714a3c 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt @@ -19,7 +19,7 @@ package org.meshtastic.core.repository import org.meshtastic.proto.LockdownStatus /** - * Coordinates lockdown (TAK passphrase) authentication for TAK-locked devices. + * Coordinates lockdown passphrase authentication for firmware-locked devices. * * Implementations handle the full authentication lifecycle: auto-unlock with a stored passphrase, manual passphrase * submission, lock-now, and session lifecycle hooks. @@ -32,8 +32,10 @@ interface LockdownCoordinator { 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. + * Lifecycle hook called on every config_complete_id from the device. + * + * Currently a no-op; retained so implementations can react to config-complete in the future without changing the + * public contract. */ fun onConfigComplete() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt index 186e1b007f..a544d05715 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt @@ -17,7 +17,11 @@ package org.meshtastic.core.repository /** Stored passphrase entry with associated TTL parameters. */ -data class StoredPassphrase(val passphrase: String, val boots: Int, val hours: Int) +data class StoredPassphrase(val passphrase: String, val boots: Int, val hours: Int) { + init { + require(passphrase.isNotEmpty()) { "passphrase must not be empty" } + } +} /** * Encrypted per-device storage for lockdown passphrases. diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 394d243811..1fe042663e 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -52,9 +52,12 @@ class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { } } + private fun requirePrefs(): SharedPreferences = + prefs ?: error("Encrypted passphrase store unavailable") + @Suppress("ReturnCount") override fun getPassphrase(deviceAddress: String): StoredPassphrase? { - val p = prefs ?: return null + val p = requirePrefs() val key = sanitizeKey(deviceAddress) val passphrase = p.getString("${key}_passphrase", null) ?: return null val boots = p.getInt("${key}_boots", LockdownPassphraseStore.DEFAULT_BOOTS) @@ -63,7 +66,7 @@ class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { } override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { - val p = prefs ?: return + val p = requirePrefs() val key = sanitizeKey(deviceAddress) p.edit() .putString("${key}_passphrase", passphrase) @@ -73,7 +76,7 @@ class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { } override fun clearPassphrase(deviceAddress: String) { - val p = prefs ?: return + val p = requirePrefs() val key = sanitizeKey(deviceAddress) p.edit().remove("${key}_passphrase").remove("${key}_boots").remove("${key}_hours").apply() } diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 53da59e3c4..7fef1428d8 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -34,7 +34,8 @@ import javax.crypto.spec.GCMParameterSpec * File-backed encrypted passphrase store for JVM/Desktop. * * Uses a PKCS12 KeyStore to hold an AES-256 master key and AES-256-GCM to encrypt each passphrase entry. Entries are - * stored as individual `.enc` files under `~/.meshtastic/lockdown/`, keyed by a sanitized device address. + * stored as individual `.enc` files under `$MESHTASTIC_DATA_DIR/lockdown/` (default: `~/.meshtastic/lockdown/`), + * keyed by a sanitized device address. * * The keystore password is fixed because the threat model mirrors Android's `EncryptedSharedPreferences`: file-system * permission is the primary access control; the encryption layer protects data at rest against casual file browsing or @@ -71,26 +72,16 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { } override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { - val key = - masterKey - ?: run { - Logger.e { "Lockdown: Cannot save passphrase — keystore unavailable" } - return - } - try { - val plaintext = serialize(passphrase, boots, hours) - val encrypted = encrypt(key, plaintext) - entryFile(deviceAddress).writeBytes(encrypted) - } catch (e: Exception) { - Logger.e(e) { "Lockdown: Failed to save passphrase for device" } - } + val key = masterKey ?: error("Lockdown: Cannot save passphrase - keystore unavailable") + val plaintext = serialize(passphrase, boots, hours) + val encrypted = encrypt(key, plaintext) + entryFile(deviceAddress).writeBytes(encrypted) } override fun clearPassphrase(deviceAddress: String) { - try { - entryFile(deviceAddress).delete() - } catch (e: Exception) { - Logger.e(e) { "Lockdown: Failed to clear passphrase for device" } + val file = entryFile(deviceAddress) + if (file.exists() && !file.delete()) { + Logger.w { "Lockdown: Passphrase file was not deleted for device" } } } @@ -126,10 +117,20 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { private fun serialize(passphrase: String, boots: Int, hours: Int): ByteArray = "$boots\n$hours\n$passphrase".encodeToByteArray() - private fun deserialize(plaintext: ByteArray): StoredPassphrase { + private fun deserialize(plaintext: ByteArray): StoredPassphrase? { val text = plaintext.decodeToString() val lines = text.split("\n", limit = 3) - return StoredPassphrase(passphrase = lines[2], boots = lines[0].toInt(), hours = lines[1].toInt()) + if (lines.size < SERIALIZED_LINE_COUNT) { + Logger.w { "Lockdown: Invalid passphrase entry format" } + return null + } + val boots = lines[0].toIntOrNull() + val hours = lines[1].toIntOrNull() + if (boots == null || hours == null) { + Logger.w { "Lockdown: Invalid passphrase entry metadata" } + return null + } + return StoredPassphrase(passphrase = lines[2], boots = boots, hours = hours) } // endregion @@ -162,11 +163,13 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { private const val KEYSTORE_FILE = "keystore.p12" private const val KEYSTORE_TYPE = "PKCS12" private const val KEY_ALIAS = "lockdown_master" + // Intentional: this mirrors the documented desktop threat model for at-rest protection only. private val KEYSTORE_PASSWORD = "meshtastic-lockdown".toCharArray() private const val AES_ALGORITHM = "AES" private const val AES_GCM_TRANSFORM = "AES/GCM/NoPadding" private const val AES_KEY_BITS = 256 private const val GCM_TAG_BITS = 128 private const val BYTE_MASK = 0xFF + private const val SERIALIZED_LINE_COUNT = 3 } } diff --git a/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt new file mode 100644 index 0000000000..e49d5d9f59 --- /dev/null +++ b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 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 java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class LockdownPassphraseStoreImplTest { + private lateinit var tempHome: java.nio.file.Path + private lateinit var originalUserHome: String + + @BeforeTest + fun setUp() { + originalUserHome = System.getProperty("user.home") + tempHome = Files.createTempDirectory("lockdown-passphrase-store-test") + System.setProperty("user.home", tempHome.toString()) + } + + @AfterTest + fun tearDown() { + System.setProperty("user.home", originalUserHome) + File(tempHome.toString()).deleteRecursively() + } + + @Test + fun `save get and clear passphrase round trips on jvm`() { + val store = LockdownPassphraseStoreImpl() + + store.savePassphrase(deviceAddress = "AA:BB:CC:DD", passphrase = "secret", boots = 10, hours = 24) + + val stored = store.getPassphrase("AA:BB:CC:DD") + assertEquals("secret", stored?.passphrase) + assertEquals(10, stored?.boots) + assertEquals(24, stored?.hours) + + store.clearPassphrase("AA:BB:CC:DD") + + assertNull(store.getPassphrase("AA:BB:CC:DD")) + } +} \ No newline at end of file diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt index e5385e8aa0..40a318c359 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt @@ -25,6 +25,8 @@ class FakeLockdownCoordinator : LockdownCoordinator { var configCompleteCalled = false var lastStatus: LockdownStatus? = null var lastPassphrase: String? = null + var lastBoots: Int? = null + var lastHours: Int? = null var lockNowCalled = false override fun onConnect() { @@ -45,6 +47,8 @@ class FakeLockdownCoordinator : LockdownCoordinator { override fun submitPassphrase(passphrase: String, boots: Int, hours: Int) { lastPassphrase = passphrase + lastBoots = boots + lastHours = hours } override fun lockNow() { 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 8d14c4d7d3..c942626fe9 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 @@ -59,6 +59,7 @@ import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.client_notification import org.meshtastic.core.resources.compromised_keys @@ -312,6 +313,6 @@ class UIViewModel( } companion object { - private const val DEFAULT_BOOT_TTL = 50 + private const val DEFAULT_BOOT_TTL = LockdownPassphraseStore.DEFAULT_BOOTS } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt index a1767fdde2..56300347a4 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -68,8 +68,8 @@ import org.meshtastic.core.ui.icon.VisibilityOff * Non-dismissable lockdown authentication dialog. * * Shown when the connected device requires passphrase authentication. The dialog blocks all interaction with the app - * until the user either authenticates successfully or disconnects. Back gestures are intercepted and treated as - * disconnect. + * until the user either authenticates successfully or disconnects. Back gestures are suppressed to prevent dismissing + * the dialog and bypassing authentication. */ @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable @@ -172,7 +172,7 @@ fun LockdownDialog( Spacer(modifier = Modifier.height(SPACING_DP.dp)) OutlinedTextField( value = confirmPassphrase, - onValueChange = { if (it.length <= MAX_PASSPHRASE_LEN) confirmPassphrase = it }, + onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) confirmPassphrase = it }, label = { Text(stringResource(Res.string.lockdown_confirm_passphrase)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), 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 040c0bb7c9..a3bf0037ad 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 @@ -144,7 +144,7 @@ open class RadioConfigViewModel( val sessionAuthorized = serviceRepository.sessionAuthorized fun sendLockNow() { - viewModelScope.launch { lockdownCoordinator.lockNow() } + safeLaunch(tag = "sendLockNow") { lockdownCoordinator.lockNow() } } val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed diff --git a/specs/20260513-075218-lockdown-mode/checklists/requirements.md b/specs/20260513-075218-lockdown-mode/checklists/requirements.md index c37c79d8a0..95bfc80106 100644 --- a/specs/20260513-075218-lockdown-mode/checklists/requirements.md +++ b/specs/20260513-075218-lockdown-mode/checklists/requirements.md @@ -33,4 +33,4 @@ - All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. - Proto contract is well-defined in upstream `admin.proto` and `mesh.proto` — no ambiguity in the firmware interface. -- Nick's PR #4703 provides implementation reference but this spec intentionally stays at the behavior level. +- Nick's draft PR #5439 provides the implementation reference, but this spec intentionally stays at the behavior level. diff --git a/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md b/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md index 79af30b9b8..8a9a693f99 100644 --- a/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md +++ b/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md @@ -48,16 +48,16 @@ class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { EncryptedSharedPreferences.create(app, PREFS_FILE_NAME, masterKey, ...) } catch (e: Exception) { Logger.e(e) { "Failed to initialize encrypted passphrase store" } - null // Graceful degradation — auto-unlock disabled + null } } - // All methods use `val p = prefs ?: return null/Unit` pattern + private fun requirePrefs(): SharedPreferences = prefs ?: error("Encrypted passphrase store unavailable") } ``` - **Storage**: `EncryptedSharedPreferences` with AES-256-GCM MasterKey (hardware keystore when available) - **Key format**: `"${sanitizedDeviceAddress}_passphrase"`, `"..._boots"`, `"..._hours"` -- **Error resilience**: `prefs` is nullable — crypto init failure makes store silently no-op with logging +- **Error resilience**: initialization failures are logged once; subsequent operations fail fast so callers can handle persistence errors explicitly ### JVM/Desktop (`core/service/jvmMain`) @@ -69,17 +69,17 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { } ``` -- **Storage**: PKCS12 KeyStore at `~/.meshtastic/lockdown/keystore.p12` + per-device `.enc` files +- **Storage**: PKCS12 KeyStore at `$MESHTASTIC_DATA_DIR/lockdown/keystore.p12` (default `~/.meshtastic/lockdown/keystore.p12`) + per-device `.enc` files - **Key management**: Generates random AES-256 key on first use, stores in PKCS12 keystore - **Encryption**: AES-256-GCM with random IV per write; format `[1B IV len][IV][ciphertext]` - **Data format**: Line-based `"boots\nhours\npassphrase"` (avoids kotlinx-serialization dependency) -- **Error resilience**: Same nullable master key pattern as Android +- **Error resilience**: read failures return `null`; write failures throw so the coordinator can log and keep the session unlocked ## Behavioral Contract 1. **Encryption at rest**: Both platforms encrypt passphrase data. Android via EncryptedSharedPreferences, Desktop via AES-256-GCM with KeyStore-managed key. -2. **Key format**: Device addresses sanitized to `[a-zA-Z0-9_-]` for file/key safety. +2. **Key format**: Device addresses are sanitized for file/key safety. 3. **No logging**: Implementations MUST NOT log passphrase content or full device addresses. 4. **Thread safety**: Android `SharedPreferences.edit().apply()` is async-safe. JVM file I/O is synchronous (called from single-threaded radio dispatcher). 5. **Lifecycle**: Store persists across app restarts. Cleared only on explicit `clearPassphrase()` call (auth failure) or app data wipe. -6. **DEFAULT_BOOTS**: Companion constant (50) used as default by both UI and store implementations. +6. **DEFAULT_BOOTS**: Companion constant (50) is the shared default for provisioning and cached TTL metadata. diff --git a/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md b/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md index 75cb81891a..913f24a3ed 100644 --- a/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md +++ b/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md @@ -3,132 +3,60 @@ **Module**: `feature/settings` **Source set**: `commonMain` -## LockdownDialog (non-dismissable blocking dialog) +## LockdownDialog ```kotlin -/** - * Non-dismissable AlertDialog that blocks all app interaction when the connected - * node is in a lockdown state requiring user action (LOCKED or NEEDS_PROVISION). - * - * Uses `onDismissRequest = {}` + `BackHandler` to prevent dismissal. - * Shown when state requires auth; hidden when state transitions to Unlocked or NotApplicable. - * - * @param state Current lockdown state from LockdownCoordinator - * @param onSubmitPassphrase Called with (passphrase, bootsRemaining, validUntilEpoch) - * @param onDisconnect Called when user wants to disconnect instead of authenticating - */ @Composable fun LockdownDialog( - state: LockdownState, - onSubmitPassphrase: (ByteArray, UInt, UInt) -> Unit, + lockdownState: LockdownState, + onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit, onDisconnect: () -> Unit, ) ``` -### UI States Rendered +`LockdownDialog` is a non-dismissable `AlertDialog` shown while the connected device requires lockdown authentication. It uses `onDismissRequest = {}` and offers an explicit Disconnect button instead of allowing dismissal. + +### Rendered States | `LockdownState` | UI Rendering | |-----------------|-------------| -| `NeedsProvision` | "Set Passphrase" title, passphrase field + confirm field, optional TTL fields, Submit button | -| `Locked` | "Unlock Device" title, passphrase field, optional TTL fields (hidden for unlock), Submit button, lock_reason displayed | -| `Unlocking` | Same as above with Submit disabled + loading indicator | -| `UnlockFailed(backoff=0)` | Error text "Incorrect passphrase", Submit enabled for retry | -| `UnlockFailed(backoff>0)` | Error text + countdown timer, Submit disabled until backoff expires | -| `LockNowPending` | "Locking device..." with spinner | -| `LockNowAcknowledged` | "Device locked" confirmation, auto-disconnect in progress | +| `NeedsProvision` | "Set Passphrase" title, passphrase + confirm fields, editable `boots` / `hours` inputs, Submit button | +| `Locked` | "Enter Passphrase" title, passphrase field, lock reason when present, Submit button | +| `UnlockFailed` | Same as `Locked` plus incorrect-passphrase error text | +| `UnlockBackoff` | Same as `Locked` plus backoff error text; Submit disabled | +| `None` / `Unlocked` / `LockNowAcknowledged` | Dialog hidden | ### Component Details -- **Passphrase field**: `OutlinedTextField` with `visualTransformation = PasswordVisualTransformation()`, trailing eye icon to toggle visibility -- **Confirm field** (provision only): Second `OutlinedTextField` with match validation -- **Boots remaining** (optional): `OutlinedTextField` with `keyboardType = KeyboardType.Number`, hint "Leave empty for default" -- **Hours until expiry** (optional): `OutlinedTextField` with number input, converted to `valid_until_epoch` (current time + hours * 3600) -- **Submit button**: `FilledTonalButton`, disabled during backoff or when passphrase empty -- **Disconnect button**: `TextButton` "Disconnect" to allow user to bail without authenticating -- **Error display**: `Text` with `MaterialTheme.colorScheme.error` color - ---- +- **Passphrase field**: `OutlinedTextField` with password visibility toggle +- **Confirm field**: shown only in provisioning mode +- **Provisioning TTL fields**: integer `boots` and `hours`; current defaults are `50` and `0` +- **Validation**: passphrase is required and limited to 64 UTF-8 bytes; confirm field must match in provisioning mode +- **Disconnect button**: explicit escape hatch when the user does not want to authenticate -## LockdownSessionStatus (session info row) +## LockdownSessionStatus ```kotlin -/** - * Displays current session token TTL information in Security settings. - * Only visible when node is in UNLOCKED state. - * - * @param session Active session info (boots remaining, expiry) - */ @Composable -fun LockdownSessionStatus( - session: LockdownState.Unlocked, -) +fun LockdownSessionStatus(tokenInfo: LockdownTokenInfo?, modifier: Modifier = Modifier) ``` +`LockdownSessionStatus` is shown in `SecurityConfigScreen` only when `sessionAuthorized == true` and `tokenInfo` is non-null. + ### Display Format | Condition | Displayed Text | |-----------|---------------| -| `bootsRemaining > 0 && validUntilEpoch > 0` | "Session: N reboots remaining, expires [formatted date]" | -| `bootsRemaining > 0 && validUntilEpoch == 0` | "Session: N reboots remaining, no time limit" | -| `bootsRemaining == 0 && validUntilEpoch > 0` | "Session: expires [formatted date]" | -| `bootsRemaining == 0 && validUntilEpoch == 0` | "Session: no expiry configured" | +| `bootsRemaining > 0` | "Session: N reboots remaining" | +| `expiryEpoch > 0` | "expires [formatted date]" | +| `expiryEpoch == 0` | "no time limit" | ---- +## Lock Now Action -## LockNowButton +There is no standalone `LockNowButton` composable in the current implementation. The Lock Now action is a `NodeActionButton` embedded directly in `SecurityConfigScreen` and enabled only when the device is connected and `sessionAuthorized == true`. -```kotlin -/** - * "Lock Now" button for Security settings. Only enabled when the node is - * UNLOCKED and lockdown is applicable. - * - * @param isEnabled true when node is unlocked and user can issue lock-now - * @param onClick Callback to trigger lock-now via LockdownCoordinator - */ -@Composable -fun LockNowButton( - isEnabled: Boolean, - onClick: () -> Unit, -) -``` +## Integration Points -### Visibility Rules - -| Coordinator State | Button State | -|-------------------|-------------| -| `NotApplicable` | Hidden (node doesn't support lockdown) | -| `Unlocked` | Visible + Enabled | -| `Locked` / `NeedsProvision` | Visible + Disabled with "Device is locked" hint | -| `LockNowPending` | Visible + Disabled + "Locking..." text | -| `LockNowAcknowledged` | Hidden (disconnecting) | - ---- - -## Integration Point - -The `LockdownScreen` composable is placed at the app's top-level composition: - -```kotlin -// In the main app content composable (after connection established): -val lockdownState by lockdownCoordinator.state.collectAsStateWithLifecycle() - -Box { - // Normal navigation content - MeshtasticNavDisplay(...) - - // Lockdown overlay — blocks everything when active - when (val state = lockdownState) { - is LockdownState.NotApplicable, - is LockdownState.Unlocked -> { /* Normal operation, no overlay */ } - else -> { - LockdownScreen( - state = state, - onSubmitPassphrase = { pass, boots, epoch -> - scope.launch { lockdownCoordinator.submitPassphrase(pass, boots, epoch) } - }, - onDisconnect = { connectionManager.disconnect() }, - ) - } - } -} -``` +- `UIViewModel` and `ConnectionsViewModel` expose `lockdownState` from `ServiceRepository` +- `RadioConfigViewModel` exposes `lockdownTokenInfo`, `sessionAuthorized`, and `sendLockNow()` for the security screen +- `SecurityConfigScreen` renders `LockdownSessionStatus` above the Lock Now action when the current session is authorized diff --git a/specs/20260513-075218-lockdown-mode/data-model.md b/specs/20260513-075218-lockdown-mode/data-model.md index aab75b5992..108f859c47 100644 --- a/specs/20260513-075218-lockdown-mode/data-model.md +++ b/specs/20260513-075218-lockdown-mode/data-model.md @@ -5,130 +5,73 @@ ## Domain Entities -### LockdownState (sealed class) +### LockdownState -The core state machine representing the current lockdown status of the connected node. +The current implementation models lockdown UI state with a sealed class in `core/model`. | Variant | Fields | Description | |---------|--------|-------------| -| `NotApplicable` | — | Node doesn't support lockdown (no `LockdownStatus` received) | -| `NeedsProvision` | — | First-time setup; no passphrase ever set on this device | -| `Locked` | `lockReason: LockdownStatus.State` | Storage locked or client not authenticated; uses proto enum directly | -| `Unlocking` | — | Auth sent; awaiting firmware response | -| `Unlocked` | `bootsRemaining: UInt`, `validUntilEpoch: UInt` | Authenticated; session active with TTL info | -| `UnlockFailed` | `backoffSeconds: UInt` | Passphrase rejected; optional rate-limit | -| `LockNowPending` | — | Lock-now command sent; awaiting firmware ACK | -| `LockNowAcknowledged` | — | Firmware confirmed lock; will disconnect | +| `None` | — | No active lockdown prompt for the current connection | +| `NeedsProvision` | — | Node requires initial passphrase provisioning | +| `Locked` | `lockReason: String` | Node is locked and awaiting authentication | +| `Unlocked` | — | Current BLE session is authorized | +| `UnlockFailed` | — | Firmware rejected the submitted passphrase and allows immediate retry | +| `UnlockBackoff` | `backoffSeconds: Int` | Firmware rejected the passphrase and rate-limited retries | +| `LockNowAcknowledged` | — | Lock-now was acknowledged; client should disconnect and clear session state | -**State Transitions:** +### LockdownTokenInfo -``` - ┌─────────────────────┐ - │ NotApplicable │ (no LockdownStatus ever received) - └─────────────────────┘ - -┌─────────────────────────────────────────────────────────────────────┐ -│ FromRadio.lockdown_status received │ -└─────────────────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ - ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ - │NeedsProvision│ │ Locked │ │ Unlocked │ - └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ - │ │ │ - │ user submits │ user submits / │ user presses - │ passphrase │ auto-replay │ "Lock Now" - ▼ ▼ ▼ - ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ - │ Unlocking │ │ Unlocking │ │LockNowPending│ - └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ - │ │ │ - │ UNLOCKED │ UNLOCK_FAILED │ LOCKED (with - ▼ ▼ │ wasLockNow set) - ┌──────────────┐ ┌──────────────┐ ▼ - │ Unlocked │ │ UnlockFailed │ ┌────────────────────┐ - └──────────────┘ └──────┬───────┘ │LockNowAcknowledged │ - │ └────────┬───────────┘ - │ retry │ - ▼ │ disconnect - ┌──────────────┐ ▼ - │ Locked │ (connection closed) - └──────────────┘ -``` - -**Validation Rules:** -- `passphrase`: 1-32 bytes (non-empty for provision/unlock, ignored for lock-now) -- `bootsRemaining`: 0 = firmware default; any positive value accepted -- `validUntilEpoch`: 0 = no time limit; positive = absolute Unix seconds -- `backoffSeconds`: 0 = no backoff (immediate retry allowed); >0 = enforced wait - ---- - -### LockdownSession (data class) - -Represents the active session info displayed to the user after successful unlock. +Session TTL metadata is stored separately from `LockdownState`. | Field | Type | Description | |-------|------|-------------| -| `bootsRemaining` | `UInt` | Reboots before token expires (decrements per boot) | -| `validUntilEpoch` | `UInt` | Unix epoch seconds when token expires; 0 = no time limit | +| `bootsRemaining` | `Int` | Reboots remaining before the token expires | +| `expiryEpoch` | `Long` | Unix epoch seconds when the token expires; `0` means no time limit | -**Derived properties:** -- `hasTimeLimit: Boolean` = `validUntilEpoch > 0u` -- `isBootLimited: Boolean` = `bootsRemaining > 0u` +### StoredPassphrase ---- - -### CachedPassphrase (per-node storage) +Encrypted cached passphrase metadata keyed by connected device address. | Field | Type | Description | |-------|------|-------------| -| `nodeId` | `Int` | Node number (mesh address) used as storage key | -| `passphrase` | `ByteArray` | Raw passphrase bytes (1-32), encrypted at rest | - -**Storage key format:** `"lockdown_${nodeId.toUInt().toString(16)}"` (hex node ID) - -**Lifecycle:** -- Created/updated on successful unlock (UNLOCKED received after user-entered passphrase) -- Read on reconnection (LOCKED received → auto-replay attempt) -- Deleted when auto-replay fails (UNLOCK_FAILED after cached passphrase sent) -- Never logged or exposed in debug output +| `passphrase` | `String` | Non-empty passphrase string | +| `boots` | `Int` | Provisioning boot TTL cached alongside the passphrase | +| `hours` | `Int` | Provisioning hour TTL cached alongside the passphrase | ---- +**Storage key**: sanitized device address string, not mesh node number. ## Proto Mapping -### FromRadio.lockdown_status → LockdownState +### FromRadio.lockdown_status -> ServiceRepository state -| Proto `LockdownStatus.State` | Maps to `LockdownState` | -|------------------------------|-------------------------| -| `NEEDS_PROVISION` | `NeedsProvision` | -| `LOCKED` | `Locked(reason = status.lock_reason)` | -| `UNLOCKED` | `Unlocked(bootsRemaining = status.boots_remaining, validUntilEpoch = status.valid_until_epoch)` | -| `UNLOCK_FAILED` | `UnlockFailed(backoffSeconds = status.backoff_seconds)` | -| `STATE_UNSPECIFIED` | Treated as `Locked(reason = "unknown")` | +| Proto `LockdownStatus.State` | Result | +|------------------------------|--------| +| `NEEDS_PROVISION` | `lockdownState = NeedsProvision` | +| `LOCKED` | auto-replay cached passphrase when available; otherwise `lockdownState = Locked(lockReason)` | +| `UNLOCKED` | `lockdownState = Unlocked`, `sessionAuthorized = true`, `lockdownTokenInfo = LockdownTokenInfo(...)` | +| `UNLOCK_FAILED` with `backoff_seconds > 0` | `lockdownState = UnlockBackoff(backoffSeconds)` | +| `UNLOCK_FAILED` with `backoff_seconds == 0` | `lockdownState = UnlockFailed` for manual submits; `Locked()` after failed auto-replay | +| `STATE_UNSPECIFIED` | No state change; warning logged | -### LockdownAuth → AdminMessage (outgoing) +### LockdownAuth -> AdminMessage (outgoing) | Operation | `passphrase` | `boots_remaining` | `valid_until_epoch` | `lock_now` | -|-----------|-------------|-------------------|--------------------|-----------| -| Provision | user-entered (1-32 bytes) | user-entered or 0 | user-entered or 0 | `false` | -| Unlock | user-entered (1-32 bytes) | 0 (firmware default) | 0 (no limit) | `false` | -| Auto-replay | cached bytes | 0 | 0 | `false` | -| Lock Now | empty/ignored | 0 | 0 | `true` | - ---- +|-----------|-------------|-------------------|--------------------|-----------| +| Provision | user-entered UTF-8 string (1-64 bytes) | UI-provided `boots` | UI-provided `hours` mapped by firmware/client contract | `false` | +| Unlock | user-entered UTF-8 string | cached or submitted `boots` | cached or submitted `hours` | `false` | +| Auto-replay | cached `StoredPassphrase.passphrase` | cached `boots` | cached `hours` | `false` | +| Lock Now | empty / ignored | `0` | `0` | `true` | ## Relationships -``` -LockdownCoordinator (1) ──owns──▶ LockdownState (1, current) -LockdownCoordinator (1) ──uses──▶ LockdownPassphraseStore (1) -LockdownCoordinator (1) ──uses──▶ CommandSender (1, for sending AdminMessage) -LockdownCoordinator (1) ──uses──▶ ConnectionManager (1, for disconnect on lock-now) -FromRadioPacketHandler (1) ──calls──▶ LockdownCoordinator.handleStatus() -UI (LockdownDialog) ──observes──▶ LockdownCoordinator.state (StateFlow) -UI (LockdownDialog) ──calls──▶ LockdownCoordinator.submitPassphrase() -UI (LockNowButton) ──calls──▶ LockdownCoordinator.lockNow() -SecurityConfigScreen ──observes──▶ LockdownCoordinator.state (for session info) +```text +FromRadioPacketHandlerImpl -> LockdownCoordinator.handleLockdownStatus() +LockdownCoordinatorImpl -> LockdownPassphraseStore +LockdownCoordinatorImpl -> CommandSender +LockdownCoordinatorImpl -> ServiceRepository +LockdownCoordinatorImpl -> MeshConnectionManager +UIViewModel / ConnectionsViewModel -> ServiceRepository.lockdownState +RadioConfigViewModel -> ServiceRepository.lockdownTokenInfo / sessionAuthorized +LockdownDialog -> UIViewModel.sendLockdownUnlock() / disconnect callback +SecurityConfigScreen -> RadioConfigViewModel.sendLockNow() ``` diff --git a/specs/20260513-075218-lockdown-mode/plan.md b/specs/20260513-075218-lockdown-mode/plan.md index 719b3b6d60..b107b3e6fb 100644 --- a/specs/20260513-075218-lockdown-mode/plan.md +++ b/specs/20260513-075218-lockdown-mode/plan.md @@ -1,11 +1,11 @@ # Implementation Plan: Lockdown Mode -**Branch**: `feat/lockdown-mode` | **Date**: 2026-05-13 | **Spec**: [spec.md](spec.md) +**Branch**: `features/lockdown-v2` | **Date**: 2026-05-13 | **Spec**: [spec.md](spec.md) **Input**: Feature specification from `specs/20260513-075218-lockdown-mode/spec.md` ## Summary -Implement client-side support for firmware lockdown mode using the typed `LockdownAuth` / `LockdownStatus` protobuf contract. The app detects locked nodes via `FromRadio.lockdown_status`, presents a non-dismissable blocking passphrase dialog, sends `AdminMessage.lockdown_auth` for provision/unlock/lock-now operations, caches passphrases in platform-encrypted storage, and auto-replays on reconnect. Architecture uses a `LockdownCoordinator` interface in `commonMain` with platform-specific passphrase store implementations via expect/actual. +Implement client-side support for firmware lockdown mode using the typed `LockdownAuth` / `LockdownStatus` protobuf contract. The app detects locked nodes via `FromRadio.lockdown_status`, presents a non-dismissable blocking passphrase dialog, sends `AdminMessage.lockdown_auth` for provision/unlock/lock-now operations, caches passphrases in platform-encrypted storage, and auto-replays on reconnect. Architecture uses `LockdownCoordinator` and `LockdownPassphraseStore` interfaces in `commonMain` with platform-specific implementations wired through DI. ## Technical Context @@ -13,7 +13,7 @@ Implement client-side support for firmware lockdown mode using the typed `Lockdo **Primary Dependencies**: Compose Multiplatform, Koin 4.2+, Wire (protobuf), Kable (BLE), Okio **Storage**: EncryptedSharedPreferences (Android), PKCS12 KeyStore + AES-256-GCM (Desktop) **Testing**: `./gradlew test allTests` (KMP modules use `:allTests`, Android-only use `:testFdroidDebugUnitTest`) -**Target Platform**: Android (primary), Desktop (JVM), iOS (future) +**Target Platform**: Android (primary), Desktop (JVM) **Project Type**: Mobile/Desktop KMP app **Performance Goals**: Unlock flow < 5s user-perceived latency on BLE **Constraints**: Passphrase 1-64 UTF-8 bytes, no logging of sensitive data, offline-capable @@ -26,8 +26,7 @@ Implement client-side support for firmware lockdown mode using the typed `Lockdo - **I. Kotlin Multiplatform Core**: ✅ PASS - `commonMain`: `LockdownCoordinator` interface, `LockdownState` sealed class, `LockdownPassphraseStore` interface, UI composables (dialog, lock-now button, session status) - `androidMain`: `LockdownPassphraseStoreImpl` (EncryptedSharedPreferences) - - `jvmMain`: `LockdownPassphraseStoreImpl` (Java KeyStore file-backed) - - `iosMain`: `LockdownPassphraseStoreImpl` (Keychain) — stub for now + - `jvmMain`: `LockdownPassphraseStoreImpl` (PKCS12 KeyStore + AES-256-GCM file-backed) - No `java.*` or `android.*` imports in commonMain. All business logic in commonMain. - **II. Zero Lint Tolerance**: ✅ PASS @@ -35,8 +34,8 @@ Implement client-side support for firmware lockdown mode using the typed `Lockdo - Modules touched: `:core:model`, `:core:repository`, `:core:data`, `:core:datastore`, `:feature:settings` - **III. Compose Multiplatform UI**: ✅ PASS - - Lockdown dialog is a non-dismissable `AlertDialog` composable in commonMain (`onDismissRequest = {}` + `BackHandler`) - - No `NavigationBackHandler` needed (dialog blocks all interaction; dismiss = disconnect) + - Lockdown dialog is a non-dismissable `AlertDialog` composable in commonMain (`onDismissRequest = {}`) + - No `NavigationBackHandler` needed (dialog blocks all interaction; disconnect is explicit) - No float formatting needed (TTL displayed as integer boot count / formatted date string) - **IV. Privacy First**: ✅ PASS @@ -51,7 +50,7 @@ Implement client-side support for firmware lockdown mode using the typed `Lockdo - **VI. Verify Before Push**: ✅ PASS - Local: `./gradlew spotlessApply detekt assembleDebug test allTests` - - Post-push: `gh pr checks ` or `gh run list --branch feat/lockdown-mode --limit 5` + - Post-push: `gh pr checks ` or `gh run list --branch features/lockdown-v2 --limit 5` ## Project Structure diff --git a/specs/20260513-075218-lockdown-mode/quickstart.md b/specs/20260513-075218-lockdown-mode/quickstart.md index 4e6abc80a2..f40d56c0bd 100644 --- a/specs/20260513-075218-lockdown-mode/quickstart.md +++ b/specs/20260513-075218-lockdown-mode/quickstart.md @@ -8,7 +8,7 @@ - JDK 21 installed, `ANDROID_HOME` set - Proto submodule initialized: `git submodule update --init` - `local.properties` exists (copy from `secrets.defaults.properties` if missing) -- Proto submodule bumped to revision containing `LockdownAuth` (admin.proto tag 104) and `LockdownStatus` (mesh.proto tag 18). See protobufs#911. +- Proto submodule includes `LockdownAuth` and `LockdownStatus` ## Quick Verification @@ -20,31 +20,29 @@ ./gradlew :core:model:allTests ./gradlew :core:repository:allTests ./gradlew :core:data:allTests -./gradlew :core:datastore:allTests +./gradlew :core:service:jvmTest ./gradlew :feature:settings:allTests ``` ## Implementation Order -1. **`core/model`** — `LockdownState` sealed class (no dependencies) -2. **`core/repository`** — `LockdownCoordinator` interface + `LockdownPassphraseStore` interface -3. **`core/datastore`** — Platform implementations of `LockdownPassphraseStore` (Android real, JVM/iOS stubs) -4. **`core/data`** — `LockdownCoordinatorImpl` (state machine, auto-replay logic) -5. **`core/data`** — Wire `FromRadioPacketHandlerImpl` to route `lockdown_status` to coordinator -6. **`feature/settings`** — `LockdownDialog` (non-dismissable AlertDialog), `LockdownSessionStatus`, `LockNowButton` -7. **App shell** — Show `LockdownDialog` when lockdown state requires auth -8. **Banner gating** — Add `isAuthorized` checks to action-prompting banners +1. **`core/model`** — `LockdownState` and `LockdownTokenInfo` +2. **`core/repository`** — `LockdownCoordinator` + `LockdownPassphraseStore` interfaces +3. **`core/service`** — Android and JVM `LockdownPassphraseStoreImpl` +4. **`core/data`** — `LockdownCoordinatorImpl` state machine and packet routing +5. **`feature/settings`** — `LockdownDialog` and `LockdownSessionStatus` +6. **App shell / view models** — expose `lockdownState`, unlock action, and lock-now action ## Key Files to Modify | File | Change | |------|--------| -| `core/data/.../FromRadioPacketHandlerImpl.kt` | Add `lockdown_status` branch in `when` block | -| `core/data/.../CommandSenderImpl.kt` | Add `sendLockdownAuth()` helper (or inline in coordinator) | -| `feature/settings/.../SecurityConfigScreen.kt` | Add `LockdownSessionStatus` + `LockNowButton` | -| App top-level composable | Add lockdown state observation + `LockdownScreen` overlay | +| `core/data/.../FromRadioPacketHandlerImpl.kt` | Route `lockdown_status` and `config_complete_id` lifecycle events to the coordinator | +| `core/data/.../CommandSenderImpl.kt` | Add `sendLockdownPassphrase()` and `sendLockNow()` helpers | +| `feature/settings/.../SecurityConfigScreen.kt` | Add `LockdownSessionStatus` and Lock Now action | +| App top-level composable | Observe `lockdownState` and show `LockdownDialog` overlay | -## Key Files to Create +## Key Files Created | File | Module | Source Set | |------|--------|-----------| @@ -52,45 +50,42 @@ | `LockdownCoordinator.kt` | `core/repository` | commonMain | | `LockdownPassphraseStore.kt` | `core/repository` | commonMain | | `LockdownCoordinatorImpl.kt` | `core/data` | commonMain | -| `LockdownPassphraseStoreImpl.kt` | `core/datastore` | androidMain | -| `LockdownPassphraseStoreImpl.kt` | `core/datastore` | jvmMain | -| `LockdownPassphraseStoreImpl.kt` | `core/datastore` | iosMain | -| `LockdownScreen.kt` | `feature/settings` | commonMain | +| `LockdownPassphraseStoreImpl.kt` | `core/service` | androidMain | +| `LockdownPassphraseStoreImpl.kt` | `core/service` | jvmMain | +| `LockdownDialog.kt` | `feature/settings` | commonMain | | `LockdownSessionStatus.kt` | `feature/settings` | commonMain | -| `LockNowButton.kt` | `feature/settings` | commonMain | ## Testing Strategy -### Unit Tests (commonMain) +### Unit Tests - `LockdownCoordinatorImpl` state machine transitions -- Auto-replay logic (cached passphrase → auto-submit on LOCKED) -- Cache-clear-on-failure logic (UNLOCK_FAILED after auto-replay → clear) -- Lock-now flag tracking (wasLockNow → LockNowAcknowledged on LOCKED) -- Backoff enforcement (timer expires before retry allowed) +- Auto-replay logic (cached passphrase -> auto-submit on LOCKED) +- Cache-clear-on-failure logic (UNLOCK_FAILED after auto-replay -> clear) +- Lock-now flag tracking (`wasLockNow` -> `LockNowAcknowledged` on LOCKED) +- Backoff state transitions and retry flow +- JVM passphrase store round-trip (`save -> get -> clear`) ### Integration Testing -Requires a device flashed with LOCKDOWN firmware build: -- Provision flow (fresh device → set passphrase → UNLOCKED) -- Unlock flow (locked device → enter passphrase → UNLOCKED) -- Auto-replay (disconnect → reconnect → auto-unlocked without prompt) -- Wrong passphrase (→ UNLOCK_FAILED, retry) -- Backoff (multiple wrong attempts → countdown) -- Lock Now (→ device reboots → next connection requires auth) -- Token expiry (set short TTL → reboot past limit → LOCKED) +Requires a device flashed with lockdown-capable firmware: +- Provision flow (fresh device -> set passphrase -> UNLOCKED) +- Unlock flow (locked device -> enter passphrase -> UNLOCKED) +- Auto-replay (disconnect -> reconnect -> auto-unlocked without prompt) +- Wrong passphrase (-> UNLOCK_FAILED, retry) +- Backoff (multiple wrong attempts -> countdown) +- Lock Now (-> device reboots -> next connection requires auth) ## Dependencies | Dependency | Module | Purpose | |-----------|--------|---------| -| `androidx.security:security-crypto` | `core/datastore` (androidMain) | EncryptedSharedPreferences | +| `androidx.security:security-crypto` | `core/service` (androidMain) | EncryptedSharedPreferences | | Wire-generated protos | `core/proto` | `LockdownAuth`, `LockdownStatus`, `AdminMessage` | ## Common Pitfalls -1. **Proto submodule not bumped**: `LockdownAuth` and `LockdownStatus` don't exist until the proto submodule includes protobufs#911. Build will fail with unresolved references. -2. **`when` exhaustiveness**: New `ModemPreset` enum entries from the proto bump will break exhaustive `when` blocks in `Channel.kt`, `ChannelOption.kt`, `ModelExtensions.kt`. Fix those separately from lockdown changes. -3. **Passphrase encoding**: Proto defines `bytes passphrase = 1`. Use `ByteString` / `ByteArray` directly — do NOT convert to/from UTF-8 String (passphrases may contain arbitrary bytes). -4. **Node ID for local device**: Use `serviceRepository.myNodeNum` (or equivalent) as `destNum` when sending admin messages to the locally-connected node. -5. **Testing without hardware**: The lockdown state machine can be unit-tested by mocking the `LockdownPassphraseStore` and calling `handleStatus()` directly with constructed `LockdownStatus` protos. +1. **Proto submodule not bumped**: `LockdownAuth` and `LockdownStatus` must exist in the current proto revision. +2. **Passphrase validation**: The current UI enforces a maximum of 64 UTF-8 bytes for both passphrase and confirmation fields. +3. **Storage keying**: Cached passphrases are keyed by connected device address, not mesh node number. +4. **Testing without hardware**: The lockdown state machine can be unit-tested by mocking the `LockdownPassphraseStore` and calling `handleLockdownStatus()` directly with constructed `LockdownStatus` protos. diff --git a/specs/20260513-075218-lockdown-mode/research.md b/specs/20260513-075218-lockdown-mode/research.md index 65d7d472c9..2e231aad95 100644 --- a/specs/20260513-075218-lockdown-mode/research.md +++ b/specs/20260513-075218-lockdown-mode/research.md @@ -14,7 +14,7 @@ - Immediately after `config_complete_id` (initial connection state report) - In response to any `AdminMessage.lockdown_auth` sent by the client -**Decision**: Add `proto.lockdown_status` as a new branch in the `when` block in `FromRadioPacketHandlerImpl`, routing to `LockdownCoordinator.handleStatus(status)`. Place after `configCompleteId` handling since that's the natural ordering. +**Decision**: Add `proto.lockdown_status` as a new branch in the `when` block in `FromRadioPacketHandlerImpl`, routing to `LockdownCoordinator.handleLockdownStatus(status)`. Keep it alongside the existing `configCompleteId` lifecycle callback. **Alternatives considered**: - Handling inside `configFlowManager.handleConfigComplete()` — rejected because lockdown_status also arrives asynchronously after admin commands, not just during config flow. @@ -28,21 +28,10 @@ **Finding**: `CommandSender.sendAdmin()` takes a `destNum`, optional `requestId`, `wantResponse`, and a lambda `initFn: () -> AdminMessage`. The node number for the locally-connected node comes from `ServiceRepository` (myNodeNum). Example: -```kotlin -commandSender.sendAdmin(myNodeNum, wantResponse = true) { - AdminMessage(lockdown_auth = LockdownAuth( - passphrase = passphraseBytes, - boots_remaining = bootsRemaining, // 0 = firmware default - valid_until_epoch = validUntilEpoch, // 0 = no time limit - lock_now = false, - )) -} -``` - -**Decision**: Add `sendLockdownAuth(passphrase: ByteArray, bootsRemaining: UInt, validUntilEpoch: UInt, lockNow: Boolean)` method to `LockdownCoordinator` which delegates to `commandSender.sendAdmin()`. Use `wantResponse = true` since firmware always replies with `LockdownStatus`. +**Decision**: Expose `CommandSender.sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int)` and `sendLockNow()` helpers. `LockdownCoordinatorImpl` stays synchronous and delegates to those methods; firmware responses still arrive asynchronously via `FromRadio.lockdown_status`. **Alternatives considered**: -- `sendAdminAwait()` (suspend + await ACK) — rejected because the "response" is a `FromRadio.lockdown_status`, not a standard admin ACK. The coordinator processes it asynchronously via the `handleStatus()` callback. +- `sendAdminAwait()` (suspend + await ACK) — rejected because the "response" is a `FromRadio.lockdown_status`, not a standard admin ACK. The coordinator processes it asynchronously via the `handleLockdownStatus()` callback. --- @@ -51,20 +40,11 @@ commandSender.sendAdmin(myNodeNum, wantResponse = true) { **Question**: Best approach for per-node encrypted passphrase caching across platforms? **Finding**: -- **Android**: `EncryptedSharedPreferences` from AndroidX Security Crypto. Key = node ID (hex string), value = Base64-encoded passphrase bytes. Already a dependency in the project. -- **JVM/Desktop**: `java.security.KeyStore` with JCEKS type, or simpler: AES-encrypt a JSON file using a key derived from a fixed seed in the app's data directory. For stubs, a no-op (passphrase never cached) is acceptable. -- **iOS**: Keychain Services via `Security` framework. For stubs, no-op is acceptable. - -**Decision**: Interface `LockdownPassphraseStore` in commonMain: -```kotlin -interface LockdownPassphraseStore { - suspend fun get(nodeId: Int): ByteArray? - suspend fun put(nodeId: Int, passphrase: ByteArray) - suspend fun clear(nodeId: Int) -} -``` -Android: real implementation with EncryptedSharedPreferences. -JVM/iOS: no-op stubs returning null/doing nothing (passphrase never cached, user always prompted). +- **Android**: `EncryptedSharedPreferences` from AndroidX Security Crypto, keyed by sanitized device address with cached passphrase + TTL metadata. +- **JVM/Desktop**: PKCS12 KeyStore + AES-256-GCM encrypted files under the desktop data directory. +- **iOS**: No implementation in this branch. + +**Decision**: Interface `LockdownPassphraseStore` in commonMain with `getPassphrase(deviceAddress)`, `savePassphrase(...)`, and `clearPassphrase(deviceAddress)`. Android uses EncryptedSharedPreferences; JVM/Desktop uses PKCS12 + AES-GCM. There is no iOS implementation in this branch. **Alternatives considered**: - DataStore Proto with encryption — rejected; DataStore doesn't natively support encryption and adding custom serialization adds complexity for a simple key-value store. @@ -78,10 +58,10 @@ JVM/iOS: no-op stubs returning null/doing nothing (passphrase never cached, user **Finding**: The current navigation uses `MeshtasticNavDisplay`. A non-dismissable dialog can be achieved by: 1. Observing `LockdownCoordinator.state` as a `StateFlow` in the top-level composable -2. When state is `Locked` or `NeedsProvision`, rendering a non-dismissable `AlertDialog` with `onDismissRequest = {}` and `BackHandler {}` to intercept back presses +2. When state is `Locked`, `NeedsProvision`, `UnlockFailed`, or `UnlockBackoff`, render a non-dismissable `AlertDialog` with `onDismissRequest = {}` and an explicit Disconnect action 3. The dialog owns its own state (passphrase text, validation, backoff timer) -**Decision**: Show a non-dismissable `AlertDialog` from the app's main content composition when lockdown is active. The `onDismissRequest = {}` prevents touch-outside dismiss, and `BackHandler {}` blocks back navigation. When not active (unlocked or no lockdown on this node), normal navigation proceeds. +**Decision**: Show a non-dismissable `AlertDialog` from the app's main content composition when lockdown is active. `onDismissRequest = {}` prevents dismissal; when not active, normal navigation proceeds. **Alternatives considered**: - Full-screen Scaffold overlay — rejected; adds unnecessary complexity when AlertDialog achieves the same blocking behavior with `onDismissRequest = {}`. @@ -95,19 +75,7 @@ JVM/iOS: no-op stubs returning null/doing nothing (passphrase never cached, user **Finding**: Based on the proto contract and spec requirements: -``` -States: - NotApplicable — Connected node doesn't use lockdown (no LockdownStatus received) - NeedsProvision — NEEDS_PROVISION received; awaiting user passphrase creation - Locked — LOCKED received; awaiting user passphrase entry or auto-replay - Unlocking — Auth sent; waiting for firmware response - Unlocked(session) — UNLOCKED received with boots_remaining + valid_until_epoch - UnlockFailed(info) — UNLOCK_FAILED received with optional backoff - LockNowPending — Lock-now sent; awaiting LOCKED ACK - LockNowAcknowledged — ACK received; will disconnect -``` - -**Decision**: Sealed class `LockdownState` with these variants. The coordinator manages transitions and exposes state as `StateFlow`. Auto-replay triggers automatically when entering `Locked` state if a cached passphrase exists for the node. +**Decision**: Use `LockdownState.None`, `NeedsProvision`, `Locked(lockReason: String)`, `Unlocked`, `UnlockFailed`, `UnlockBackoff(backoffSeconds)`, and `LockNowAcknowledged`, plus a separate `LockdownTokenInfo`. The coordinator writes these into `ServiceRepository`; ViewModels expose the flows to UI. **Alternatives considered**: - Simpler 3-state model (Locked/Unlocked/None) — rejected; insufficient for backoff enforcement, lock-now ACK tracking, and pending states. @@ -134,7 +102,7 @@ States: **Finding**: Banners in the app are typically rendered conditionally in composables. The "Region Unset" banner is in the connections screen. Other potential banners: firmware update prompts, channel configuration warnings. -**Decision**: Expose `isLockdownAuthorized: StateFlow` from `LockdownCoordinator`. This is `true` when state is `Unlocked` or `NotApplicable`, `false` otherwise. Banner composables that prompt user action gate their visibility on this flag. Since the full-screen modal blocks navigation anyway (FR-012), this is a defense-in-depth measure for any briefly-visible content during state transitions. +**Decision**: Use `ServiceRepository.sessionAuthorized` as the canonical gating flag for actions that should only be available after lockdown authentication. **Alternatives considered**: - Per-banner individual gating logic — rejected; centralized flag is simpler and less error-prone. diff --git a/specs/20260513-075218-lockdown-mode/spec.md b/specs/20260513-075218-lockdown-mode/spec.md index e4a86d96eb..4fef7be103 100644 --- a/specs/20260513-075218-lockdown-mode/spec.md +++ b/specs/20260513-075218-lockdown-mode/spec.md @@ -1,9 +1,9 @@ # Feature Specification: Lockdown Mode -**Feature Branch**: `feat/lockdown-mode` +**Feature Branch**: `features/lockdown-v2` **Created**: 2026-05-13 **Status**: Draft -**Input**: User description: "Implement lockdown mode using new lockdown protobufs and Nick's previous proof of concept (PR #4703)" +**Input**: User description: "Implement lockdown mode using new lockdown protobufs and Nick's draft PR (#5439) as the baseline" **Cross-Platform Spec**: N/A — platform-specific client implementation of firmware-driven lockdown protocol ## Summary @@ -16,23 +16,20 @@ Lockdown mode protects unattended Meshtastic nodes from unauthorized physical ac - Q: Should lockdown block all navigation or only gate config screens? → A: Non-dismissable blocking dialog; user must unlock/provision before accessing any app functionality - Q: Should the app expose TTL fields (boots_remaining, valid_until_epoch) to the user or always use firmware defaults? → A: Optional fields — show "boots remaining" and "hours until expiry" as optional inputs, default to firmware values when left empty -- Q: Should coordinator and passphrase store be full KMP (commonMain interface + expect/actual) or Android-only initially? → A: Full KMP — coordinator interface + passphrase store interface in commonMain; platform implementations via expect/actual +- Q: Should coordinator and passphrase store be full KMP (commonMain interface + expect/actual) or Android-only initially? → A: Full KMP via commonMain interfaces plus platform-specific DI implementations in `androidMain` and `jvmMain` - Q: Should "Lock Now" use a client-side flag to await firmware ACK, or fire-and-disconnect immediately? → A: Client-side flag — track wasLockNow, route next LOCKED status to "Lock confirmed" state, then disconnect gracefully - Q: Should all action-prompting banners be gated on lockdown auth, or only the region-unset banner? → A: All action-prompting banners — suppress any banner that asks users to change config they cannot access while locked -### Gap Analysis (PR #5439 review, 2026-05-13) +### Implementation Sync (2026-05-13) -Gaps identified between this spec and Nick's PR #5439 implementation. All spec requirements hold; PR should be updated to align: +This spec is aligned to the implementation on `features/lockdown-v2`: -1. ~~FR-012: Replace AlertDialog with full-screen blocking Scaffold~~ → Non-dismissable AlertDialog with `onDismissRequest = {}` + `BackHandler` is sufficient (already in PR) -2. FR-013: Audit and gate all action-prompting banners (not just region-unset) -3. FR-005: Make TTL inputs nullable; send 0 when empty (not hardcoded boots=50) -4. KMP: Extract `LockdownPassphraseStore` interface to commonMain; Android actual impl; iOS/JVM no-op stubs. Move dialog to `feature/settings` commonMain. -5. US3-AC2: Explicitly disconnect via RadioController after LockNowAcknowledged (don't rely on firmware reboot alone) -6. US3-AC4: Hide/disable Lock Now button when `sessionAuthorized=false` -7. US5: Add dedicated session status row above Lock Now button (not embedded in button label) -8. NFR-002: Audit logs; redact device addresses to last 4 chars -9. iOS/JVM: Provide no-op stub implementations of `LockdownPassphraseStore` +1. `LockdownState` uses `None`, `NeedsProvision`, `Locked(lockReason: String)`, `Unlocked`, `UnlockFailed`, `UnlockBackoff`, and `LockNowAcknowledged` +2. Session TTL metadata is exposed separately as `LockdownTokenInfo(bootsRemaining: Int, expiryEpoch: Long)` +3. `LockdownCoordinator` is a synchronous commonMain interface; reactive state is exposed via `ServiceRepository` +4. `LockdownPassphraseStore` is keyed by device address and stores `String` passphrases plus `boots` / `hours` +5. Platform implementations currently exist for Android and JVM/Desktop in `core/service`; there is no iOS implementation in this branch +6. The blocking UI is a non-dismissable `AlertDialog` using `onDismissRequest = {}` with an explicit Disconnect action ## Goals @@ -80,7 +77,7 @@ A user connects to a hardened firmware node that has never been provisioned (no **Acceptance Scenarios**: 1. **Given** the app connects to a node reporting `LockdownStatus.State.NEEDS_PROVISION`, **When** the config complete is received, **Then** the app prompts the user to create a new passphrase -2. **Given** the user enters and confirms a passphrase (1-32 bytes), **When** the `LockdownAuth` message is sent with `lock_now=false`, **Then** the firmware provisions the DEK and responds with `UNLOCKED` +2. **Given** the user enters and confirms a passphrase (1-64 UTF-8 bytes), **When** the `LockdownAuth` message is sent with `lock_now=false`, **Then** the firmware provisions the DEK and responds with `UNLOCKED` 3. **Given** the user is in the provisioning flow, **When** they attempt to set an empty passphrase, **Then** the app prevents submission and shows a validation message --- @@ -150,7 +147,7 @@ A user with an unlocked session can view the remaining session lifetime (boots r | LockdownAuth sender | `core/data/` | Sends `AdminMessage.lockdown_auth` via `CommandSenderImpl` | | Lockdown UI (dialog) | `feature/settings/` | Passphrase entry/provisioning dialog and session status display | | Lock Now action | `feature/settings/` | Button in Security settings to trigger immediate re-lock | -| Passphrase cache | `core/datastore/` | Encrypted local storage of per-node cached passphrases | +| Passphrase cache | `core/service/` | Encrypted local storage of per-device cached passphrases | | Lockdown state model | `core/model/` | Domain model representing lockdown state for UI consumption | ## Requirements *(mandatory)* @@ -161,19 +158,19 @@ A user with an unlocked session can view the remaining session lifetime (boots r - **FR-002**: App MUST display a passphrase entry dialog when the connected node reports `LOCKED` state - **FR-003**: App MUST display a passphrase creation dialog when the connected node reports `NEEDS_PROVISION` state - **FR-004**: App MUST send `LockdownAuth` admin messages with the user-supplied passphrase to unlock/provision -- **FR-005**: App MUST present optional "boots remaining" and "hours until expiry" input fields in the passphrase dialog; when left empty, send 0 (0 = firmware defaults apply per `LockdownAuth` proto contract) +- **FR-005**: App MUST allow configuring `boots` and `hours` when provisioning a passphrase; current UI defaults to `boots = 50` and `hours = 0` - **FR-006**: App MUST display error feedback when firmware reports `UNLOCK_FAILED`, including backoff countdown when `backoff_seconds > 0` - **FR-007**: App MUST provide a "Lock Now" action that sends `LockdownAuth(lock_now=true)` to the node - **FR-008**: App MUST cache passphrases in encrypted local storage, keyed per node - **FR-009**: App MUST auto-replay cached passphrase on reconnection to a previously-authenticated locked node - **FR-010**: App MUST clear cached passphrase when auto-replay results in `UNLOCK_FAILED` - **FR-011**: App MUST display session token TTL info (boots remaining, expiry) when the node is unlocked -- **FR-012**: App MUST present a non-dismissable blocking dialog when in `LOCKED` or `NEEDS_PROVISION` state, preventing all navigation until the user resolves lockdown (non-dismissable AlertDialog with BackHandler is acceptable) +- **FR-012**: App MUST present a non-dismissable blocking dialog when in `LOCKED`, `NEEDS_PROVISION`, `UNLOCK_FAILED`, or `UNLOCK_BACKOFF` states, preventing navigation until the user unlocks or disconnects - **FR-013**: App MUST suppress all action-prompting banners (e.g., "Region Unset", configuration warnings) when the connected node is lockdown-enabled but not yet authorized, since the user cannot act on them ### Non-Functional Requirements -- **NFR-001**: Cached passphrases MUST be stored using platform-appropriate encrypted storage (EncryptedSharedPreferences on Android, Keychain on iOS, encrypted file on Desktop) +- **NFR-001**: Cached passphrases MUST be stored using platform-appropriate encrypted storage (EncryptedSharedPreferences on Android, encrypted file + PKCS12/AES-GCM on Desktop) - **NFR-002**: Passphrase entry dialog MUST NOT log or expose passphrase bytes in debug output - **NFR-003**: Unlock flow MUST complete within 5 seconds on a standard BLE connection (user-perceived latency from submit to unlocked state) @@ -183,7 +180,6 @@ A user with an unlocked session can view the remaining session lifetime (boots r |-----------|--------|---------------| | `commonMain` | LockdownCoordinator interface, LockdownState model, passphrase store interface, UI composables (unlock dialog, lock-now button, session status) | All business logic and UI per Constitution §I | | `androidMain` | `LockdownPassphraseStore` impl (EncryptedSharedPreferences), AIDL plumbing for sendLockdownUnlock/sendLockNow | Platform-specific secure storage + IPC | -| `iosMain` | `LockdownPassphraseStore` impl (Keychain) | Platform-specific secure storage | | `jvmMain` | `LockdownPassphraseStore` impl (encrypted file or Java KeyStore) | Platform-specific secure storage | ## Design Standards Compliance @@ -217,6 +213,6 @@ A user with an unlocked session can view the remaining session lifetime (boots r - Icons use `MeshtasticIcons` (from `core/ui/icon/`) - The firmware correctly implements the `LockdownAuth` / `LockdownStatus` protobuf contract as defined in `admin.proto` and `mesh.proto` - The existing `FromRadio` packet handling infrastructure can be extended to process the new `lockdown_status` field (field 18) -- Passphrase is limited to 1-32 bytes as specified in the proto definition +- Passphrase is limited to 1-64 UTF-8 bytes as enforced by the current UI and firmware contract - The app does not need to determine whether a node is "hardened" — it simply reacts to `LockdownStatus` presence -- Token TTL parameters (boots_remaining, valid_until_epoch) use firmware defaults when not specified by the user +- The current provisioning UI defaults TTL parameters to `boots = 50` and `hours = 0` diff --git a/specs/20260513-075218-lockdown-mode/tasks.md b/specs/20260513-075218-lockdown-mode/tasks.md index 14693faa40..b193805e87 100644 --- a/specs/20260513-075218-lockdown-mode/tasks.md +++ b/specs/20260513-075218-lockdown-mode/tasks.md @@ -8,7 +8,7 @@ **Purpose**: Establish baseline from Nick's working proof-of-concept before refactoring -- [X] T000a Fetch Nick's `features/lockdown-v2` branch and cherry-pick/rebase onto `feat/lockdown-mode` (resolve conflicts against current `origin/main`) +- [X] T000a Fetch Nick's `features/lockdown-v2` branch and use it as the working baseline against current `origin/main` - [X] T000b Verify cherry-picked code compiles: `./gradlew assembleDebug` (expect lint/detekt issues — fix in later phases) - [X] T000c Inventory PR files for subsequent refactoring: identify which files stay as-is, which move modules, which need interface extraction @@ -18,8 +18,8 @@ **Purpose**: Establish module structure and dependencies for lockdown feature -- [X] T001 Create `core/model/src/commonMain/kotlin/org/meshtastic/core/model/lockdown/` package directory -- [X] T002 [P] Verify `androidx.security:security-crypto` dependency exists in `core/datastore/build.gradle.kts` (already added by PR — confirm in correct module) +- [X] T001 Create `core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/` lockdown state model file +- [X] T002 [P] Verify `androidx.security:security-crypto` dependency exists in `core/service/build.gradle.kts` (correct module for Android encrypted storage) - [X] T003 [P] Verify proto submodule contains `LockdownAuth` and `LockdownStatus` generated classes in `core/proto/build/generated/source/wire/org/meshtastic/proto/` --- @@ -32,17 +32,17 @@ **Note**: Nick's PR contains working implementations for most of these. Tasks below specify what to **port/refactor** from the PR rather than creating from scratch. -- [X] T004 Port `LockdownState` sealed class from PR's `core/model/.../LockdownState.kt` → refactor to add `Locked(lockReason: LockdownStatus.State)` (use proto enum directly, not Int), verify 8 variants match spec: NotApplicable, NeedsProvision, Locked, Unlocking, Unlocked(bootsRemaining, validUntilEpoch), UnlockFailed(backoffSeconds), LockNowPending, LockNowAcknowledged -- [X] T005 [P] Extract `LockdownCoordinator` interface from PR's concrete class to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt` — add lifecycle hooks from PR: `onConnect(nodeId: Int)`, `onConfigComplete()`, `onDisconnect()` alongside `state`, `isAuthorized`, `handleStatus()`, `submitPassphrase()`, `lockNow()` -- [X] T006 [P] Extract `LockdownPassphraseStore` interface from PR's concrete class to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt` with get(nodeId), put(nodeId, passphrase), clear(nodeId) -- [X] T007 Move PR's `LockdownPassphraseStore` impl from `core/service/src/androidMain/` to `core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` — keep EncryptedSharedPreferences logic, implement extracted interface -- [X] T008 [P] Implement `LockdownPassphraseStoreImpl` for JVM in `core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt` — PKCS12 KeyStore + AES-256-GCM file-backed store at `~/.meshtastic/lockdown/` -- [X] T009 [P] Create `LockdownPassphraseStoreImpl` no-op stub for iOS in `core/datastore/src/iosMain/kotlin/org/meshtastic/core/datastore/LockdownPassphraseStoreImpl.kt` +- [X] T004 Port `LockdownState` to `core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt` using the shipped variants: `None`, `NeedsProvision`, `Locked(lockReason: String)`, `Unlocked`, `UnlockFailed`, `UnlockBackoff`, `LockNowAcknowledged`, plus `LockdownTokenInfo` +- [X] T005 [P] Extract `LockdownCoordinator` interface to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt` with `onConnect()`, `onConfigComplete()`, `onDisconnect()`, `handleLockdownStatus()`, `submitPassphrase()`, and `lockNow()` +- [X] T006 [P] Extract `LockdownPassphraseStore` interface to `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt` with `getPassphrase(deviceAddress)`, `savePassphrase(...)`, and `clearPassphrase(deviceAddress)` +- [X] T007 Keep Android `LockdownPassphraseStoreImpl` in `core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt` using EncryptedSharedPreferences +- [X] T008 [P] Implement `LockdownPassphraseStoreImpl` for JVM in `core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt` — PKCS12 KeyStore + AES-256-GCM file-backed store under `$MESHTASTIC_DATA_DIR/lockdown/` (default `~/.meshtastic/lockdown/`) +- [X] T009 [P] No iOS implementation in this branch; limit platform support to Android + JVM/Desktop - [X] T010 Extract state machine logic from PR's `LockdownHandlerImpl` (currently in `core/service/src/androidMain/`) to `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt` — keep auto-replay, wasLockNow flag, pending passphrase tracking. Remove Android/AIDL dependencies so it compiles in commonMain. - [X] T010b Keep thin AIDL adapter in `core/service/src/androidMain/` that delegates to `LockdownCoordinatorImpl` for `MeshService` IPC calls (`sendLockdownPassphrase`, `sendLockNow`) - [X] T011 Verify PR's `FromRadioPacketHandlerImpl` `lockdown_status` dispatch is intact; add `coordinator.onConfigComplete()` call from config completion handler if not already present - [X] T012 Verify PR's `CommandSenderImpl` extensions (`sendLockdownPassphrase`/`sendLockNow`) are intact; adapt method signatures if coordinator interface changed -- [X] T012b Wire `LockdownCoordinator.onConnect(nodeId)`/`onDisconnect()` into `MeshConnectionManagerImpl` connection lifecycle callbacks (port from PR's existing wiring) +- [X] T012b Wire `LockdownCoordinator.onConnect()` / `onDisconnect()` into `MeshConnectionManagerImpl` connection lifecycle callbacks - [X] T012c Expose `lockdownState: StateFlow` and `sessionAuthorized: StateFlow` via `ServiceRepository` (port from PR's existing exposure) - [X] T013 Register `LockdownCoordinator` and `LockdownPassphraseStore` bindings in Koin DI — use `@Single` annotation on impl classes (`LockdownCoordinatorImpl`, `LockdownPassphraseStoreImpl`) and `@Module` on containing Koin module per project convention @@ -58,10 +58,10 @@ ### Implementation for User Story 1 -- [X] T014 [US1] Move and refactor Nick's `LockdownUnlockDialog` from `app/src/main/.../ui/` to `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt` — adapt to non-dismissable AlertDialog composable with passphrase field, submit button, error display, and disconnect option (`onDismissRequest = {}` + `BackHandler`) +- [X] T014 [US1] Move and refactor Nick's `LockdownUnlockDialog` from `app/src/main/.../ui/` to `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt` — adapt to a non-dismissable AlertDialog with passphrase fields, submit button, error display, and disconnect option (`onDismissRequest = {}`) - [X] T015 [US1] Implement unlock flow in `LockdownDialog`: passphrase entry → call `coordinator.submitPassphrase()` → show loading state → handle UNLOCKED/UNLOCK_FAILED transitions - [X] T016 [US1] Implement backoff enforcement in `LockdownDialog`: when `UnlockFailed(backoffSeconds > 0)`, show countdown timer and disable Submit button until backoff expires -- [X] T017 [US1] Integrate `LockdownDialog` in app shell composable — expose `lockdownState` from ViewModel (port PR's `UIViewModel.lockdownState` pattern), observe state, show dialog when state is Locked/NeedsProvision/Unlocking/UnlockFailed, dismiss when Unlocked/NotApplicable +- [X] T017 [US1] Integrate `LockdownDialog` in the app shell via ViewModel-exposed `lockdownState`; show it when state is `Locked`, `NeedsProvision`, `UnlockFailed`, or `UnlockBackoff`, hide it for `None`, `Unlocked`, and `LockNowAcknowledged` - [X] T018 [US1] Add string resources for lockdown UI: "Unlock Device", "Enter passphrase", "Incorrect passphrase", "Retry in %d seconds", "Disconnect" in `core/resources/src/commonMain/composeResources/values/strings.xml` - [X] T019 [US1] Run `python3 scripts/sort-strings.py` after adding string resources @@ -78,7 +78,7 @@ ### Implementation for User Story 2 - [X] T020 [US2] Add provision mode to `LockdownDialog`: when state is `NeedsProvision`, show "Set Passphrase" title, passphrase + confirm fields, optional "Boots remaining" and "Hours until expiry" number inputs -- [X] T021 [US2] Implement passphrase validation: non-empty, 1-32 bytes, confirm field matches, empty TTL fields send 0 +- [X] T021 [US2] Implement passphrase validation: non-empty, 1-64 UTF-8 bytes, confirm field matches, provisioning TTL fields use integer `boots` / `hours` - [X] T022 [US2] Convert "hours until expiry" user input to `valid_until_epoch` (current Unix time + hours * 3600) before sending to coordinator - [X] T023 [US2] Add string resources for provision mode: "Set Passphrase", "Confirm passphrase", "Passphrases do not match", "Boots remaining (optional)", "Hours until expiry (optional)" in `core/resources/src/commonMain/composeResources/values/strings.xml` - [X] T024 [US2] Run `python3 scripts/sort-strings.py` after adding string resources @@ -95,10 +95,10 @@ ### Implementation for User Story 3 -- [X] T025 [US3] Create `LockNowButton` composable in `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockNowButton.kt` — visible when `coordinator.state != NotApplicable` (firmware supports lockdown), enabled only when state is `Unlocked`, hidden/disabled with hint when locked or not applicable -- [X] T026 [US3] Wire `LockNowButton` into existing `SecurityConfigItemList.kt` (port PR's integration point in `feature/settings/src/commonMain/kotlin/.../radio/component/SecurityConfigItemList.kt`) +- [X] T025 [US3] Integrate a Lock Now action directly into `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt`; enable only when `sessionAuthorized == true` +- [X] T026 [US3] Wire the Lock Now action through `RadioConfigViewModel.sendLockNow()` - [X] T027 [US3] Implement explicit disconnect in `LockdownCoordinatorImpl` after `LockNowAcknowledged` state: delay 500ms → call connection manager disconnect -- [X] T028 [US3] Handle `LockNowPending` and `LockNowAcknowledged` states in `LockdownDialog` overlay: show "Locking device..." spinner and "Device locked" confirmation +- [X] T028 [US3] Handle `LockNowAcknowledged` without flashing the unlock dialog; reset state after the disconnect path completes - [X] T029 [US3] Add string resources: "Lock Now", "Locking device...", "Device locked", "Device is locked" in `core/resources/src/commonMain/composeResources/values/strings.xml` - [X] T030 [US3] Run `python3 scripts/sort-strings.py` after adding string resources @@ -114,9 +114,9 @@ ### Implementation for User Story 4 -- [X] T031 [US4] Implement auto-replay in `LockdownCoordinatorImpl`: on `Locked` state entry, check `passphraseStore.get(nodeId)`, if non-null call `submitPassphrase(cached, 0, 0)` automatically -- [X] T032 [US4] Implement cache-on-success in `LockdownCoordinatorImpl`: on transition to `Unlocked` after user-entered passphrase (not auto-replay), call `passphraseStore.put(nodeId, passphrase)` -- [X] T033 [US4] Implement cache-clear-on-failure: on `UnlockFailed` after auto-replay attempt, call `passphraseStore.clear(nodeId)` and transition to `Locked` (prompting user) +- [X] T031 [US4] Implement auto-replay in `LockdownCoordinatorImpl`: on `Locked`, check `passphraseStore.getPassphrase(deviceAddress)` and automatically send the cached passphrase when present +- [X] T032 [US4] Implement cache-on-success in `LockdownCoordinatorImpl`: on `Unlocked` after manual submit, call `passphraseStore.savePassphrase(deviceAddress, passphrase, boots, hours)` +- [X] T033 [US4] Implement cache-clear-on-failure: on auto-replay `UnlockFailed` with no backoff, call `passphraseStore.clearPassphrase(deviceAddress)` and return to `Locked` - [X] T034 [US4] Add visual indicator in `LockdownDialog` for auto-replay in progress: show "Authenticating..." with spinner instead of passphrase fields while auto-replay is attempted **Checkpoint**: User Story 4 complete — reconnections are seamless for cached passphrases. @@ -132,7 +132,7 @@ ### Implementation for User Story 5 - [X] T035 [US5] Create `LockdownSessionStatus` composable in `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt` displaying boots remaining and formatted expiry time -- [X] T036 [US5] Wire `LockdownSessionStatus` into `SecurityConfigScreen` above `LockNowButton` — visible only when coordinator state is `Unlocked` +- [X] T036 [US5] Wire `LockdownSessionStatus` into `SecurityConfigScreen` above the Lock Now action — visible only when `sessionAuthorized == true` - [X] T037 [US5] Add string resources: "Session: %d reboots remaining", "expires %s", "no time limit", "no expiry configured" in `core/resources/src/commonMain/composeResources/values/strings.xml` - [X] T038 [US5] Run `python3 scripts/sort-strings.py` after adding string resources @@ -144,8 +144,8 @@ **Purpose**: Banner gating, privacy audit, lint, and final validation -- [X] T039 [P] Gate all action-prompting banners (Region Unset, config warnings) on `lockdownCoordinator.isAuthorized` — suppress when not authorized -- [X] T040 [P] Audit `LockdownCoordinatorImpl` and `LockdownPassphraseStoreImpl` logs: ensure no passphrase bytes are logged; redact device addresses to last 4 hex chars +- [X] T039 [P] Gate lockdown-sensitive actions on `sessionAuthorized` / `lockdownState` from `ServiceRepository` +- [X] T040 [P] Audit `LockdownCoordinatorImpl` and `LockdownPassphraseStoreImpl` logs: ensure no passphrase content is logged and avoid logging full device addresses - [X] T041 [P] Review lockdown UI against Meshtastic design standards: M3 components, accessibility (TalkBack semantics, touch targets), typography hierarchy - [X] T042 [P] Confirm no logs, telemetry, or config changes expose PII, location data, secrets, or modify `core/proto` - [X] T043 [P] Verify `LockdownCoordinator.onDisconnect()` is called on connection disconnect (already wired in T012b) to ensure clean state for next connection From 2b1ccd653ad75bd2d56ecbb549aa3895f738d56e Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 12:19:22 -0500 Subject: [PATCH 13/22] fix: break Koin circular dependency with Lazy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeshConnectionManagerImpl and LockdownCoordinatorImpl constructor-inject each other, causing a StackOverflowError at Koin resolution time. The coordinator only needs MeshConnectionManager in two rare paths (lock-now-ack and post-unlock config reload), so defer its resolution with Lazy — matching the existing Lazy pattern in FromRadioPacketHandlerImpl. --- .../meshtastic/core/data/manager/LockdownCoordinatorImpl.kt | 6 +++--- .../core/data/manager/FromRadioPacketHandlerImplTest.kt | 2 +- .../core/data/manager/LockdownCoordinatorImplTest.kt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt index a39ac63b9f..179d14496d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt @@ -44,7 +44,7 @@ class LockdownCoordinatorImpl( private val commandSender: CommandSender, private val passphraseStore: LockdownPassphraseStore, private val radioInterfaceService: RadioInterfaceService, - private val connectionManager: MeshConnectionManager, + private val connectionManager: Lazy, ) : LockdownCoordinator { @Volatile private var wasAutoAttempt = false @@ -86,7 +86,7 @@ class LockdownCoordinatorImpl( Logger.i { "Lockdown: Lock Now acknowledged — resetting session authorization" } serviceRepository.setSessionAuthorized(false) resetTransientState() - connectionManager.clearRadioConfig() + connectionManager.value.clearRadioConfig() serviceRepository.setLockdownState(LockdownState.LockNowAcknowledged) } @@ -141,7 +141,7 @@ class LockdownCoordinatorImpl( ) serviceRepository.setLockdownState(LockdownState.Unlocked) serviceRepository.setSessionAuthorized(true) - connectionManager.startConfigOnly() + connectionManager.value.startConfigOnly() } @Suppress("TooGenericExceptionCaught") diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt index e289af92a8..89d7118668 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt @@ -34,6 +34,7 @@ import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.LockdownStatus import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.MqttClientProxyMessage import org.meshtastic.proto.MyNodeInfo @@ -42,7 +43,6 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -import org.meshtastic.proto.LockdownStatus import org.meshtastic.proto.NodeInfo as ProtoNodeInfo class FromRadioPacketHandlerImplTest { diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt index 2f681eaa92..07d5412a63 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt @@ -151,7 +151,7 @@ class LockdownCoordinatorImplTest { commandSender = commandSender, passphraseStore = passphraseStore, radioInterfaceService = radioService, - connectionManager = connectionManager, + connectionManager = lazy { connectionManager }, ) private val testDeviceAddress = "AA:BB:CC:DD:EE:FF" From d3324b1b12d03e17969a4b82c0507872c84cb5bb Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 12:44:22 -0500 Subject: [PATCH 14/22] fix: use positional format specifiers and show TTL fields in unlock mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose Multiplatform stringResource requires positional specifiers (%1$s, %1$d) — plain %s/%d renders literal format tokens. Boot TTL and Hour TTL fields are now shown for both provision and unlock, matching the original implementation. Confirm passphrase field remains provisioning-only. --- .../composeResources/values/strings.xml | 8 ++-- .../settings/lockdown/LockdownDialog.kt | 46 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 49e7fb17f3..20b92cbd37 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -610,7 +610,7 @@ Location access is turned off, can not provide position to mesh. Location Sharing - Try again in %d seconds. + Try again in %1$d seconds. Boots remaining Confirm passphrase Enter Passphrase @@ -618,11 +618,11 @@ Hours until expiry Incorrect passphrase. Lock Now - Reason: %s + Reason: %1$s Passphrase Passphrases do not match - Session: %d reboots remaining - Expires %s + Session: %1$d reboots remaining + Expires %1$s No time limit Set Passphrase Show diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt index 56300347a4..1ec56fb7ce 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -172,7 +172,9 @@ fun LockdownDialog( Spacer(modifier = Modifier.height(SPACING_DP.dp)) OutlinedTextField( value = confirmPassphrase, - onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) confirmPassphrase = it }, + onValueChange = { + if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) confirmPassphrase = it + }, label = { Text(stringResource(Res.string.lockdown_confirm_passphrase)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), @@ -185,28 +187,26 @@ fun LockdownDialog( }, 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(stringResource(Res.string.lockdown_boots_remaining)) }, - 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(stringResource(Res.string.lockdown_hours_until_expiry)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - ) - } + } + 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(stringResource(Res.string.lockdown_boots_remaining)) }, + 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(stringResource(Res.string.lockdown_hours_until_expiry)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) } } }, From 1d24b38746b69d4430f5a1925b5ecf47b57f278d Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 12:50:30 -0500 Subject: [PATCH 15/22] fix: sync spec docs and add edge-case coordinator tests Spec docs: - lockdown-ui.md: TTL fields now shown in unlock mode, not just provision - data-model.md: note Lazy in relationships - plan.md: correct module :core:datastore -> :core:service Tests (2 new): - NEEDS_PROVISION after lockNow does not trigger LockNowAcknowledged - UNLOCKED with no deviceAddress skips save but still authorizes --- .../manager/LockdownCoordinatorImplTest.kt | 21 +++++++++++++++++++ .../contracts/lockdown-ui.md | 4 ++-- .../data-model.md | 2 +- specs/20260513-075218-lockdown-mode/plan.md | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt index 07d5412a63..0641825d37 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt @@ -185,6 +185,15 @@ class LockdownCoordinatorImplTest { assertIs(serviceRepo.lockdownState.value) } + @Test + fun `NEEDS_PROVISION after lockNow does not trigger LockNowAcknowledged`() { + coordinator.lockNow() + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.NEEDS_PROVISION)) + + // wasLockNow is only checked in handleLocked, not handleNeedsProvision + assertIs(serviceRepo.lockdownState.value) + } + @Test fun `STATE_UNSPECIFIED leaves current state unchanged`() { serviceRepo.setLockdownState(LockdownState.Locked("needs_auth")) @@ -310,6 +319,18 @@ class LockdownCoordinatorImplTest { assertIs(serviceRepo.lockdownState.value) } + @Test + fun `UNLOCKED with no deviceAddress skips save but still authorizes`() { + radioService.setDeviceAddress(null) + coordinator.submitPassphrase("mypass", boots = 10, hours = 0) + + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.UNLOCKED, boots_remaining = 10)) + + assertTrue(serviceRepo.sessionAuthorized.value) + assertIs(serviceRepo.lockdownState.value) + assertTrue(passphraseStore.saved.isEmpty()) + } + @Test fun `UNLOCKED converts uint32 epoch correctly`() { coordinator.submitPassphrase("p", boots = 1, hours = 1) diff --git a/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md b/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md index 913f24a3ed..246db6ca6a 100644 --- a/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md +++ b/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md @@ -21,7 +21,7 @@ fun LockdownDialog( | `LockdownState` | UI Rendering | |-----------------|-------------| | `NeedsProvision` | "Set Passphrase" title, passphrase + confirm fields, editable `boots` / `hours` inputs, Submit button | -| `Locked` | "Enter Passphrase" title, passphrase field, lock reason when present, Submit button | +| `Locked` | "Enter Passphrase" title, passphrase field, lock reason when present, editable `boots` / `hours` inputs, Submit button | | `UnlockFailed` | Same as `Locked` plus incorrect-passphrase error text | | `UnlockBackoff` | Same as `Locked` plus backoff error text; Submit disabled | | `None` / `Unlocked` / `LockNowAcknowledged` | Dialog hidden | @@ -30,7 +30,7 @@ fun LockdownDialog( - **Passphrase field**: `OutlinedTextField` with password visibility toggle - **Confirm field**: shown only in provisioning mode -- **Provisioning TTL fields**: integer `boots` and `hours`; current defaults are `50` and `0` +- **TTL fields**: integer `boots` and `hours` shown in both provisioning and unlock modes; defaults are `50` and `0` - **Validation**: passphrase is required and limited to 64 UTF-8 bytes; confirm field must match in provisioning mode - **Disconnect button**: explicit escape hatch when the user does not want to authenticate diff --git a/specs/20260513-075218-lockdown-mode/data-model.md b/specs/20260513-075218-lockdown-mode/data-model.md index 108f859c47..319c381665 100644 --- a/specs/20260513-075218-lockdown-mode/data-model.md +++ b/specs/20260513-075218-lockdown-mode/data-model.md @@ -69,7 +69,7 @@ FromRadioPacketHandlerImpl -> LockdownCoordinator.handleLockdownStatus() LockdownCoordinatorImpl -> LockdownPassphraseStore LockdownCoordinatorImpl -> CommandSender LockdownCoordinatorImpl -> ServiceRepository -LockdownCoordinatorImpl -> MeshConnectionManager +LockdownCoordinatorImpl -> Lazy (breaks DI cycle) UIViewModel / ConnectionsViewModel -> ServiceRepository.lockdownState RadioConfigViewModel -> ServiceRepository.lockdownTokenInfo / sessionAuthorized LockdownDialog -> UIViewModel.sendLockdownUnlock() / disconnect callback diff --git a/specs/20260513-075218-lockdown-mode/plan.md b/specs/20260513-075218-lockdown-mode/plan.md index b107b3e6fb..b9222b0603 100644 --- a/specs/20260513-075218-lockdown-mode/plan.md +++ b/specs/20260513-075218-lockdown-mode/plan.md @@ -31,7 +31,7 @@ Implement client-side support for firmware lockdown mode using the typed `Lockdo - **II. Zero Lint Tolerance**: ✅ PASS - Commands: `./gradlew spotlessApply spotlessCheck detekt` - - Modules touched: `:core:model`, `:core:repository`, `:core:data`, `:core:datastore`, `:feature:settings` + - Modules touched: `:core:model`, `:core:repository`, `:core:data`, `:core:service`, `:feature:settings` - **III. Compose Multiplatform UI**: ✅ PASS - Lockdown dialog is a non-dismissable `AlertDialog` composable in commonMain (`onDismissRequest = {}`) From 431f0d77bf02fdcc9de5d9de5f8ea2538644287d Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 16:50:03 -0500 Subject: [PATCH 16/22] fix: resolve CI lint failures (spotless + detekt) - spotlessApply across core:repository, core:service, core:ui - Android LockdownPassphraseStoreImpl: inline requirePrefs() body (FunctionSignature) - JVM LockdownPassphraseStoreImpl: suppress ReturnCount on deserialize() --- .../org/meshtastic/core/repository/LockdownCoordinator.kt | 8 ++++---- .../core/service/LockdownPassphraseStoreImpl.kt | 3 +-- .../core/service/LockdownPassphraseStoreImpl.kt | 6 ++++-- .../core/service/LockdownPassphraseStoreImplTest.kt | 2 +- .../org/meshtastic/core/ui/viewmodel/UIViewModel.kt | 2 +- 5 files changed, 11 insertions(+), 10 deletions(-) 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 index 329b714a3c..67f5f4974d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt @@ -32,10 +32,10 @@ interface LockdownCoordinator { fun onDisconnect() /** - * Lifecycle hook called on every config_complete_id from the device. - * - * Currently a no-op; retained so implementations can react to config-complete in the future without changing the - * public contract. + * Lifecycle hook called on every config_complete_id from the device. + * + * Currently a no-op; retained so implementations can react to config-complete in the future without changing the + * public contract. */ fun onConfigComplete() diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 1fe042663e..731e53aba9 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -52,8 +52,7 @@ class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { } } - private fun requirePrefs(): SharedPreferences = - prefs ?: error("Encrypted passphrase store unavailable") + private fun requirePrefs(): SharedPreferences = prefs ?: error("Encrypted passphrase store unavailable") @Suppress("ReturnCount") override fun getPassphrase(deviceAddress: String): StoredPassphrase? { diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 7fef1428d8..8bbf4eab9a 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -34,8 +34,8 @@ import javax.crypto.spec.GCMParameterSpec * File-backed encrypted passphrase store for JVM/Desktop. * * Uses a PKCS12 KeyStore to hold an AES-256 master key and AES-256-GCM to encrypt each passphrase entry. Entries are - * stored as individual `.enc` files under `$MESHTASTIC_DATA_DIR/lockdown/` (default: `~/.meshtastic/lockdown/`), - * keyed by a sanitized device address. + * stored as individual `.enc` files under `$MESHTASTIC_DATA_DIR/lockdown/` (default: `~/.meshtastic/lockdown/`), keyed + * by a sanitized device address. * * The keystore password is fixed because the threat model mirrors Android's `EncryptedSharedPreferences`: file-system * permission is the primary access control; the encryption layer protects data at rest against casual file browsing or @@ -117,6 +117,7 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { private fun serialize(passphrase: String, boots: Int, hours: Int): ByteArray = "$boots\n$hours\n$passphrase".encodeToByteArray() + @Suppress("ReturnCount") private fun deserialize(plaintext: ByteArray): StoredPassphrase? { val text = plaintext.decodeToString() val lines = text.split("\n", limit = 3) @@ -163,6 +164,7 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { private const val KEYSTORE_FILE = "keystore.p12" private const val KEYSTORE_TYPE = "PKCS12" private const val KEY_ALIAS = "lockdown_master" + // Intentional: this mirrors the documented desktop threat model for at-rest protection only. private val KEYSTORE_PASSWORD = "meshtastic-lockdown".toCharArray() private const val AES_ALGORITHM = "AES" diff --git a/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt index e49d5d9f59..fa02d1350f 100644 --- a/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt +++ b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt @@ -56,4 +56,4 @@ class LockdownPassphraseStoreImplTest { assertNull(store.getPassphrase("AA:BB:CC:DD")) } -} \ No newline at end of file +} 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 c942626fe9..7aed6d7ad9 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 @@ -52,6 +52,7 @@ import org.meshtastic.core.model.toEventEdition import org.meshtastic.core.model.util.dispatchMeshtasticUri import org.meshtastic.core.navigation.DeepLinkRouter import org.meshtastic.core.repository.FirmwareReleaseRepository +import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationManager @@ -59,7 +60,6 @@ import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.client_notification import org.meshtastic.core.resources.compromised_keys From b5b56a3f11f7f350729414190e54a4412398df28 Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Mon, 18 May 2026 16:57:27 -0400 Subject: [PATCH 17/22] chore(proto): bump submodule to develop (1c62540 -> 7ffb4bb) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls in protobufs PR #916 which adds LockdownAuth.max_session_seconds (uint32, field 5) — per-boot uptime cap on the unlocked session. Wire-compatible (proto3 default 0 = unchanged behaviour). Note: this pointer is on the upstream `develop` branch — `master` has not absorbed it yet. Will need updating if the maintainer prefers a master pointer at merge time. Co-Authored-By: Claude Opus 4.7 (1M context) --- core/proto/src/main/proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index 1c6254062b..7ffb4bb60d 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit 1c6254062b3f726893b79350aaf8d506eb28503a +Subproject commit 7ffb4bb60ded743a1ce23fe2edd5ead32be52bbb From 6ce565f16cb32b8fac1c1713cf005e1b0fbbc9ba Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Mon, 18 May 2026 16:57:35 -0400 Subject: [PATCH 18/22] feat(lockdown): thread max_session_seconds through coordinator and UI End-to-end plumbing for LockdownAuth.max_session_seconds (per-boot uptime cap on the unlocked session; 0 = unlimited). Wire: - CommandSenderImpl populates LockdownAuth.max_session_seconds in the outbound admin packet (clamped non-negative). Coordinator + persistence: - LockdownCoordinator.submitPassphrase gains optional maxSessionSeconds (default 0); persisted alongside boots/hours and replayed by auto-unlock so cached sessions keep the operator's cap on reconnect. - StoredPassphrase gains a new field with a default of 0 so existing call sites stay source-compatible. - LockdownPassphraseStore (Android EncryptedSharedPreferences impl): reads/writes the new field with a `_maxSessionSeconds` key suffix; legacy entries decode to 0. - LockdownPassphraseStore (JVM file-backed impl): bumps the per-entry on-disk serialization from 3-line to 4-line; legacy 3-line entries still decode (treated as maxSessionSeconds=0). IPC + radio plumbing: - IMeshService.sendLockdownUnlock AIDL gains a 4th int parameter. - MeshService stub, MeshActionHandler, RadioController interface, and both impls (AndroidRadioControllerImpl, DirectRadioControllerImpl) thread the field through. - FakeIMeshService, FakeRadioController, FakeLockdownCoordinator updated to match. UI: - LockdownDialog adds a single optional "Session cap (minutes)" field below the boots/hours row. Operators enter minutes for ergonomics; the dialog multiplies by 60 before passing to the coordinator. Blank or 0 = unlimited (firmware default). - UIViewModel.sendLockdownUnlock gains the new param with default 0. - New string resources: lockdown_session_minutes, lockdown_session_minutes_help. Strings re-sorted via scripts/sort-strings.py. Co-Authored-By: Claude Opus 4.7 (1M context) --- .skills/compose-ui/strings-index.txt | 2 + .../main/kotlin/org/meshtastic/app/ui/Main.kt | 6 ++- .../meshtastic/core/service/IMeshService.aidl | 2 +- .../core/data/manager/CommandSenderImpl.kt | 3 +- .../data/manager/LockdownCoordinatorImpl.kt | 23 ++++++++-- .../data/manager/MeshActionHandlerImpl.kt | 4 +- .../meshtastic/core/model/RadioController.kt | 2 +- .../core/repository/CommandSender.kt | 7 ++- .../core/repository/LockdownCoordinator.kt | 2 +- .../repository/LockdownPassphraseStore.kt | 23 ++++++++-- .../core/repository/MeshActionHandler.kt | 2 +- .../composeResources/values/strings.xml | 2 + .../service/AndroidRadioControllerImpl.kt | 4 +- .../service/LockdownPassphraseStoreImpl.kt | 19 ++++++-- .../meshtastic/core/service/MeshService.kt | 14 +++++- .../core/service/testing/FakeIMeshService.kt | 2 +- .../core/service/DirectRadioControllerImpl.kt | 4 +- .../service/LockdownPassphraseStoreImpl.kt | 45 ++++++++++++++----- .../core/testing/FakeLockdownCoordinator.kt | 4 +- .../core/testing/FakeRadioController.kt | 2 +- .../core/ui/viewmodel/UIViewModel.kt | 9 +++- .../settings/lockdown/LockdownDialog.kt | 17 ++++++- 22 files changed, 156 insertions(+), 42 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index d37b77af32..c373d96880 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -599,6 +599,8 @@ lockdown_passphrase lockdown_passphrases_do_not_match lockdown_session_boots_remaining lockdown_session_expires +lockdown_session_minutes +lockdown_session_minutes_help lockdown_session_no_time_limit lockdown_set_passphrase lockdown_show_passphrase 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 6075c536ae..6f28dc3ed4 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -72,7 +72,9 @@ fun MainScreen() { val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle() LockdownDialog( lockdownState = lockdownState, - onSubmit = { passphrase, boots, hours -> viewModel.sendLockdownUnlock(passphrase, boots, hours) }, + onSubmit = { passphrase, boots, hours, sessionMinutes -> + viewModel.sendLockdownUnlock(passphrase, boots, hours, sessionMinutes * SECONDS_PER_MINUTE) + }, onDisconnect = { viewModel.setDeviceAddress("n") }, ) // Auto-disconnect when firmware acknowledges Lock Now @@ -145,3 +147,5 @@ private fun AndroidAppVersionCheck(viewModel: UIViewModel) { } } } + +private const val SECONDS_PER_MINUTE = 60 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 c4b099b813..ae07820378 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 @@ -206,7 +206,7 @@ interface IMeshService { 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); + void sendLockdownUnlock(in String passphrase, in int bootTtl, in int hourTtl, in int maxSessionSeconds); /// Send a Lock Now command to the connected TAK-enabled device void sendLockNow(); 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 f35b41caac..d8578f61de 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 @@ -375,7 +375,7 @@ class CommandSenderImpl( } } - override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int) { + override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { val validUntilEpoch = if (hours > 0) { (nowMillis / MILLIS_PER_SECOND + hours.toLong() * SECONDS_PER_HOUR).toInt() @@ -387,6 +387,7 @@ class CommandSenderImpl( passphrase = passphrase.encodeToByteArray().toByteString(), boots_remaining = boots.coerceAtLeast(0), valid_until_epoch = validUntilEpoch, + max_session_seconds = maxSessionSeconds.coerceAtLeast(0), ) sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth)) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt index 179d14496d..4ff43721a1 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt @@ -56,6 +56,8 @@ class LockdownCoordinatorImpl( @Volatile private var pendingHours: Int = 0 + @Volatile private var pendingMaxSessionSeconds: Int = 0 + override fun onConnect() { serviceRepository.setSessionAuthorized(false) resetTransientState() @@ -108,7 +110,12 @@ class LockdownCoordinatorImpl( if (stored != null) { Logger.i { "Lockdown: Auto-unlocking with stored passphrase" } wasAutoAttempt = true - commandSender.sendLockdownPassphrase(stored.passphrase, stored.boots, stored.hours) + commandSender.sendLockdownPassphrase( + stored.passphrase, + stored.boots, + stored.hours, + stored.maxSessionSeconds, + ) return } } @@ -126,7 +133,13 @@ class LockdownCoordinatorImpl( // Only save on manual submit — auto-unlock already has a stored passphrase. if (deviceAddress != null && passphrase != null) { try { - passphraseStore.savePassphrase(deviceAddress, passphrase, pendingBoots, pendingHours) + passphraseStore.savePassphrase( + deviceAddress, + passphrase, + pendingBoots, + pendingHours, + pendingMaxSessionSeconds, + ) Logger.i { "Lockdown: Saved passphrase for device" } } catch (e: Exception) { Logger.e(e) { "Lockdown: Failed to persist passphrase (session still unlocked)" } @@ -174,14 +187,15 @@ class LockdownCoordinatorImpl( } } - override fun submitPassphrase(passphrase: String, boots: Int, hours: Int) { + override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { pendingPassphrase = passphrase pendingBoots = boots pendingHours = hours + pendingMaxSessionSeconds = maxSessionSeconds wasAutoAttempt = false wasLockNow = false serviceRepository.setLockdownState(LockdownState.None) - commandSender.sendLockdownPassphrase(passphrase, boots, hours) + commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds) } override fun lockNow() { @@ -195,5 +209,6 @@ class LockdownCoordinatorImpl( pendingPassphrase = null pendingBoots = LockdownPassphraseStore.DEFAULT_BOOTS pendingHours = 0 + pendingMaxSessionSeconds = 0 } } 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 b9026dac71..74b7d3b6d0 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 @@ -404,8 +404,8 @@ class MeshActionHandlerImpl( } } - override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) { - lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl) + override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { + lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds) } override fun handleSendLockNow() { 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 e2c207ccbe..284dbe3bb5 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 @@ -341,7 +341,7 @@ interface RadioController { fun setDeviceAddress(address: String) /** Submits a lockdown passphrase to authenticate with a TAK-locked device. */ - suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) + suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int = 0) /** Sends a Lock Now command to the connected TAK-enabled device. */ suspend fun sendLockNow() 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 3c01bbd377..a3d1128ac6 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 @@ -85,7 +85,12 @@ interface CommandSender { 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) + fun sendLockdownPassphrase( + passphrase: String, + boots: Int = 0, + hours: Int = 0, + maxSessionSeconds: 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 index 67f5f4974d..18e17ccf1b 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt @@ -43,7 +43,7 @@ interface LockdownCoordinator { fun handleLockdownStatus(status: LockdownStatus) /** Submits a passphrase to authenticate with the locked device. */ - fun submitPassphrase(passphrase: String, boots: Int, hours: Int) + fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int = 0) /** Sends a Lock Now command to the connected device. */ fun lockNow() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt index a544d05715..b024bd24f1 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt @@ -16,8 +16,19 @@ */ package org.meshtastic.core.repository -/** Stored passphrase entry with associated TTL parameters. */ -data class StoredPassphrase(val passphrase: String, val boots: Int, val hours: Int) { +/** + * Stored passphrase entry with associated TTL parameters. + * + * @param maxSessionSeconds Per-boot uptime cap, in seconds. 0 = unlimited. + * Non-zero is firmware-side enforcement: the device revokes auth and reboots + * after this many seconds of uptime even if the boot-count TTL is still valid. + */ +data class StoredPassphrase( + val passphrase: String, + val boots: Int, + val hours: Int, + val maxSessionSeconds: Int = 0, +) { init { require(passphrase.isNotEmpty()) { "passphrase must not be empty" } } @@ -35,7 +46,13 @@ interface LockdownPassphraseStore { fun getPassphrase(deviceAddress: String): StoredPassphrase? /** Saves the passphrase and TTL parameters for the given device address. */ - fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) + fun savePassphrase( + deviceAddress: String, + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int = 0, + ) /** Clears the stored passphrase for the given device address. */ fun clearPassphrase(deviceAddress: String) 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 4c6b58af88..19fd1f3577 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 @@ -118,7 +118,7 @@ interface MeshActionHandler { fun handleUpdateLastAddress(deviceAddr: String?) /** Submits a lockdown passphrase to authenticate with a TAK-locked device. */ - fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) + fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int = 0) /** Sends a Lock Now command to the connected TAK-enabled device. */ fun handleSendLockNow() diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 20b92cbd37..c9df41bdfa 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -623,6 +623,8 @@ Passphrases do not match Session: %1$d reboots remaining Expires %1$s + Session cap (minutes) + Per-boot uptime cap. 0 = unlimited. No time limit Set Passphrase Show 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 041a884449..a811bd0e2a 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 @@ -221,8 +221,8 @@ class AndroidRadioControllerImpl( context.startForegroundService(intent) } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) { - serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl) + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { + serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) } override suspend fun sendLockNow() { diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 731e53aba9..0c908ae2cd 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -61,23 +61,36 @@ class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { val passphrase = p.getString("${key}_passphrase", null) ?: return null val boots = p.getInt("${key}_boots", LockdownPassphraseStore.DEFAULT_BOOTS) val hours = p.getInt("${key}_hours", 0) - return StoredPassphrase(passphrase, boots, hours) + val maxSessionSeconds = p.getInt("${key}_maxSessionSeconds", 0) + return StoredPassphrase(passphrase, boots, hours, maxSessionSeconds) } - override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { + override fun savePassphrase( + deviceAddress: String, + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + ) { val p = requirePrefs() val key = sanitizeKey(deviceAddress) p.edit() .putString("${key}_passphrase", passphrase) .putInt("${key}_boots", boots) .putInt("${key}_hours", hours) + .putInt("${key}_maxSessionSeconds", maxSessionSeconds) .apply() } override fun clearPassphrase(deviceAddress: String) { val p = requirePrefs() val key = sanitizeKey(deviceAddress) - p.edit().remove("${key}_passphrase").remove("${key}_boots").remove("${key}_hours").apply() + p.edit() + .remove("${key}_passphrase") + .remove("${key}_boots") + .remove("${key}_hours") + .remove("${key}_maxSessionSeconds") + .apply() } private fun sanitizeKey(address: String): String = address.replace(":", "_") 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 19e80684e7..a8c80c35b8 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 @@ -402,8 +402,18 @@ class MeshService : Service() { 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 sendLockdownUnlock( + passphrase: String?, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + ) = toRemoteExceptions { + router.actionHandler.handleSendLockdownUnlock( + passphrase.orEmpty(), + bootTtl, + hourTtl, + maxSessionSeconds, + ) } 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 e88b451051..0f0e54ee2a 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 @@ -126,7 +126,7 @@ open class FakeIMeshService : IMeshService.Stub() { override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {} - override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) {} + override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int, maxSessionSeconds: 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 86b7a0398d..318ee7981e 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 @@ -235,8 +235,8 @@ class DirectRadioControllerImpl( radioInterfaceService.setDeviceAddress(address) } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) { - actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl) + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { + actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) } override suspend fun sendLockNow() { diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 8bbf4eab9a..868f72a75c 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -71,9 +71,15 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { } } - override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { + override fun savePassphrase( + deviceAddress: String, + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + ) { val key = masterKey ?: error("Lockdown: Cannot save passphrase - keystore unavailable") - val plaintext = serialize(passphrase, boots, hours) + val plaintext = serialize(passphrase, boots, hours, maxSessionSeconds) val encrypted = encrypt(key, plaintext) entryFile(deviceAddress).writeBytes(encrypted) } @@ -114,24 +120,42 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { // region Serialization (simple line-based to avoid adding kotlinx-serialization dependency) - private fun serialize(passphrase: String, boots: Int, hours: Int): ByteArray = - "$boots\n$hours\n$passphrase".encodeToByteArray() + // Format v2: "boots\nhours\nmaxSessionSeconds\npassphrase" (4 lines). + // Backward-compat: legacy 3-line entries (no maxSessionSeconds) decode with maxSessionSeconds=0. + private fun serialize(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int): ByteArray = + "$boots\n$hours\n$maxSessionSeconds\n$passphrase".encodeToByteArray() @Suppress("ReturnCount") private fun deserialize(plaintext: ByteArray): StoredPassphrase? { val text = plaintext.decodeToString() - val lines = text.split("\n", limit = 3) - if (lines.size < SERIALIZED_LINE_COUNT) { + // Try v2 (4-line) format first. + val v2 = text.split("\n", limit = 4) + if (v2.size == SERIALIZED_LINE_COUNT_V2) { + val boots = v2[0].toIntOrNull() + val hours = v2[1].toIntOrNull() + val maxSession = v2[2].toIntOrNull() + if (boots != null && hours != null && maxSession != null) { + return StoredPassphrase( + passphrase = v2[3], + boots = boots, + hours = hours, + maxSessionSeconds = maxSession, + ) + } + } + // Fall back to v1 (3-line, no maxSessionSeconds). + val v1 = text.split("\n", limit = 3) + if (v1.size < SERIALIZED_LINE_COUNT_V1) { Logger.w { "Lockdown: Invalid passphrase entry format" } return null } - val boots = lines[0].toIntOrNull() - val hours = lines[1].toIntOrNull() + val boots = v1[0].toIntOrNull() + val hours = v1[1].toIntOrNull() if (boots == null || hours == null) { Logger.w { "Lockdown: Invalid passphrase entry metadata" } return null } - return StoredPassphrase(passphrase = lines[2], boots = boots, hours = hours) + return StoredPassphrase(passphrase = v1[2], boots = boots, hours = hours) } // endregion @@ -172,6 +196,7 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { private const val AES_KEY_BITS = 256 private const val GCM_TAG_BITS = 128 private const val BYTE_MASK = 0xFF - private const val SERIALIZED_LINE_COUNT = 3 + private const val SERIALIZED_LINE_COUNT_V1 = 3 + private const val SERIALIZED_LINE_COUNT_V2 = 4 } } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt index 40a318c359..9091242d3b 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt @@ -27,6 +27,7 @@ class FakeLockdownCoordinator : LockdownCoordinator { var lastPassphrase: String? = null var lastBoots: Int? = null var lastHours: Int? = null + var lastMaxSessionSeconds: Int? = null var lockNowCalled = false override fun onConnect() { @@ -45,10 +46,11 @@ class FakeLockdownCoordinator : LockdownCoordinator { lastStatus = status } - override fun submitPassphrase(passphrase: String, boots: Int, hours: Int) { + override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { lastPassphrase = passphrase lastBoots = boots lastHours = hours + lastMaxSessionSeconds = maxSessionSeconds } override fun lockNow() { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index 036e9148a6..859fe07c17 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -162,7 +162,7 @@ class FakeRadioController : lastSetDeviceAddress = address } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {} + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {} override suspend fun sendLockNow() {} 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 7aed6d7ad9..84dee5df94 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 @@ -141,8 +141,13 @@ class UIViewModel( 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 sendLockdownUnlock( + passphrase: String, + bootTtl: Int = DEFAULT_BOOT_TTL, + hourTtl: Int = 0, + maxSessionSeconds: Int = 0, + ) { + viewModelScope.launch { radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) } } fun sendLockNow() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt index 1ec56fb7ce..6a83edf108 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -57,6 +57,8 @@ import org.meshtastic.core.resources.lockdown_incorrect_passphrase import org.meshtastic.core.resources.lockdown_lock_reason import org.meshtastic.core.resources.lockdown_passphrase import org.meshtastic.core.resources.lockdown_passphrases_do_not_match +import org.meshtastic.core.resources.lockdown_session_minutes +import org.meshtastic.core.resources.lockdown_session_minutes_help import org.meshtastic.core.resources.lockdown_set_passphrase import org.meshtastic.core.resources.lockdown_show_passphrase import org.meshtastic.core.resources.lockdown_submit @@ -75,7 +77,7 @@ import org.meshtastic.core.ui.icon.VisibilityOff @Composable fun LockdownDialog( lockdownState: LockdownState, - onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit, + onSubmit: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit, onDisconnect: () -> Unit, ) { val shouldShow = @@ -93,6 +95,7 @@ fun LockdownDialog( var passwordVisible by rememberSaveable { mutableStateOf(false) } var boots by rememberSaveable { mutableIntStateOf(LockdownPassphraseStore.DEFAULT_BOOTS) } var hours by rememberSaveable { mutableIntStateOf(0) } + var sessionMinutes by rememberSaveable { mutableIntStateOf(0) } val isProvisioning = lockdownState is LockdownState.NeedsProvision val title = @@ -208,10 +211,20 @@ fun LockdownDialog( modifier = Modifier.weight(1f), ) } + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + OutlinedTextField( + value = sessionMinutes.toString(), + onValueChange = { str -> str.toIntOrNull()?.let { sessionMinutes = it.coerceAtLeast(0) } }, + label = { Text(stringResource(Res.string.lockdown_session_minutes)) }, + supportingText = { Text(stringResource(Res.string.lockdown_session_minutes_help)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) } }, confirmButton = { - TextButton(onClick = { onSubmit(passphrase, boots, hours) }, enabled = isValid) { + TextButton(onClick = { onSubmit(passphrase, boots, hours, sessionMinutes) }, enabled = isValid) { Text(stringResource(Res.string.lockdown_submit)) } }, From 2d6425e0786bfd6eac00f6e817ee3f95138a655f Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Thu, 28 May 2026 16:30:27 -0400 Subject: [PATCH 19/22] fix(proto): bump submodule to #927 (disable/DISABLED) and drop stale Marti prune Moves core/proto to PR #927 head (c834f6b), which adds LockdownAuth.disable and LockdownStatus.State.DISABLED on top of develop. That tree has no meshtastic.Marti message, so the Marti prune left over from the master merge was unused and broke generateCommonMainProtos; remove it. Co-Authored-By: Claude Opus 4.7 --- core/proto/build.gradle.kts | 1 - core/proto/src/main/proto | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/core/proto/build.gradle.kts b/core/proto/build.gradle.kts index ced0614e8b..cccc661e45 100644 --- a/core/proto/build.gradle.kts +++ b/core/proto/build.gradle.kts @@ -120,7 +120,6 @@ wire { prune("meshtastic.GeoPointSource") prune("meshtastic.TakTalkMessage") prune("meshtastic.TakTalkRoomData") - prune("meshtastic.Marti") } // Modern KMP publication uses the project name as the artifactId by default. diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index 83ce840394..c834f6b76e 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit 83ce840394554eb59404bbf76805d70664d931a6 +Subproject commit c834f6b76e75f3318e5746ad43ae151a969a852a From d0857ef278ebc60cd45457b42863df78b7e09115 Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Thu, 28 May 2026 16:30:37 -0400 Subject: [PATCH 20/22] feat(lockdown): runtime enable/disable toggle and DISABLED state Make lockdown a runtime, user-toggleable setting rather than a one-way lock: - Thread a `disable` flag through the lockdown send path (CommandSender, LockdownCoordinator, MeshActionHandler, RadioController, AIDL, UIViewModel) so the app can send LockdownAuth{passphrase, disable=true} to decrypt storage and leave lockdown. - Add LockdownState.Disabled and map LockdownStatus.State.DISABLED; clear the stored passphrase and session authorization when a device reports DISABLED (or when the user disables it), so we never auto-unlock a disabled device. - Add a "Lockdown mode" switch to the security settings screen (LockdownModeSetting): enable from DISABLED via a set-passphrase dialog with a one-time irreversible-SWD warning + explicit confirm; disable from UNLOCKED via a passphrase prompt; "Lock now" and session info while unlocked. The setting is hidden when the device never reports lockdown_status (non-capable). - Tests for the disable round-trip and DISABLED mapping; refresh fakes/strings. Co-Authored-By: Claude Opus 4.7 --- .skills/compose-ui/strings-index.txt | 10 + .../meshtastic/core/service/IMeshService.aidl | 4 +- .../core/data/manager/CommandSenderImpl.kt | 9 +- .../data/manager/LockdownCoordinatorImpl.kt | 51 ++- .../data/manager/MeshActionHandlerImpl.kt | 10 +- .../manager/LockdownCoordinatorImplTest.kt | 47 ++- .../meshtastic/core/model/RadioController.kt | 13 +- .../core/model/service/LockdownState.kt | 3 + .../core/repository/CommandSender.kt | 8 +- .../core/repository/LockdownCoordinator.kt | 15 +- .../repository/LockdownPassphraseStore.kt | 20 +- .../core/repository/MeshActionHandler.kt | 13 +- .../composeResources/values/strings.xml | 10 + .../service/AndroidRadioControllerImpl.kt | 10 +- .../meshtastic/core/service/MeshService.kt | 2 + .../core/service/testing/FakeIMeshService.kt | 8 +- .../core/service/DirectRadioControllerImpl.kt | 10 +- .../core/testing/FakeLockdownCoordinator.kt | 10 +- .../core/testing/FakeRadioController.kt | 8 +- .../core/ui/viewmodel/UIViewModel.kt | 5 +- .../settings/lockdown/LockdownModeSetting.kt | 333 ++++++++++++++++++ .../settings/radio/RadioConfigViewModel.kt | 18 + .../radio/component/SecurityConfigScreen.kt | 32 +- 23 files changed, 595 insertions(+), 54 deletions(-) create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index a3f574d128..e298014406 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -658,12 +658,22 @@ location_sharing lockdown_backoff lockdown_boots_remaining lockdown_confirm_passphrase +lockdown_disable +lockdown_disable_message +lockdown_enable lockdown_enter_passphrase lockdown_hide_passphrase lockdown_hours_until_expiry lockdown_incorrect_passphrase +lockdown_irreversible_ack +lockdown_irreversible_warning lockdown_lock_now lockdown_lock_reason +lockdown_mode +lockdown_mode_setting_up +lockdown_mode_summary_locked +lockdown_mode_summary_off +lockdown_mode_summary_unlocked lockdown_passphrase lockdown_passphrases_do_not_match lockdown_session_boots_remaining 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 ae07820378..37523996b1 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 @@ -205,8 +205,8 @@ interface IMeshService { */ 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, in int maxSessionSeconds); + /// Send a lockdown passphrase to authenticate with a locked device, or (disable=true) to turn lockdown off + void sendLockdownUnlock(in String passphrase, in int bootTtl, in int hourTtl, in int maxSessionSeconds, in boolean disable); /// Send a Lock Now command to the connected TAK-enabled device void sendLockNow(); 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 d8578f61de..c51a8c5623 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 @@ -375,7 +375,13 @@ class CommandSenderImpl( } } - override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { + override fun sendLockdownPassphrase( + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { val validUntilEpoch = if (hours > 0) { (nowMillis / MILLIS_PER_SECOND + hours.toLong() * SECONDS_PER_HOUR).toInt() @@ -388,6 +394,7 @@ class CommandSenderImpl( boots_remaining = boots.coerceAtLeast(0), valid_until_epoch = validUntilEpoch, max_session_seconds = maxSessionSeconds.coerceAtLeast(0), + disable = disable, ) sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth)) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt index 4ff43721a1..91b433f8a6 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt @@ -80,10 +80,28 @@ class LockdownCoordinatorImpl( LockdownStatus.State.LOCKED -> handleLocked(status.lock_reason) LockdownStatus.State.UNLOCKED -> handleUnlocked(status) LockdownStatus.State.UNLOCK_FAILED -> handleUnlockFailed(status.backoff_seconds) + LockdownStatus.State.DISABLED -> handleDisabled() LockdownStatus.State.STATE_UNSPECIFIED -> Logger.w { "Lockdown: Received STATE_UNSPECIFIED from firmware" } } } + @Suppress("TooGenericExceptionCaught") + private fun handleDisabled() { + // Lockdown-capable but currently OFF. Drop any stale stored passphrase so we don't try to auto-unlock later. + val deviceAddress = radioInterfaceService.getDeviceAddress() + if (deviceAddress != null) { + try { + passphraseStore.clearPassphrase(deviceAddress) + } catch (e: Exception) { + Logger.e(e) { "Lockdown: Failed to clear stored passphrase on DISABLED" } + } + } + resetTransientState() + serviceRepository.setSessionAuthorized(false) + serviceRepository.setLockdownTokenInfo(null) + serviceRepository.setLockdownState(LockdownState.Disabled) + } + private fun handleLockNowAcknowledged() { Logger.i { "Lockdown: Lock Now acknowledged — resetting session authorization" } serviceRepository.setSessionAuthorized(false) @@ -187,15 +205,36 @@ class LockdownCoordinatorImpl( } } - override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { - pendingPassphrase = passphrase - pendingBoots = boots - pendingHours = hours - pendingMaxSessionSeconds = maxSessionSeconds + @Suppress("TooGenericExceptionCaught") + override fun submitPassphrase( + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { wasAutoAttempt = false wasLockNow = false + if (disable) { + // Turning lockdown OFF: the device will reboot to DISABLED, so there is nothing to re-save. Drop any + // stored passphrase now so a later reconnect doesn't auto-unlock a device the user just disabled. + pendingPassphrase = null + val deviceAddress = radioInterfaceService.getDeviceAddress() + if (deviceAddress != null) { + try { + passphraseStore.clearPassphrase(deviceAddress) + } catch (e: Exception) { + Logger.e(e) { "Lockdown: Failed to clear stored passphrase while disabling" } + } + } + } else { + pendingPassphrase = passphrase + pendingBoots = boots + pendingHours = hours + pendingMaxSessionSeconds = maxSessionSeconds + } serviceRepository.setLockdownState(LockdownState.None) - commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds) + commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds, disable) } override fun lockNow() { 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 74b7d3b6d0..b3ef50b018 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 @@ -404,8 +404,14 @@ class MeshActionHandlerImpl( } } - override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { - lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds) + override fun handleSendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { + lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable) } override fun handleSendLockNow() { diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt index 0641825d37..c834177473 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt @@ -32,6 +32,7 @@ import org.meshtastic.proto.LockdownStatus import org.meshtastic.proto.Telemetry import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -67,12 +68,22 @@ class LockdownCoordinatorImplTest { var lastPassphrase: String? = null var lastBoots: Int = 0 var lastHours: Int = 0 + var lastMaxSessionSeconds: Int = 0 + var lastDisable: Boolean = false var lockNowCalled = false - override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int) { + override fun sendLockdownPassphrase( + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { lastPassphrase = passphrase lastBoots = boots lastHours = hours + lastMaxSessionSeconds = maxSessionSeconds + lastDisable = disable } override fun sendLockNow() { @@ -488,5 +499,39 @@ class LockdownCoordinatorImplTest { assertIs(serviceRepo.lockdownState.value) } + @Test + fun `submitPassphrase with disable forwards disable flag and clears stored passphrase`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saved[testDeviceAddress] = StoredPassphrase("original", 50, 0) + + coordinator.submitPassphrase("original", boots = 0, hours = 0, disable = true) + + assertTrue(commandSender.lastDisable) + assertTrue(passphraseStore.saved.isEmpty()) + } + + @Test + fun `submitPassphrase without disable does not set disable flag`() { + coordinator.submitPassphrase("p", boots = 5, hours = 0) + assertFalse(commandSender.lastDisable) + } + + // endregion + + // region DISABLED + + @Test + fun `DISABLED sets Disabled state, clears authorization, token, and stored passphrase`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saved[testDeviceAddress] = StoredPassphrase("stale", 50, 0) + + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.DISABLED)) + + assertIs(serviceRepo.lockdownState.value) + assertFalse(serviceRepo.sessionAuthorized.value) + assertNull(serviceRepo.lockdownTokenInfo.value) + assertTrue(passphraseStore.saved.isEmpty()) + } + // endregion } 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 284dbe3bb5..9c7f9c6075 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 @@ -340,8 +340,17 @@ interface RadioController { */ fun setDeviceAddress(address: String) - /** Submits a lockdown passphrase to authenticate with a TAK-locked device. */ - suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int = 0) + /** + * Submits a lockdown passphrase to authenticate with a locked device, or (when [disable] is `true`) to turn + * lockdown OFF. + */ + suspend fun sendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int = 0, + disable: Boolean = false, + ) /** 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 index 9ceb34694e..55d62a5a47 100644 --- 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 @@ -32,6 +32,9 @@ sealed class LockdownState { data object Unlocked : LockdownState() + /** Device is lockdown-capable but lockdown is currently OFF. The toggle shows OFF. */ + data object Disabled : LockdownState() + /** Lock Now ACK received — client should disconnect immediately, no dialog. */ data object LockNowAcknowledged : LockdownState() 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 a3d1128ac6..cdb13b3242 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 @@ -84,12 +84,18 @@ 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. */ + /** + * Sends a lockdown passphrase to authenticate with a locked device. + * + * @param disable when `true`, instructs the device to decrypt storage back to plaintext and leave lockdown (the off + * switch). The device reboots and reconnects reporting `DISABLED`. + */ fun sendLockdownPassphrase( passphrase: String, boots: Int = 0, hours: Int = 0, maxSessionSeconds: Int = 0, + disable: Boolean = false, ) /** Sends a Lock Now command to immediately lock a TAK-enabled device. */ 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 index 18e17ccf1b..0240ebc1d7 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt @@ -42,8 +42,19 @@ interface LockdownCoordinator { /** 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, maxSessionSeconds: Int = 0) + /** + * Submits a passphrase to authenticate with the locked device. + * + * @param disable when `true`, turns lockdown OFF (decrypt storage back to plaintext); the device reboots and + * reconnects reporting `DISABLED`. + */ + fun submitPassphrase( + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int = 0, + disable: Boolean = false, + ) /** Sends a Lock Now command to the connected device. */ fun lockNow() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt index b024bd24f1..4d144c1bc0 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt @@ -19,16 +19,10 @@ package org.meshtastic.core.repository /** * Stored passphrase entry with associated TTL parameters. * - * @param maxSessionSeconds Per-boot uptime cap, in seconds. 0 = unlimited. - * Non-zero is firmware-side enforcement: the device revokes auth and reboots - * after this many seconds of uptime even if the boot-count TTL is still valid. + * @param maxSessionSeconds Per-boot uptime cap, in seconds. 0 = unlimited. Non-zero is firmware-side enforcement: the + * device revokes auth and reboots after this many seconds of uptime even if the boot-count TTL is still valid. */ -data class StoredPassphrase( - val passphrase: String, - val boots: Int, - val hours: Int, - val maxSessionSeconds: Int = 0, -) { +data class StoredPassphrase(val passphrase: String, val boots: Int, val hours: Int, val maxSessionSeconds: Int = 0) { init { require(passphrase.isNotEmpty()) { "passphrase must not be empty" } } @@ -46,13 +40,7 @@ interface LockdownPassphraseStore { fun getPassphrase(deviceAddress: String): StoredPassphrase? /** Saves the passphrase and TTL parameters for the given device address. */ - fun savePassphrase( - deviceAddress: String, - passphrase: String, - boots: Int, - hours: Int, - maxSessionSeconds: Int = 0, - ) + fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int = 0) /** Clears the stored passphrase for the given device address. */ fun clearPassphrase(deviceAddress: String) 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 19fd1f3577..8b4fb60183 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 @@ -117,8 +117,17 @@ 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, maxSessionSeconds: Int = 0) + /** + * Submits a lockdown passphrase to authenticate with a locked device, or (when [disable] is `true`) to turn + * lockdown OFF. + */ + fun handleSendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int = 0, + disable: Boolean = false, + ) /** Sends a Lock Now command to the connected TAK-enabled device. */ fun handleSendLockNow() diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 966eeda646..c44013f24d 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -682,12 +682,22 @@ Try again in %1$d seconds. Boots remaining Confirm passphrase + Disable lockdown + Enter your passphrase to turn off lockdown. The device will decrypt its storage and reboot. + Enable lockdown Enter Passphrase Hide Hours until expiry Incorrect passphrase. + I understand this cannot be undone + Enabling lockdown permanently locks the debug port (SWD) on supported hardware. Recovery then requires a full erase. This cannot be undone. Lock Now Reason: %1$s + Lockdown mode + Setting up… + Active — enter your passphrase to unlock this connection. + Encrypt device storage and require a passphrase per connection. + Active — storage encrypted, this connection authenticated. Passphrase Passphrases do not match Session: %1$d reboots remaining 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 a811bd0e2a..3f2096b4aa 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 @@ -221,8 +221,14 @@ class AndroidRadioControllerImpl( context.startForegroundService(intent) } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { - serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) + override suspend fun sendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { + serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable) } override suspend fun 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 8e6d6c1aa9..797acc22c5 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 @@ -455,12 +455,14 @@ class MeshService : Service() { bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int, + disable: Boolean, ) = toRemoteExceptions { router.actionHandler.handleSendLockdownUnlock( passphrase.orEmpty(), bootTtl, hourTtl, maxSessionSeconds, + disable, ) } 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 0f0e54ee2a..8eb61b2e37 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 @@ -126,7 +126,13 @@ open class FakeIMeshService : IMeshService.Stub() { override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {} - override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {} + override fun sendLockdownUnlock( + passphrase: String?, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) {} 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 318ee7981e..2b467729d3 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 @@ -235,8 +235,14 @@ class DirectRadioControllerImpl( radioInterfaceService.setDeviceAddress(address) } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { - actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) + override suspend fun sendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { + actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable) } override suspend fun sendLockNow() { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt index 9091242d3b..fbec8c9d5d 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt @@ -28,6 +28,7 @@ class FakeLockdownCoordinator : LockdownCoordinator { var lastBoots: Int? = null var lastHours: Int? = null var lastMaxSessionSeconds: Int? = null + var lastDisable: Boolean = false var lockNowCalled = false override fun onConnect() { @@ -46,11 +47,18 @@ class FakeLockdownCoordinator : LockdownCoordinator { lastStatus = status } - override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { + override fun submitPassphrase( + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { lastPassphrase = passphrase lastBoots = boots lastHours = hours lastMaxSessionSeconds = maxSessionSeconds + lastDisable = disable } override fun lockNow() { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index 859fe07c17..a560a105e9 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -162,7 +162,13 @@ class FakeRadioController : lastSetDeviceAddress = address } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {} + override suspend fun sendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) {} override suspend fun sendLockNow() {} 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 84dee5df94..20f88fffbd 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 @@ -146,8 +146,11 @@ class UIViewModel( bootTtl: Int = DEFAULT_BOOT_TTL, hourTtl: Int = 0, maxSessionSeconds: Int = 0, + disable: Boolean = false, ) { - viewModelScope.launch { radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) } + viewModelScope.launch { + radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable) + } } fun sendLockNow() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt new file mode 100644 index 0000000000..ae994aa231 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt @@ -0,0 +1,333 @@ +/* + * Copyright (c) 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.feature.settings.lockdown + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.service.LockdownState +import org.meshtastic.core.model.service.LockdownTokenInfo +import org.meshtastic.core.repository.LockdownPassphraseStore +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.lockdown_boots_remaining +import org.meshtastic.core.resources.lockdown_confirm_passphrase +import org.meshtastic.core.resources.lockdown_disable +import org.meshtastic.core.resources.lockdown_disable_message +import org.meshtastic.core.resources.lockdown_enable +import org.meshtastic.core.resources.lockdown_hide_passphrase +import org.meshtastic.core.resources.lockdown_hours_until_expiry +import org.meshtastic.core.resources.lockdown_irreversible_ack +import org.meshtastic.core.resources.lockdown_irreversible_warning +import org.meshtastic.core.resources.lockdown_lock_now +import org.meshtastic.core.resources.lockdown_mode +import org.meshtastic.core.resources.lockdown_mode_setting_up +import org.meshtastic.core.resources.lockdown_mode_summary_locked +import org.meshtastic.core.resources.lockdown_mode_summary_off +import org.meshtastic.core.resources.lockdown_mode_summary_unlocked +import org.meshtastic.core.resources.lockdown_passphrase +import org.meshtastic.core.resources.lockdown_passphrases_do_not_match +import org.meshtastic.core.resources.lockdown_session_minutes +import org.meshtastic.core.resources.lockdown_session_minutes_help +import org.meshtastic.core.resources.lockdown_set_passphrase +import org.meshtastic.core.resources.lockdown_show_passphrase +import org.meshtastic.core.ui.component.SwitchPreference +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Visibility +import org.meshtastic.core.ui.icon.VisibilityOff +import org.meshtastic.feature.settings.radio.component.NodeActionButton + +/** + * Runtime lockdown-mode toggle for the security settings screen. + * + * The switch and its dialogs are driven entirely by the latest [lockdownState]: + * - [LockdownState.Disabled] / [LockdownState.NeedsProvision] → OFF; turning ON opens the set-passphrase dialog with + * the one-time irreversible warning. + * - [LockdownState.Locked] → ON (locked); authentication is handled by the global lockdown dialog, so the switch is + * read-only here. + * - [LockdownState.Unlocked] → ON; turning OFF opens the disable dialog, plus a "Lock now" affordance and session info. + * + * When [lockdownState] is [LockdownState.None] the device is not lockdown-capable (it never sent a `lockdown_status`), + * so nothing is rendered. + */ +@Composable +fun ColumnScope.LockdownModeSetting( + lockdownState: LockdownState, + tokenInfo: LockdownTokenInfo?, + connected: Boolean, + containerColor: Color, + onEnable: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit, + onDisable: (passphrase: String) -> Unit, + onLockNow: () -> Unit, + modifier: Modifier = Modifier, +) { + if (lockdownState is LockdownState.None) return + + var showEnableDialog by rememberSaveable { mutableStateOf(false) } + var showDisableDialog by rememberSaveable { mutableStateOf(false) } + + val lockdownOn = lockdownState is LockdownState.Locked || lockdownState is LockdownState.Unlocked + val unlocked = lockdownState is LockdownState.Unlocked + // Only DISABLED/NEEDS_PROVISION (turn on) and UNLOCKED (turn off) are actionable from here; LOCKED auth is driven + // by the blocking global dialog. + val toggleEnabled = + connected && + (lockdownState is LockdownState.Disabled || lockdownState is LockdownState.NeedsProvision || unlocked) + + val summary = + when (lockdownState) { + is LockdownState.Unlocked -> stringResource(Res.string.lockdown_mode_summary_unlocked) + is LockdownState.Locked -> stringResource(Res.string.lockdown_mode_summary_locked) + is LockdownState.NeedsProvision -> stringResource(Res.string.lockdown_mode_setting_up) + else -> stringResource(Res.string.lockdown_mode_summary_off) + } + + SwitchPreference( + modifier = modifier, + title = stringResource(Res.string.lockdown_mode), + summary = summary, + checked = lockdownOn, + enabled = toggleEnabled, + onCheckedChange = { turnOn -> if (turnOn) showEnableDialog = true else showDisableDialog = true }, + containerColor = containerColor, + ) + + if (unlocked) { + LockdownSessionStatus(tokenInfo = tokenInfo) + NodeActionButton( + modifier = Modifier.padding(horizontal = SPACING_DP.dp), + title = stringResource(Res.string.lockdown_lock_now), + enabled = connected, + onClick = onLockNow, + ) + } + + if (showEnableDialog) { + EnableLockdownDialog( + onConfirm = { passphrase, boots, hours, sessionMinutes -> + showEnableDialog = false + onEnable(passphrase, boots, hours, sessionMinutes) + }, + onDismiss = { showEnableDialog = false }, + ) + } + + if (showDisableDialog) { + DisableLockdownDialog( + onConfirm = { passphrase -> + showDisableDialog = false + onDisable(passphrase) + }, + onDismiss = { showDisableDialog = false }, + ) + } +} + +@Suppress("LongMethod") +@Composable +private fun EnableLockdownDialog( + onConfirm: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit, + onDismiss: () -> Unit, +) { + var passphrase by rememberSaveable { mutableStateOf("") } + var confirmPassphrase by rememberSaveable { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + var boots by rememberSaveable { mutableIntStateOf(LockdownPassphraseStore.DEFAULT_BOOTS) } + var hours by rememberSaveable { mutableIntStateOf(0) } + var sessionMinutes by rememberSaveable { mutableIntStateOf(0) } + var acknowledged by rememberSaveable { mutableStateOf(false) } + + val passphraseValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN + val matches = passphrase == confirmPassphrase + val isValid = passphraseValid && matches && acknowledged + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(Res.string.lockdown_set_passphrase)) }, + text = { + Column { + Text( + text = stringResource(Res.string.lockdown_irreversible_warning), + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + PassphraseField( + value = passphrase, + onValueChange = { passphrase = it }, + label = stringResource(Res.string.lockdown_passphrase), + passwordVisible = passwordVisible, + onToggleVisibility = { passwordVisible = !passwordVisible }, + ) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + OutlinedTextField( + value = confirmPassphrase, + onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) confirmPassphrase = it }, + label = { Text(stringResource(Res.string.lockdown_confirm_passphrase)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + isError = confirmPassphrase.isNotEmpty() && !matches, + supportingText = + if (confirmPassphrase.isNotEmpty() && !matches) { + { Text(stringResource(Res.string.lockdown_passphrases_do_not_match)) } + } else { + null + }, + 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(stringResource(Res.string.lockdown_boots_remaining)) }, + 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(stringResource(Res.string.lockdown_hours_until_expiry)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + OutlinedTextField( + value = sessionMinutes.toString(), + onValueChange = { str -> str.toIntOrNull()?.let { sessionMinutes = it.coerceAtLeast(0) } }, + label = { Text(stringResource(Res.string.lockdown_session_minutes)) }, + supportingText = { Text(stringResource(Res.string.lockdown_session_minutes_help)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it }) + Text(stringResource(Res.string.lockdown_irreversible_ack)) + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(passphrase, boots, hours, sessionMinutes) }, enabled = isValid) { + Text(stringResource(Res.string.lockdown_enable)) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } }, + ) +} + +@Composable +private fun DisableLockdownDialog(onConfirm: (passphrase: String) -> Unit, onDismiss: () -> Unit) { + var passphrase by rememberSaveable { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + val isValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(Res.string.lockdown_disable)) }, + text = { + Column { + Text(stringResource(Res.string.lockdown_disable_message)) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + PassphraseField( + value = passphrase, + onValueChange = { passphrase = it }, + label = stringResource(Res.string.lockdown_passphrase), + passwordVisible = passwordVisible, + onToggleVisibility = { passwordVisible = !passwordVisible }, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(passphrase) }, enabled = isValid) { + Text(stringResource(Res.string.lockdown_disable)) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } }, + ) +} + +@Composable +private fun PassphraseField( + value: String, + onValueChange: (String) -> Unit, + label: String, + passwordVisible: Boolean, + onToggleVisibility: () -> Unit, +) { + OutlinedTextField( + value = value, + onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) onValueChange(it) }, + label = { Text(label) }, + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = onToggleVisibility) { + Icon( + imageVector = if (passwordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, + contentDescription = + stringResource( + if (passwordVisible) { + Res.string.lockdown_hide_passphrase + } else { + Res.string.lockdown_show_passphrase + }, + ), + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) +} + +// Firmware maximum: AdminMessage.lockdown_auth.passphrase is limited to 64 bytes. +private const val MAX_PASSPHRASE_LEN = 64 +private const val MAX_BYTE_VALUE = 255 +private const val SPACING_DP = 8 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 b9ff67b389..ec870bc4b1 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 @@ -60,6 +60,7 @@ import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.LocationService import org.meshtastic.core.repository.LockdownCoordinator +import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.repository.MapConsentPrefs import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.NodeRepository @@ -141,11 +142,28 @@ open class RadioConfigViewModel( val lockdownTokenInfo = serviceRepository.lockdownTokenInfo val sessionAuthorized = serviceRepository.sessionAuthorized + val lockdownState = serviceRepository.lockdownState fun sendLockNow() { safeLaunch(tag = "sendLockNow") { lockdownCoordinator.lockNow() } } + /** + * Submits a lockdown passphrase: enables lockdown (from DISABLED), authenticates ([disable]=false from LOCKED), or + * turns lockdown off ([disable]=true from UNLOCKED). + */ + fun submitLockdownPassphrase( + passphrase: String, + boots: Int = LockdownPassphraseStore.DEFAULT_BOOTS, + hours: Int = 0, + maxSessionSeconds: Int = 0, + disable: Boolean = false, + ) { + safeLaunch(tag = "submitLockdownPassphrase") { + lockdownCoordinator.submitPassphrase(passphrase, boots, hours, maxSessionSeconds, disable) + } + } + val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed fun toggleAnalyticsAllowed() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt index 887c349e2e..a7b4856055 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt @@ -46,7 +46,6 @@ import org.meshtastic.core.resources.config_security_public_key import org.meshtastic.core.resources.config_security_serial_enabled import org.meshtastic.core.resources.debug_log_api_enabled import org.meshtastic.core.resources.direct_message_key -import org.meshtastic.core.resources.lockdown_lock_now import org.meshtastic.core.resources.logs import org.meshtastic.core.resources.managed_mode import org.meshtastic.core.resources.private_key @@ -63,7 +62,7 @@ import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Warning -import org.meshtastic.feature.settings.lockdown.LockdownSessionStatus +import org.meshtastic.feature.settings.lockdown.LockdownModeSetting import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.proto.Config import kotlin.random.Random @@ -206,16 +205,25 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() + val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle() val tokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle() - val authorized by viewModel.sessionAuthorized.collectAsStateWithLifecycle() - if (authorized) { - LockdownSessionStatus(tokenInfo = tokenInfo) - } - NodeActionButton( - modifier = Modifier.padding(horizontal = 8.dp), - title = stringResource(Res.string.lockdown_lock_now), - enabled = state.connected && authorized, - onClick = { viewModel.sendLockNow() }, + LockdownModeSetting( + lockdownState = lockdownState, + tokenInfo = tokenInfo, + connected = state.connected, + containerColor = CardDefaults.cardColors().containerColor, + onEnable = { passphrase, boots, hours, sessionMinutes -> + viewModel.submitLockdownPassphrase( + passphrase = passphrase, + boots = boots, + hours = hours, + maxSessionSeconds = sessionMinutes * SECONDS_PER_MINUTE, + ) + }, + onDisable = { passphrase -> + viewModel.submitLockdownPassphrase(passphrase = passphrase, disable = true) + }, + onLockNow = { viewModel.sendLockNow() }, ) } } @@ -249,3 +257,5 @@ fun PrivateKeyRegenerateDialog( ) } } + +private const val SECONDS_PER_MINUTE = 60 From 28f92d07f22dd39bba9499df3958aa2764c200b6 Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Thu, 28 May 2026 16:49:11 -0400 Subject: [PATCH 21/22] test: fix Kotlin/Native test compile for lockdown changes The CI shard-core allTests job compiles commonTest for the iosSimulatorArm64 target, which surfaced issues the JVM-only local run missed: - Update stale test fakes to the current interfaces: FakePassphraseStore (maxSessionSeconds on savePassphrase) and core/takserver's FakeCommandSender / FakeServiceRepository (lockdown send + state members). These predate this change but only the Native test compile catches them. - Rename the DISABLED test: Kotlin/Native rejects commas in backtick names. Co-Authored-By: Claude Opus 4.7 --- .../manager/LockdownCoordinatorImplTest.kt | 12 ++++++--- .../core/takserver/TAKMeshIntegrationTest.kt | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt index c834177473..fa0866cacb 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt @@ -53,9 +53,15 @@ class LockdownCoordinatorImplTest { return saved[deviceAddress] } - override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { + override fun savePassphrase( + deviceAddress: String, + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + ) { saveThrows?.let { throw it } - saved[deviceAddress] = StoredPassphrase(passphrase, boots, hours) + saved[deviceAddress] = StoredPassphrase(passphrase, boots, hours, maxSessionSeconds) } override fun clearPassphrase(deviceAddress: String) { @@ -521,7 +527,7 @@ class LockdownCoordinatorImplTest { // region DISABLED @Test - fun `DISABLED sets Disabled state, clears authorization, token, and stored passphrase`() { + fun `DISABLED sets Disabled state and clears authorization token and stored passphrase`() { radioService.setDeviceAddress(testDeviceAddress) passphraseStore.saved[testDeviceAddress] = StoredPassphrase("stale", 50, 0) diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt index f12c817a6f..58f8f7e957 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt @@ -34,6 +34,8 @@ import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.model.Position +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.CommandSender @@ -146,6 +148,16 @@ class TAKMeshIntegrationTest { override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {} override fun requestNeighborInfo(requestId: Int, destNum: Int) {} + + override fun sendLockdownPassphrase( + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) {} + + override fun sendLockNow() {} } private class FakeServiceRepository : ServiceRepository { @@ -191,6 +203,20 @@ class TAKMeshIntegrationTest { override val serviceAction: Flow = MutableSharedFlow() override suspend fun onServiceAction(action: ServiceAction) {} + + override val lockdownState: StateFlow = MutableStateFlow(LockdownState.None) + + override fun setLockdownState(state: LockdownState) {} + + override fun clearLockdownState() {} + + override val lockdownTokenInfo: StateFlow = MutableStateFlow(null) + + override fun setLockdownTokenInfo(info: LockdownTokenInfo?) {} + + override val sessionAuthorized: StateFlow = MutableStateFlow(false) + + override fun setSessionAuthorized(authorized: Boolean) {} } private class FakeMeshConfigHandler : MeshConfigHandler { From 8cab967c14018eab98bdf67b5cc25f6b49e49439 Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Thu, 28 May 2026 18:36:20 -0400 Subject: [PATCH 22/22] feat(lockdown): gate toggle on Capabilities.supportsLockdown; soften enable warning - Add Capabilities.supportsLockdown (atLeast V2_8_0) and gate the security-screen lockdown toggle visibility on it, consistent with the other firmware-version capability flags. SecurityConfigScreen computes Capabilities from the connected node's firmware_version; LockdownModeSetting takes a `supported` flag. - Reword the enable-lockdown warning: it's reversible (disable with passphrase, or a full erase), so drop the inaccurate "permanent/irreversible" framing and the red error styling; rename strings lockdown_irreversible_* -> lockdown_enable_*. - Update CapabilitiesTest for the V2_8_0 floor. Co-Authored-By: Claude Opus 4.7 --- .skills/compose-ui/strings-index.txt | 4 ++-- .../org/meshtastic/core/model/Capabilities.kt | 7 +++++++ .../meshtastic/core/model/CapabilitiesTest.kt | 6 ++++++ .../composeResources/values/strings.xml | 4 ++-- .../settings/lockdown/LockdownModeSetting.kt | 17 +++++++++-------- .../radio/component/SecurityConfigScreen.kt | 5 +++++ 6 files changed, 31 insertions(+), 12 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index e298014406..6f7ed9801e 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -661,12 +661,12 @@ lockdown_confirm_passphrase lockdown_disable lockdown_disable_message lockdown_enable +lockdown_enable_ack +lockdown_enable_warning lockdown_enter_passphrase lockdown_hide_passphrase lockdown_hours_until_expiry lockdown_incorrect_passphrase -lockdown_irreversible_ack -lockdown_irreversible_warning lockdown_lock_now lockdown_lock_reason lockdown_mode diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt index 724bdfdd23..43b914bf83 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -71,6 +71,13 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl /** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */ val supportsEsp32Ota = atLeast(V2_7_18) + /** + * Support for runtime lockdown mode (per-connection passphrase auth). Supported since firmware v2.8.0. Note: + * lockdown is also hardware-gated (nRF52 only) — the device advertises real support by sending a `LockdownStatus`, + * which is the authoritative signal and drives the actual UI state. + */ + val supportsLockdown = atLeast(V2_8_0) + companion object { private val V2_6_8 = DeviceVersion("2.6.8") private val V2_6_9 = DeviceVersion("2.6.9") diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt index b8a80b7044..c92c718c9c 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt @@ -37,6 +37,12 @@ class CapabilitiesTest { assertFalse(caps("3.0.0").canRequestNeighborInfo) } + @Test + fun supportsLockdown_requires_V2_8_0() { + assertFalse(caps("2.7.21").supportsLockdown) + assertTrue(caps("2.8.0").supportsLockdown) + } + @Test fun canSendVerifiedContacts_requires_V2_7_12() { assertFalse(caps("2.7.11").canSendVerifiedContacts) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index c44013f24d..ad6c3532c5 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -685,12 +685,12 @@ Disable lockdown Enter your passphrase to turn off lockdown. The device will decrypt its storage and reboot. Enable lockdown + I understand + Heads up: enabling lockdown locks the debug port (SWD) on supported hardware. You can turn lockdown off any time with your passphrase, and a full device erase restores everything if needed. Enter Passphrase Hide Hours until expiry Incorrect passphrase. - I understand this cannot be undone - Enabling lockdown permanently locks the debug port (SWD) on supported hardware. Recovery then requires a full erase. This cannot be undone. Lock Now Reason: %1$s Lockdown mode diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt index ae994aa231..6ae5813099 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt @@ -58,10 +58,10 @@ import org.meshtastic.core.resources.lockdown_confirm_passphrase import org.meshtastic.core.resources.lockdown_disable import org.meshtastic.core.resources.lockdown_disable_message import org.meshtastic.core.resources.lockdown_enable +import org.meshtastic.core.resources.lockdown_enable_ack +import org.meshtastic.core.resources.lockdown_enable_warning import org.meshtastic.core.resources.lockdown_hide_passphrase import org.meshtastic.core.resources.lockdown_hours_until_expiry -import org.meshtastic.core.resources.lockdown_irreversible_ack -import org.meshtastic.core.resources.lockdown_irreversible_warning import org.meshtastic.core.resources.lockdown_lock_now import org.meshtastic.core.resources.lockdown_mode import org.meshtastic.core.resources.lockdown_mode_setting_up @@ -90,11 +90,12 @@ import org.meshtastic.feature.settings.radio.component.NodeActionButton * read-only here. * - [LockdownState.Unlocked] → ON; turning OFF opens the disable dialog, plus a "Lock now" affordance and session info. * - * When [lockdownState] is [LockdownState.None] the device is not lockdown-capable (it never sent a `lockdown_status`), - * so nothing is rendered. + * Visibility is gated on [supported] — the firmware-version capability from `Capabilities.supportsLockdown` (lockdown + * ships in firmware v2.8.0). [lockdownState] drives the switch position once a `LockdownStatus` arrives. */ @Composable fun ColumnScope.LockdownModeSetting( + supported: Boolean, lockdownState: LockdownState, tokenInfo: LockdownTokenInfo?, connected: Boolean, @@ -104,7 +105,7 @@ fun ColumnScope.LockdownModeSetting( onLockNow: () -> Unit, modifier: Modifier = Modifier, ) { - if (lockdownState is LockdownState.None) return + if (!supported) return var showEnableDialog by rememberSaveable { mutableStateOf(false) } var showDisableDialog by rememberSaveable { mutableStateOf(false) } @@ -190,8 +191,8 @@ private fun EnableLockdownDialog( text = { Column { Text( - text = stringResource(Res.string.lockdown_irreversible_warning), - color = MaterialTheme.colorScheme.error, + text = stringResource(Res.string.lockdown_enable_warning), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(SPACING_DP.dp)) PassphraseField( @@ -250,7 +251,7 @@ private fun EnableLockdownDialog( Spacer(modifier = Modifier.height(SPACING_DP.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it }) - Text(stringResource(Res.string.lockdown_irreversible_ack)) + Text(stringResource(Res.string.lockdown_enable_ack)) } } }, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt index a7b4856055..9d98f53acc 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -33,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import okio.ByteString import okio.ByteString.Companion.toByteString import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.Capabilities import org.meshtastic.core.model.util.encodeToString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.admin_key @@ -78,6 +80,8 @@ expect fun ExportSecurityConfigButton( @Suppress("LongMethod") fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + val firmwareVersion = state.metadata?.firmware_version + val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) } val securityConfig = state.radioConfig.security ?: Config.SecurityConfig() val formState = rememberConfigState(initialValue = securityConfig) @@ -208,6 +212,7 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle() val tokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle() LockdownModeSetting( + supported = capabilities.supportsLockdown, lockdownState = lockdownState, tokenInfo = tokenInfo, connected = state.connected,