diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index c900b3eefa..245e5616a3 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -627,6 +627,26 @@ 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_minutes +lockdown_session_minutes_help +lockdown_session_no_time_limit +lockdown_set_passphrase +lockdown_show_passphrase +lockdown_submit locked ### LOG ### log_retention_days diff --git a/.specify/feature.json b/.specify/feature.json index b5d645344b..7bc2be669f 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1,3 @@ -{"feature_directory":"specs/20260513-160000-m3-expressive-adoption"} +{ + "feature_directory": "specs/20260513-075218-lockdown-mode" +} diff --git a/AGENTS.md b/AGENTS.md index 83dd3aca20..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/20260513-160000-m3-expressive-adoption/plan.md` +at `specs/20260513-075218-lockdown-mode/plan.md` diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt index ec8dab03e3..bbbdee4299 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/androidApp/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 @@ -48,6 +49,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 @@ -68,6 +70,21 @@ fun MainScreen() { AndroidAppVersionCheck(viewModel) + val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle() + LockdownDialog( + lockdownState = lockdownState, + 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 + LaunchedEffect(lockdownState) { + if (lockdownState is LockdownState.LockNowAcknowledged) { + viewModel.setDeviceAddress("n") + } + } + MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) { MeshtasticNavigationSuite( multiBackstack = multiBackstack, @@ -132,3 +149,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 f2307dd904..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 @@ -204,4 +204,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, 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 24ababf144..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 @@ -49,6 +49,7 @@ import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.HostMetrics import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.LockdownAuth import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.Neighbor import org.meshtastic.proto.NeighborInfo @@ -56,6 +57,7 @@ import org.meshtastic.proto.Paxcount import org.meshtastic.proto.PortNum import org.meshtastic.proto.PowerMetrics import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.ToRadio import kotlin.math.absoluteValue import kotlin.random.Random import kotlin.time.Duration.Companion.hours @@ -373,6 +375,43 @@ class CommandSenderImpl( } } + 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() + } else { + 0 + } + val lockdownAuth = + LockdownAuth( + passphrase = passphrase.encodeToByteArray().toByteString(), + boots_remaining = boots.coerceAtLeast(0), + valid_until_epoch = validUntilEpoch, + max_session_seconds = maxSessionSeconds.coerceAtLeast(0), + ) + 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.value ?: 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 @@ -462,5 +501,8 @@ class CommandSenderImpl( private const val HEX_RADIX = 16 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 7ea4e92d57..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 @@ -23,6 +23,7 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.ioDispatcher 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 @@ -48,6 +49,7 @@ class FromRadioPacketHandlerImpl( private val mqttManager: MqttManager, private val packetHandler: PacketHandler, private val notificationManager: NotificationManager, + private val lockdownCoordinator: LockdownCoordinator, ) : FromRadioPacketHandler { // Application-scoped coroutine context for suspend work (e.g. getStringSuspend). @@ -69,6 +71,7 @@ class FromRadioPacketHandlerImpl( val deviceUIConfig = proto.deviceuiConfig val fileInfo = proto.fileInfo val xmodemPacket = proto.xmodemPacket + val lockdownStatus = proto.lockdown_status when { myInfo != null -> router.value.configFlowManager.handleMyInfo(myInfo) @@ -84,7 +87,10 @@ class FromRadioPacketHandlerImpl( 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) @@ -100,6 +106,8 @@ class FromRadioPacketHandlerImpl( xmodemPacket != null -> router.value.xmodemManager.handleIncomingXModem(xmodemPacket) + lockdownStatus != null -> lockdownCoordinator.handleLockdownStatus(lockdownStatus) + clientNotification != null -> handleClientNotification(clientNotification) // Firmware rebooted without a transport-level disconnect (common on serial/TCP). 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 new file mode 100644 index 0000000000..4ff43721a1 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt @@ -0,0 +1,214 @@ +/* + * 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 co.touchlab.kermit.Logger +import org.koin.core.annotation.Single +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 +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, + private val passphraseStore: LockdownPassphraseStore, + private val radioInterfaceService: RadioInterfaceService, + private val connectionManager: Lazy, +) : 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 + + @Volatile private var pendingMaxSessionSeconds: Int = 0 + + override fun onConnect() { + serviceRepository.setSessionAuthorized(false) + resetTransientState() + } + + override fun onDisconnect() { + serviceRepository.setSessionAuthorized(false) + serviceRepository.setLockdownTokenInfo(null) + serviceRepository.setLockdownState(LockdownState.None) + resetTransientState() + } + + override fun onConfigComplete() { + // No-op once authorized; retained for lifecycle symmetry. + } + + 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 -> Logger.w { "Lockdown: Received STATE_UNSPECIFIED from firmware" } + } + } + + private fun handleLockNowAcknowledged() { + Logger.i { "Lockdown: Lock Now acknowledged — resetting session authorization" } + serviceRepository.setSessionAuthorized(false) + resetTransientState() + connectionManager.value.clearRadioConfig() + serviceRepository.setLockdownState(LockdownState.LockNowAcknowledged) + } + + @Suppress("TooGenericExceptionCaught") + private fun handleLocked(lockReason: String) { + if (wasLockNow) { + handleLockNowAcknowledged() + return + } + val deviceAddress = radioInterfaceService.getDeviceAddress() + if (deviceAddress != null) { + 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 + commandSender.sendLockdownPassphrase( + stored.passphrase, + stored.boots, + stored.hours, + stored.maxSessionSeconds, + ) + return + } + } + serviceRepository.setLockdownState(LockdownState.Locked(lockReason)) + } + + private fun handleNeedsProvision() { + 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) { + try { + 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)" } + } + } + pendingPassphrase = null + serviceRepository.setLockdownTokenInfo( + LockdownTokenInfo( + bootsRemaining = status.boots_remaining, + expiryEpoch = status.valid_until_epoch.toUInt().toLong(), + ), + ) + serviceRepository.setLockdownState(LockdownState.Unlocked) + serviceRepository.setSessionAuthorized(true) + connectionManager.value.startConfigOnly() + } + + @Suppress("TooGenericExceptionCaught") + 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) { + 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()) + } + 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, maxSessionSeconds: Int) { + pendingPassphrase = passphrase + pendingBoots = boots + pendingHours = hours + pendingMaxSessionSeconds = maxSessionSeconds + wasAutoAttempt = false + wasLockNow = false + serviceRepository.setLockdownState(LockdownState.None) + commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds) + } + + override fun lockNow() { + wasLockNow = true + commandSender.sendLockNow() + } + + private fun resetTransientState() { + wasAutoAttempt = false + wasLockNow = false + 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 e16852d251..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 @@ -35,6 +35,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 @@ -69,6 +70,7 @@ class MeshActionHandlerImpl( private val notificationManager: NotificationManager, private val messageProcessor: Lazy, private val radioConfigRepository: RadioConfigRepository, + private val lockdownCoordinator: LockdownCoordinator, @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshActionHandler { @@ -401,4 +403,12 @@ class MeshActionHandlerImpl( } } } + + override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { + lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds) + } + + 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 a62cb5bedc..ad4bb51f07 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 @@ -40,6 +40,7 @@ import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.HandshakeConstants 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 @@ -87,6 +88,7 @@ class MeshConnectionManagerImpl( private val workerManager: MeshWorkerManager, private val appWidgetUpdater: AppWidgetUpdater, private val heartbeatSender: DataLayerHeartbeatSender, + private val lockdownCoordinator: LockdownCoordinator, @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshConnectionManager { /** @@ -202,6 +204,7 @@ class MeshConnectionManagerImpl( } serviceBroadcasts.broadcastConnection() connectTimeMsec = nowMillis + lockdownCoordinator.onConnect() // Send a wake-up heartbeat before the config request. The firmware may be in a // power-saving state where the NimBLE callback context needs warming up. The 100ms @@ -282,6 +285,7 @@ class MeshConnectionManagerImpl( private fun handleDisconnected() { serviceRepository.setConnectionState(ConnectionState.Disconnected) + lockdownCoordinator.onDisconnect() tearDownConnection() analytics.track( @@ -300,6 +304,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 = HandshakeConstants.NODE_INFO_NONCE)) } startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action) 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..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 @@ -28,17 +28,21 @@ 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 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 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.NodeInfo as ProtoNodeInfo class FromRadioPacketHandlerImplTest { @@ -50,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 @@ -65,6 +70,7 @@ class FromRadioPacketHandlerImplTest { mqttManager, packetHandler, notificationManager, + lockdownCoordinator, ) } @@ -109,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 new file mode 100644 index 0000000000..0641825d37 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt @@ -0,0 +1,492 @@ +/* + * 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 = lazy { 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) + } + + @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")) + + 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 + + @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 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) + // 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) + } + + @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 + + @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..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 @@ -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 @@ -57,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 @@ -74,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) @@ -107,6 +110,7 @@ class MeshActionHandlerImplTest { notificationManager = notificationManager, messageProcessor = lazy { messageProcessor }, radioConfigRepository = radioConfigRepository, + lockdownCoordinator = lockdownCoordinator, scope = scope, ) @@ -311,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 fadd19542e..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 @@ -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 @@ -82,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) @@ -133,6 +135,7 @@ class MeshConnectionManagerImplTest { workerManager, appWidgetUpdater, DataLayerHeartbeatSender(packetHandler), + lockdownCoordinator, scope, ) @@ -150,6 +153,7 @@ class MeshConnectionManagerImplTest { "State should be Connecting after radio Connected", ) verify { serviceBroadcasts.broadcastConnection() } + assertEquals(true, lockdownCoordinator.connectCalled) } @Test @@ -224,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/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt index 84994e6288..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 @@ -339,4 +339,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, maxSessionSeconds: Int = 0) + + /** 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..9ceb34694e --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt @@ -0,0 +1,55 @@ +/* + * 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.model.service + +/** Represents the lockdown authentication state for a firmware-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() { + init { + require(backoffSeconds > 0) { "backoffSeconds must be positive" } + } + } +} + +/** + * 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/proto/src/main/proto b/core/proto/src/main/proto index 59cb394dcf..e978a1850b 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit 59cb394dcfc4432cb216358ca26e861c7d13f462 +Subproject commit e978a1850b905e05913c6ef6c73c1d3b79486d4a 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 a6b58bb485..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 @@ -83,4 +83,15 @@ 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, + 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 new file mode 100644 index 0000000000..18e17ccf1b --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt @@ -0,0 +1,50 @@ +/* + * 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.repository + +import org.meshtastic.proto.LockdownStatus + +/** + * 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. + */ +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() + + /** + * 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() + + /** Routes an incoming typed [LockdownStatus] from FromRadio. */ + fun handleLockdownStatus(status: LockdownStatus) + + /** Submits a passphrase to authenticate with the locked device. */ + fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int = 0) + + /** 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 new file mode 100644 index 0000000000..b024bd24f1 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt @@ -0,0 +1,63 @@ +/* + * 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.repository + +/** + * Stored passphrase entry with associated TTL parameters. + * + * @param maxSessionSeconds Per-boot uptime cap, in seconds. 0 = unlimited. + * Non-zero is firmware-side enforcement: the device revokes auth and reboots + * after this many seconds of uptime even if the boot-count TTL is still valid. + */ +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" } + } +} + +/** + * 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, + maxSessionSeconds: Int = 0, + ) + + /** Clears the stored passphrase for the given device address. */ + fun clearPassphrase(deviceAddress: String) + + companion object { + const val DEFAULT_BOOTS = 50 + } +} 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 873e1c76bd..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 @@ -116,4 +116,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, maxSessionSeconds: Int = 0) + + /** 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 9d898a3333..a7772460f4 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 @@ -37,4 +37,7 @@ interface MeshConnectionManager { /** Updates the current status notification. */ fun updateStatusNotification(telemetry: Telemetry? = null) + + /** 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 2a09e95c8b..ac45c63e0d 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 @@ -20,6 +20,8 @@ import co.touchlab.kermit.Severity import kotlinx.coroutines.flow.Flow 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 @@ -170,4 +172,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) } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 26d831fe62..5e505034ca 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -651,6 +651,26 @@ ChUtil: %1$s% | AirTX: %2$s% Location access is turned off, can not provide position to mesh. Location Sharing + + Try again in %1$d seconds. + Boots remaining + Confirm passphrase + Enter Passphrase + Hide + Hours until expiry + Incorrect passphrase. + Lock Now + Reason: %1$s + Passphrase + 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 + Submit Locked MeshLog retention period diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts index 59f7d3f959..22ed008b49 100644 --- a/core/service/build.gradle.kts +++ b/core/service/build.gradle.kts @@ -48,6 +48,7 @@ kotlin { api(projects.core.api) implementation(libs.androidx.core.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/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/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt index af7cb85c20..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 @@ -220,4 +220,12 @@ class AndroidRadioControllerImpl( val intent = Intent().apply { setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") } context.startForegroundService(intent) } + + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { + serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) + } + + override suspend fun sendLockNow() { + serviceRepository.meshService?.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 new file mode 100644 index 0000000000..0c908ae2cd --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -0,0 +1,101 @@ +/* + * 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 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 + +/** + * 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(binds = [LockdownPassphraseStore::class]) +class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { + + @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 + } + } + + private fun requirePrefs(): SharedPreferences = prefs ?: error("Encrypted passphrase store unavailable") + + @Suppress("ReturnCount") + override fun getPassphrase(deviceAddress: String): StoredPassphrase? { + 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) + val hours = p.getInt("${key}_hours", 0) + val maxSessionSeconds = p.getInt("${key}_maxSessionSeconds", 0) + return StoredPassphrase(passphrase, boots, hours, maxSessionSeconds) + } + + 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") + .remove("${key}_maxSessionSeconds") + .apply() + } + + private fun sanitizeKey(address: String): String = address.replace(":", "_") + + private companion object { + private const val PREFS_FILE_NAME = "lockdown_passphrase_store" + } +} 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 cf636923ac..8e6d6c1aa9 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 @@ -449,5 +449,21 @@ class MeshService : Service() { toRemoteExceptions { router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash) } + + 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 3549aff6e1..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 @@ -125,4 +125,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, 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 a4c95d8cd5..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 @@ -234,4 +234,12 @@ class DirectRadioControllerImpl( actionHandler.handleUpdateLastAddress(address) radioInterfaceService.setDeviceAddress(address) } + + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { + actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) + } + + override suspend fun sendLockNow() { + actionHandler.handleSendLockNow() + } } 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 5ad5c2d003..34aa85b2db 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.StateFlow import kotlinx.coroutines.flow.asFlow 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/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..868f72a75c --- /dev/null +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -0,0 +1,202 @@ +/* + * 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 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 + +/** + * 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. + * + * 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 { + + 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, + maxSessionSeconds: Int, + ) { + val key = masterKey ?: error("Lockdown: Cannot save passphrase - keystore unavailable") + val plaintext = serialize(passphrase, boots, hours, maxSessionSeconds) + val encrypted = encrypt(key, plaintext) + entryFile(deviceAddress).writeBytes(encrypted) + } + + override fun clearPassphrase(deviceAddress: String) { + val file = entryFile(deviceAddress) + if (file.exists() && !file.delete()) { + Logger.w { "Lockdown: Passphrase file was not deleted 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) + + // 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() + // 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 = 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 = v1[2], boots = boots, hours = hours) + } + + // 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 + + 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" + + // 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_V1 = 3 + private const val SERIALIZED_LINE_COUNT_V2 = 4 + } +} 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..fa02d1350f --- /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")) + } +} 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..9091242d3b --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.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.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 lastBoots: Int? = null + var lastHours: Int? = null + var lastMaxSessionSeconds: Int? = 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, maxSessionSeconds: Int) { + lastPassphrase = passphrase + lastBoots = boots + lastHours = hours + lastMaxSessionSeconds = maxSessionSeconds + } + + 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..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,6 +162,10 @@ class FakeRadioController : lastSetDeviceAddress = address } + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: 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 f4d15d3d9c..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 @@ -67,6 +67,8 @@ class ConnectionsViewModel( radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) 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 e0d895226b..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 @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.launchIn 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 @@ -51,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 @@ -136,6 +138,26 @@ 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, + maxSessionSeconds: Int = 0, + ) { + viewModelScope.launch { radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) } + } + + fun sendLockNow() { + viewModelScope.launch { radioController.sendLockNow() } + } + + fun clearLockdownState() { + serviceRepository.clearLockdownState() + } + /** Emits events for mesh network send/receive activity. */ val meshActivity: Flow = radioInterfaceService.meshActivity @@ -294,4 +316,8 @@ class UIViewModel( fun onAppIntroCompleted() { uiPrefs.setAppIntroCompleted(true) } + + companion object { + private const val DEFAULT_BOOT_TTL = LockdownPassphraseStore.DEFAULT_BOOTS + } } 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 new file mode 100644 index 0000000000..6a83edf108 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.lockdown + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.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_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 +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 suppressed to prevent dismissing + * the dialog and bypassing authentication. + */ +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +fun LockdownDialog( + lockdownState: LockdownState, + onSubmit: (passphrase: String, boots: Int, hours: Int, sessionMinutes: 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 confirmPassphrase by rememberSaveable { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + var boots by rememberSaveable { mutableIntStateOf(LockdownPassphraseStore.DEFAULT_BOOTS) } + var hours by rememberSaveable { mutableIntStateOf(0) } + var sessionMinutes by rememberSaveable { mutableIntStateOf(0) } + + val isProvisioning = lockdownState is LockdownState.NeedsProvision + 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.encodeToByteArray().size <= MAX_PASSPHRASE_LEN + val confirmValid = !isProvisioning || passphrase == confirmPassphrase + val isValid = passphraseValid && confirmValid && !inBackoff + + AlertDialog( + onDismissRequest = {}, // Non-dismissable + title = { Text(text = title) }, + text = { + Column { + when (lockdownState) { + is LockdownState.UnlockFailed -> { + Text( + text = stringResource(Res.string.lockdown_incorrect_passphrase), + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + } + + is LockdownState.UnlockBackoff -> { + Text( + 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 = stringResource(Res.string.lockdown_lock_reason, lockdownState.lockReason)) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + } + } + + else -> {} + } + + OutlinedTextField( + value = passphrase, + onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) passphrase = it }, + label = { Text(stringResource(Res.string.lockdown_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 = + stringResource( + if (passwordVisible) { + Res.string.lockdown_hide_passphrase + } else { + Res.string.lockdown_show_passphrase + }, + ), + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + + if (isProvisioning) { + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + OutlinedTextField( + value = confirmPassphrase, + onValueChange = { + if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) confirmPassphrase = it + }, + label = { Text(stringResource(Res.string.lockdown_confirm_passphrase)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + isError = confirmPassphrase.isNotEmpty() && passphrase != confirmPassphrase, + 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) { + OutlinedTextField( + value = boots.toString(), + onValueChange = { str -> str.toIntOrNull()?.let { boots = it.coerceIn(1, MAX_BYTE_VALUE) } }, + label = { Text(stringResource(Res.string.lockdown_boots_remaining)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(SPACING_DP.dp)) + OutlinedTextField( + value = hours.toString(), + onValueChange = { str -> str.toIntOrNull()?.let { hours = it.coerceAtLeast(0) } }, + label = { Text(stringResource(Res.string.lockdown_hours_until_expiry)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + OutlinedTextField( + value = sessionMinutes.toString(), + onValueChange = { str -> str.toIntOrNull()?.let { sessionMinutes = it.coerceAtLeast(0) } }, + label = { Text(stringResource(Res.string.lockdown_session_minutes)) }, + supportingText = { Text(stringResource(Res.string.lockdown_session_minutes_help)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton(onClick = { onSubmit(passphrase, boots, hours, sessionMinutes) }, enabled = isValid) { + Text(stringResource(Res.string.lockdown_submit)) + } + }, + dismissButton = { TextButton(onClick = onDisconnect) { Text(stringResource(Res.string.disconnect)) } }, + ) +} + +// 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 new file mode 100644 index 0000000000..30af59d0b0 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.lockdown + +import androidx.compose.foundation.layout.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.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. + */ +@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 = stringResource(Res.string.lockdown_session_boots_remaining, tokenInfo.bootsRemaining), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (tokenInfo.expiryEpoch > 0L) { + Text( + 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 = stringResource(Res.string.lockdown_session_no_time_limit), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +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 991a27d97b..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 @@ -60,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 @@ -136,7 +137,16 @@ open class RadioConfigViewModel( private val locationService: LocationService, private val fileService: FileService, private val mqttManager: MqttManager, + private val lockdownCoordinator: LockdownCoordinator, ) : ViewModel() { + + val lockdownTokenInfo = serviceRepository.lockdownTokenInfo + val sessionAuthorized = serviceRepository.sessionAuthorized + + fun sendLockNow() { + safeLaunch(tag = "sendLockNow") { lockdownCoordinator.lockNow() } + } + val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed fun toggleAnalyticsAllowed() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt index 3c1c505dca..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 @@ -63,6 +64,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 @@ -212,6 +214,18 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un onCheckedChange = { formState.value = formState.value.copy(admin_channel_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) + HorizontalDivider() + val tokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle() + val authorized by viewModel.sessionAuthorized.collectAsStateWithLifecycle() + if (authorized) { + LockdownSessionStatus(tokenInfo = tokenInfo) + } + NodeActionButton( + modifier = Modifier.padding(horizontal = 8.dp), + title = stringResource(Res.string.lockdown_lock_now), + enabled = state.connected && authorized, + onClick = { viewModel.sendLockNow() }, + ) } } } 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c5aa4a529..20b97a43e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -118,6 +118,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" } 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..95bfc80106 --- /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 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-coordinator.md b/specs/20260513-075218-lockdown-mode/contracts/lockdown-coordinator.md new file mode 100644 index 0000000000..b26b25700d --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/contracts/lockdown-coordinator.md @@ -0,0 +1,65 @@ +# Contract: LockdownCoordinator + +**Module**: `core/repository` (interface) / `core/data` (implementation) +**Source set**: `commonMain` + +## Interface + +```kotlin +package org.meshtastic.core.repository + +import org.meshtastic.proto.LockdownStatus + +/** + * 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 { + + /** Called when a new BLE/radio connection is established. Clears session authorization. */ + fun onConnect() + + /** Called on connection disconnect. Resets all lockdown state for next connection. */ + fun onDisconnect() + + /** Called when config-complete is received. Retained for lifecycle symmetry (currently no-op). */ + fun onConfigComplete() + + /** + * Called by FromRadioPacketHandler when a LockdownStatus proto arrives. + * Drives state transitions and may trigger auto-replay. + */ + fun handleLockdownStatus(status: LockdownStatus) + + /** + * Submit a passphrase for unlock or provision. + * Stores pending passphrase for cache-on-success, sends via CommandSender. + * + * @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 + */ + fun submitPassphrase(passphrase: String, boots: Int, hours: Int) + + /** Send lock-now command. Sets wasLockNow flag so next LOCKED routes to LockNowAcknowledged. */ + fun lockNow() +} +``` + +## Behavioral Contract + +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 new file mode 100644 index 0000000000..8a9a693f99 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/contracts/lockdown-passphrase-store.md @@ -0,0 +1,85 @@ +# Contract: LockdownPassphraseStore + +**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-device storage for lockdown passphrases. + * + * 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) + + companion object { + const val DEFAULT_BOOTS = 50 + } +} +``` + +## Platform Implementations + +### Android (`core/service/androidMain`) + +```kotlin +@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 + } + } + 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**: initialization failures are logged once; subsequent operations fail fast so callers can handle persistence errors explicitly + +### JVM/Desktop (`core/service/jvmMain`) + +```kotlin +@Single(binds = [LockdownPassphraseStore::class]) +class LockdownPassphraseStoreImpl : LockdownPassphraseStore { + private val masterKey: SecretKey? by lazy { loadOrCreateMasterKey() } + // AES-256-GCM encryption per device entry +} +``` + +- **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**: 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 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) 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 new file mode 100644 index 0000000000..246db6ca6a --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md @@ -0,0 +1,62 @@ +# Contract: Lockdown UI Components + +**Module**: `feature/settings` +**Source set**: `commonMain` + +## LockdownDialog + +```kotlin +@Composable +fun LockdownDialog( + lockdownState: LockdownState, + onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit, + onDisconnect: () -> Unit, +) +``` + +`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 + confirm fields, editable `boots` / `hours` inputs, 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 | + +### Component Details + +- **Passphrase field**: `OutlinedTextField` with password visibility toggle +- **Confirm field**: shown only in provisioning mode +- **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 + +## LockdownSessionStatus + +```kotlin +@Composable +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` | "Session: N reboots remaining" | +| `expiryEpoch > 0` | "expires [formatted date]" | +| `expiryEpoch == 0` | "no time limit" | + +## Lock Now Action + +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`. + +## Integration Points + +- `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 new file mode 100644 index 0000000000..319c381665 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/data-model.md @@ -0,0 +1,77 @@ +# Data Model: Lockdown Mode + +**Feature**: Lockdown Mode +**Date**: 2026-05-13 + +## Domain Entities + +### LockdownState + +The current implementation models lockdown UI state with a sealed class in `core/model`. + +| Variant | Fields | Description | +|---------|--------|-------------| +| `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 | + +### LockdownTokenInfo + +Session TTL metadata is stored separately from `LockdownState`. + +| Field | Type | Description | +|-------|------|-------------| +| `bootsRemaining` | `Int` | Reboots remaining before the token expires | +| `expiryEpoch` | `Long` | Unix epoch seconds when the token expires; `0` means no time limit | + +### StoredPassphrase + +Encrypted cached passphrase metadata keyed by connected device address. + +| Field | Type | Description | +|-------|------|-------------| +| `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 -> ServiceRepository state + +| 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) + +| Operation | `passphrase` | `boots_remaining` | `valid_until_epoch` | `lock_now` | +|-----------|-------------|-------------------|--------------------|-----------| +| 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 + +```text +FromRadioPacketHandlerImpl -> LockdownCoordinator.handleLockdownStatus() +LockdownCoordinatorImpl -> LockdownPassphraseStore +LockdownCoordinatorImpl -> CommandSender +LockdownCoordinatorImpl -> ServiceRepository +LockdownCoordinatorImpl -> Lazy (breaks DI cycle) +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 new file mode 100644 index 0000000000..b9222b0603 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/plan.md @@ -0,0 +1,103 @@ +# Implementation Plan: Lockdown Mode + +**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 `LockdownCoordinator` and `LockdownPassphraseStore` interfaces in `commonMain` with platform-specific implementations wired through DI. + +## Technical Context + +**Language/Version**: Kotlin 2.3+ (JDK 21) +**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) +**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 +**Scale/Scope**: Interfaces in `core/repository`, impl in `core/data` + `core/service`, UI in `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` (PKCS12 KeyStore + AES-256-GCM file-backed) + - 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:service`, `:feature:settings` + +- **III. Compose Multiplatform UI**: ✅ PASS + - 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 + - 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 features/lockdown-v2 --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/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 + StoredPassphrase data class + +core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/ +└── LockdownCoordinatorImpl.kt # State machine, auto-replay, error-resilient store calls + +core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/ +└── LockdownCoordinatorImplTest.kt # 15+ test cases covering all transitions + +core/service/src/androidMain/kotlin/org/meshtastic/core/service/ +└── LockdownPassphraseStoreImpl.kt # EncryptedSharedPreferences impl (nullable prefs) + +core/service/src/jvmMain/kotlin/org/meshtastic/core/service/ +└── LockdownPassphraseStoreImpl.kt # PKCS12 KeyStore + AES-256-GCM file-backed impl + +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/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 + +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..f40d56c0bd --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/quickstart.md @@ -0,0 +1,91 @@ +# 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 includes `LockdownAuth` and `LockdownStatus` + +## 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:service:jvmTest +./gradlew :feature:settings:allTests +``` + +## Implementation Order + +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` | 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 Created + +| 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/service` | androidMain | +| `LockdownPassphraseStoreImpl.kt` | `core/service` | jvmMain | +| `LockdownDialog.kt` | `feature/settings` | commonMain | +| `LockdownSessionStatus.kt` | `feature/settings` | commonMain | + +## Testing Strategy + +### 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 state transitions and retry flow +- JVM passphrase store round-trip (`save -> get -> clear`) + +### Integration Testing + +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/service` (androidMain) | EncryptedSharedPreferences | +| Wire-generated protos | `core/proto` | `LockdownAuth`, `LockdownStatus`, `AdminMessage` | + +## Common Pitfalls + +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 new file mode 100644 index 0000000000..2e231aad95 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/research.md @@ -0,0 +1,108 @@ +# 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.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. +- 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: + +**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 `handleLockdownStatus()` 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, 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. +- 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`, `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. `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 = {}`. +- 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: + +**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. + +--- + +### 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**: 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 new file mode 100644 index 0000000000..4fef7be103 --- /dev/null +++ b/specs/20260513-075218-lockdown-mode/spec.md @@ -0,0 +1,218 @@ +# Feature Specification: 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 draft PR (#5439) as the baseline" +**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 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 + +### Implementation Sync (2026-05-13) + +This spec is aligned to the implementation on `features/lockdown-v2`: + +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 + +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-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 + +--- + +### 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/service/` | Encrypted local storage of per-device 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 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`, `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, 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) + +## 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 | +| `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-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 +- 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 new file mode 100644 index 0000000000..b193805e87 --- /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 + +- [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 + +--- + +## Phase 1: Setup + +**Purpose**: Establish module structure and dependencies for lockdown feature + +- [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/` + +--- + +## 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. + +- [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()` / `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 + +**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 + +- [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 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 + +**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 + +- [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-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 + +**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 + +- [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 `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 + +**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 + +- [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. + +--- + +## 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 + +- [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 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 + +**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 + +- [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 +- [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` + +--- + +## 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