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