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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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)) } },