feat(lockdown): firmware lockdown mode (provision / unlock / lock-now) (#5939)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-24 15:18:13 -05:00
committed by GitHub
parent 8fb2001b52
commit df67b3e094
50 changed files with 3529 additions and 5 deletions

View File

@@ -769,6 +769,36 @@ local_stats_uptime
local_stats_utilization
location_disabled
location_sharing
### LOCKDOWN ###
lockdown_backoff
lockdown_boots_remaining
lockdown_confirm_passphrase
lockdown_disable
lockdown_disable_message
lockdown_enable
lockdown_enable_ack
lockdown_enable_warning
lockdown_enter_passphrase
lockdown_hide_passphrase
lockdown_hours_until_expiry
lockdown_incorrect_passphrase
lockdown_lock_now
lockdown_lock_reason
lockdown_mode
lockdown_mode_setting_up
lockdown_mode_summary_locked
lockdown_mode_summary_off
lockdown_mode_summary_unlocked
lockdown_passphrase
lockdown_passphrases_do_not_match
lockdown_session_boots_remaining
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

View File

@@ -1,3 +1,3 @@
{
"feature_directory": "specs/20260521-153452-car-app-library-integration"
"feature_directory": "specs/20260513-075218-lockdown-mode"
}

View File

@@ -50,4 +50,5 @@ You are an expert Android/KMP engineer. Maintain architectural boundaries, use M
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan
at `specs/20260513-075218-lockdown-mode/plan.md`
<!-- SPECKIT END -->

View File

@@ -33,6 +33,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
@@ -50,6 +51,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
@@ -71,6 +73,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,
@@ -136,3 +153,5 @@ private fun AndroidAppVersionCheck(viewModel: UIViewModel) {
}
}
}
private const val SECONDS_PER_MINUTE = 60

View File

@@ -50,6 +50,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
@@ -57,6 +58,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
@@ -374,6 +376,50 @@ class CommandSenderImpl(
}
}
override fun sendLockdownPassphrase(
passphrase: String,
boots: Int,
hours: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {
val validUntilEpoch =
if (hours > 0) {
(nowMillis / MILLIS_PER_SECOND + hours.toLong() * SECONDS_PER_HOUR).toInt()
} else {
0
}
val lockdownAuth =
LockdownAuth(
passphrase = passphrase.encodeToByteArray().toByteString(),
boots_remaining = boots.coerceAtLeast(0),
valid_until_epoch = validUntilEpoch,
max_session_seconds = maxSessionSeconds.coerceAtLeast(0),
disable = disable,
)
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(address: NodeAddress): Int = when (address) {
NodeAddress.Broadcast -> NodeAddress.NODENUM_BROADCAST
@@ -456,5 +502,8 @@ class CommandSenderImpl(
private const val ADMIN_CHANNEL_NAME = "admin"
private const val DEFAULT_HOP_LIMIT = 3
private const val MILLIS_PER_SECOND = 1000L
private const val SECONDS_PER_HOUR = 3600
}
}

View File

@@ -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.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MqttManager
@@ -52,6 +53,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).
@@ -74,6 +76,7 @@ class FromRadioPacketHandlerImpl(
val fileInfo = proto.fileInfo
val regionPresets = proto.region_presets
val xmodemPacket = proto.xmodemPacket
val lockdownStatus = proto.lockdown_status
when {
myInfo != null -> configFlowManager.value.handleMyInfo(myInfo)
@@ -89,7 +92,10 @@ class FromRadioPacketHandlerImpl(
serviceStateWriter.setConnectionProgress("Nodes (${configFlowManager.value.newNodeCount})")
}
configCompleteId != null -> configFlowManager.value.handleConfigComplete(configCompleteId)
configCompleteId != null -> {
configFlowManager.value.handleConfigComplete(configCompleteId)
lockdownCoordinator.onConfigComplete()
}
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
@@ -109,6 +115,8 @@ class FromRadioPacketHandlerImpl(
xmodemPacket != null -> xmodemManager.value.handleIncomingXModem(xmodemPacket)
lockdownStatus != null -> lockdownCoordinator.handleLockdownStatus(lockdownStatus)
clientNotification != null -> handleClientNotification(clientNotification)
// Firmware rebooted without a transport-level disconnect (common on serial/TCP).

View File

@@ -0,0 +1,253 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<MeshConnectionManager>,
) : LockdownCoordinator {
@Volatile private var wasAutoAttempt = false
@Volatile private var wasLockNow = false
@Volatile private var pendingPassphrase: String? = null
@Volatile private var pendingBoots: Int = LockdownPassphraseStore.DEFAULT_BOOTS
@Volatile private var pendingHours: Int = 0
@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.DISABLED -> handleDisabled()
LockdownStatus.State.STATE_UNSPECIFIED -> Logger.w { "Lockdown: Received STATE_UNSPECIFIED from firmware" }
}
}
@Suppress("TooGenericExceptionCaught")
private fun handleDisabled() {
// Lockdown-capable but currently OFF. Drop any stale stored passphrase so we don't try to auto-unlock later.
val deviceAddress = radioInterfaceService.getDeviceAddress()
if (deviceAddress != null) {
try {
passphraseStore.clearPassphrase(deviceAddress)
} catch (e: Exception) {
Logger.e(e) { "Lockdown: Failed to clear stored passphrase on DISABLED" }
}
}
resetTransientState()
serviceRepository.setSessionAuthorized(false)
serviceRepository.setLockdownTokenInfo(null)
serviceRepository.setLockdownState(LockdownState.Disabled)
}
private fun handleLockNowAcknowledged() {
Logger.i { "Lockdown: Lock Now acknowledged — resetting session authorization" }
serviceRepository.setSessionAuthorized(false)
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)
}
}
@Suppress("TooGenericExceptionCaught")
override fun submitPassphrase(
passphrase: String,
boots: Int,
hours: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {
wasAutoAttempt = false
wasLockNow = false
if (disable) {
// Turning lockdown OFF: the device will reboot to DISABLED, so there is nothing to re-save. Drop any
// stored passphrase now so a later reconnect doesn't auto-unlock a device the user just disabled.
pendingPassphrase = null
val deviceAddress = radioInterfaceService.getDeviceAddress()
if (deviceAddress != null) {
try {
passphraseStore.clearPassphrase(deviceAddress)
} catch (e: Exception) {
Logger.e(e) { "Lockdown: Failed to clear stored passphrase while disabling" }
}
}
} else {
pendingPassphrase = passphrase
pendingBoots = boots
pendingHours = hours
pendingMaxSessionSeconds = maxSessionSeconds
}
serviceRepository.setLockdownState(LockdownState.None)
commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds, disable)
}
override fun lockNow() {
wasLockNow = true
commandSender.sendLockNow()
}
private fun resetTransientState() {
wasAutoAttempt = false
wasLockNow = false
pendingPassphrase = null
pendingBoots = LockdownPassphraseStore.DEFAULT_BOOTS
pendingHours = 0
pendingMaxSessionSeconds = 0
}
}

View File

@@ -43,6 +43,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.MeshNotificationManager
@@ -91,6 +92,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 {
/**
@@ -242,6 +244,7 @@ class MeshConnectionManagerImpl(
// signals ignored (latch left set from the prior failed cycle). This covers both initial
// user-initiated connects and transport-restart recovery siblings.
handshakeCompleteLatch.value = false
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
// delay ensures the heartbeat BLE write is enqueued before the want_config_id
@@ -448,6 +451,7 @@ class MeshConnectionManagerImpl(
private fun handleDisconnected() {
serviceRepository.setConnectionState(ConnectionState.Disconnected)
lockdownCoordinator.onDisconnect()
tearDownConnection()
analytics.track(
@@ -463,6 +467,14 @@ class MeshConnectionManagerImpl(
packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE))
}
override fun clearRadioConfig() {
scope.handledLaunch {
radioConfigRepository.clearLocalConfig()
radioConfigRepository.clearChannelSet()
radioConfigRepository.clearLocalModuleConfig()
}
}
override fun startNodeInfoOnly() {
startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2)
packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE))

View File

@@ -28,18 +28,22 @@ import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.XModemManager
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.LoRaRegionPresetMap
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 {
@@ -51,6 +55,7 @@ class FromRadioPacketHandlerImplTest {
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
private val xmodemManager: XModemManager = 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

View File

@@ -0,0 +1,555 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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.assertFalse
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<String, StoredPassphrase>()
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,
maxSessionSeconds: Int,
) {
saveThrows?.let { throw it }
saved[deviceAddress] = StoredPassphrase(passphrase, boots, hours, maxSessionSeconds)
}
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 lastMaxSessionSeconds: Int = 0
var lastDisable: Boolean = false
var lockNowCalled = false
override fun sendLockdownPassphrase(
passphrase: String,
boots: Int,
hours: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {
lastPassphrase = passphrase
lastBoots = boots
lastHours = hours
lastMaxSessionSeconds = maxSessionSeconds
lastDisable = disable
}
override fun sendLockNow() {
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 suspend fun sendData(p: DataPacket) = Unit
override suspend 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 suspend fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) =
Unit
override suspend fun requestPosition(destNum: Int, currentPosition: Position) = Unit
override suspend fun setFixedPosition(destNum: Int, pos: Position) = Unit
override suspend fun requestUserInfo(destNum: Int) = Unit
override suspend fun requestTraceroute(requestId: Int, destNum: Int) = Unit
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) = Unit
override suspend 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 suspend fun onNodeDbReady() = Unit
override fun updateTelemetry(t: Telemetry) = Unit
override fun updateStatusNotification(telemetry: Telemetry?) = Unit
override fun clearRadioConfig() {
clearRadioConfigCalled = true
}
override fun onHandshakeComplete() = Unit
override fun onHandshakeProgress() = Unit
override fun recoverPostHandshakeFailure() = Unit
}
// 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<LockdownState.None>(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<LockdownState.NeedsProvision>(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<LockdownState.NeedsProvision>(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<LockdownState.Locked>(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<LockdownState.Locked>(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<LockdownState.Locked>(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<LockdownState.Locked>(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<LockdownState.Unlocked>(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<LockdownState.Unlocked>(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<LockdownState.Unlocked>(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<LockdownState.UnlockFailed>(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<LockdownState.UnlockBackoff>(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<LockdownState.Locked>(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<LockdownState.UnlockBackoff>(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<LockdownState.Locked>(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<LockdownState.LockNowAcknowledged>(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<LockdownState.Locked>(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<LockdownState.Locked>(serviceRepo.lockdownState.value)
}
@Test
fun `submitPassphrase with disable forwards disable flag and clears stored passphrase`() {
radioService.setDeviceAddress(testDeviceAddress)
passphraseStore.saved[testDeviceAddress] = StoredPassphrase("original", 50, 0)
coordinator.submitPassphrase("original", boots = 0, hours = 0, disable = true)
assertTrue(commandSender.lastDisable)
assertTrue(passphraseStore.saved.isEmpty())
}
@Test
fun `submitPassphrase without disable does not set disable flag`() {
coordinator.submitPassphrase("p", boots = 5, hours = 0)
assertFalse(commandSender.lastDisable)
}
// endregion
// region DISABLED
@Test
fun `DISABLED sets Disabled state and clears authorization token and stored passphrase`() {
radioService.setDeviceAddress(testDeviceAddress)
passphraseStore.saved[testDeviceAddress] = StoredPassphrase("stale", 50, 0)
coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.DISABLED))
assertIs<LockdownState.Disabled>(serviceRepo.lockdownState.value)
assertFalse(serviceRepo.sessionAuthorized.value)
assertNull(serviceRepo.lockdownTokenInfo.value)
assertTrue(passphraseStore.saved.isEmpty())
}
// endregion
}

View File

@@ -56,6 +56,7 @@ import org.meshtastic.core.repository.RadioInterfaceService
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
@@ -87,6 +88,7 @@ class MeshConnectionManagerImplTest {
private lateinit var packetRepository: PacketRepository
private lateinit var workerManager: MeshWorkerManager
private lateinit var appWidgetUpdater: AppWidgetUpdater
private lateinit var lockdownCoordinator: FakeLockdownCoordinator
private val dataPacket = DataPacket(id = 456, time = 0L, to = "0", from = "0", bytes = null, dataType = 0)
@@ -118,6 +120,7 @@ class MeshConnectionManagerImplTest {
packetRepository = mock(MockMode.autofill)
workerManager = mock(MockMode.autofill)
appWidgetUpdater = mock(MockMode.autofill)
lockdownCoordinator = FakeLockdownCoordinator()
testDispatcher = UnconfinedTestDispatcher()
radioConnectionState.value = ConnectionState.Disconnected
@@ -161,6 +164,7 @@ class MeshConnectionManagerImplTest {
workerManager,
appWidgetUpdater,
DataLayerHeartbeatSender(packetHandler),
lockdownCoordinator,
scope,
)
@@ -183,6 +187,7 @@ class MeshConnectionManagerImplTest {
serviceRepository.connectionState.value,
"State should be Connecting after radio Connected",
)
assertEquals(true, lockdownCoordinator.connectCalled)
}
@Test
@@ -257,6 +262,7 @@ class MeshConnectionManagerImplTest {
verify { packetHandler.stopPacketQueue() }
verify { locationManager.stop() }
verify { mqttManager.stop() }
assertEquals(true, lockdownCoordinator.disconnectCalled)
}
@Test

View File

@@ -45,7 +45,16 @@ kotlin {
// kzstd codec, and CoT XML from xmlutil — all KMP, all targets. api()-
// exported so :core:takserver reaches the org.meshtastic.tak.* pipeline
// from commonMain on every platform (no JVM-only scoping or iOS stubs).
api(libs.takpacket.sdk.kmp.get().toString())
//
// takpacket-sdk still declares a transitive protobufs (protobufs-jvm:2.7.25); exclude it so
// the app's single protobufs version (api above) is authoritative. Otherwise the older
// transitive pin out-ranks our snapshot on the host-test classpath and breaks proto ABI
// (NoSuchMethodError on post-2.7.25 messages). Drop once takpacket stops exporting protobufs.
api(libs.takpacket.sdk.kmp.get().toString()) {
exclude(group = "org.meshtastic", module = "protobufs")
exclude(group = "org.meshtastic", module = "protobufs-jvm")
exclude(group = "org.meshtastic", module = "protobufs-android")
}
}
androidMain.dependencies {
api(libs.androidx.annotation)

View File

@@ -68,6 +68,13 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
/** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */
val supportsEsp32Ota = atLeast(V2_7_18)
/**
* Support for runtime lockdown mode (per-connection passphrase auth). Supported since firmware v2.8.0. Note:
* lockdown is also hardware-gated (nRF52 only) — the device advertises real support by sending a `LockdownStatus`,
* which is the authoritative signal and drives the actual UI state.
*/
val supportsLockdown = atLeast(V2_8_0)
companion object {
private val V2_6_8 = DeviceVersion("2.6.8")
private val V2_6_9 = DeviceVersion("2.6.9")

View File

@@ -0,0 +1,58 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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()
/** Device is lockdown-capable but lockdown is currently OFF. The toggle shows OFF. */
data object Disabled : LockdownState()
/** Lock Now ACK received — client should disconnect immediately, no dialog. */
data object LockNowAcknowledged : LockdownState()
/** 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)

View File

@@ -37,6 +37,12 @@ class CapabilitiesTest {
assertFalse(caps("3.0.0").canRequestNeighborInfo)
}
@Test
fun supportsLockdown_requires_V2_8_0() {
assertFalse(caps("2.7.21").supportsLockdown)
assertTrue(caps("2.8.0").supportsLockdown)
}
@Test
fun canSendVerifiedContacts_requires_V2_7_12() {
assertFalse(caps("2.7.11").canSendVerifiedContacts)

View File

@@ -83,4 +83,21 @@ interface CommandSender {
/** Requests neighbor info from a specific node. */
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
/**
* Sends a lockdown passphrase to authenticate with a locked device.
*
* @param disable when `true`, instructs the device to decrypt storage back to plaintext and leave lockdown (the off
* switch). The device reboots and reconnects reporting `DISABLED`.
*/
fun sendLockdownPassphrase(
passphrase: String,
boots: Int = 0,
hours: Int = 0,
maxSessionSeconds: Int = 0,
disable: Boolean = false,
)
/** Sends a Lock Now command to immediately lock a locked-firmware device. */
fun sendLockNow()
}

View File

@@ -0,0 +1,61 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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.
*
* @param disable when `true`, turns lockdown OFF (decrypt storage back to plaintext); the device reboots and
* reconnects reporting `DISABLED`.
*/
fun submitPassphrase(
passphrase: String,
boots: Int,
hours: Int,
maxSessionSeconds: Int = 0,
disable: Boolean = false,
)
/** Sends a Lock Now command to the connected device. */
fun lockNow()
}

View File

@@ -0,0 +1,51 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -69,4 +69,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()
}

View File

@@ -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.TracerouteResponse
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
@@ -176,4 +178,25 @@ interface ServiceRepository :
*/
const val RECONNECTING_PROGRESS_TEXT = "Reconnecting\u2026"
}
/** Reactive flow of the current lockdown authentication state. */
val lockdownState: StateFlow<LockdownState>
/** 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<LockdownTokenInfo?>
/** 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<Boolean>
/** Updates the session authorization flag. */
fun setSessionAuthorized(authorized: Boolean)
}

View File

@@ -793,6 +793,36 @@
<string name="local_stats_utilization">ChUtil: %1$s% | AirTX: %2$s%</string>
<string name="location_disabled">Location access is turned off, can not provide position to mesh.</string>
<string name="location_sharing">Location Sharing</string>
<!-- LOCKDOWN -->
<string name="lockdown_backoff">Try again in %1$d seconds.</string>
<string name="lockdown_boots_remaining">Boots remaining</string>
<string name="lockdown_confirm_passphrase">Confirm passphrase</string>
<string name="lockdown_disable">Disable lockdown</string>
<string name="lockdown_disable_message">Enter your passphrase to turn off lockdown. The device will decrypt its storage and reboot.</string>
<string name="lockdown_enable">Enable lockdown</string>
<string name="lockdown_enable_ack">I understand</string>
<string name="lockdown_enable_warning">Heads up: enabling lockdown locks the debug port (SWD) on supported hardware. You can turn lockdown off any time with your passphrase, and a full device erase restores everything if needed.</string>
<string name="lockdown_enter_passphrase">Enter Passphrase</string>
<string name="lockdown_hide_passphrase">Hide</string>
<string name="lockdown_hours_until_expiry">Hours until expiry</string>
<string name="lockdown_incorrect_passphrase">Incorrect passphrase.</string>
<string name="lockdown_lock_now">Lock Now</string>
<string name="lockdown_lock_reason">Reason: %1$s</string>
<string name="lockdown_mode">Lockdown mode</string>
<string name="lockdown_mode_setting_up">Setting up…</string>
<string name="lockdown_mode_summary_locked">Active — enter your passphrase to unlock this connection.</string>
<string name="lockdown_mode_summary_off">Encrypt device storage and require a passphrase per connection.</string>
<string name="lockdown_mode_summary_unlocked">Active — storage encrypted, this connection authenticated.</string>
<string name="lockdown_passphrase">Passphrase</string>
<string name="lockdown_passphrases_do_not_match">Passphrases do not match</string>
<string name="lockdown_session_boots_remaining">Session: %1$d reboots remaining</string>
<string name="lockdown_session_expires">Expires %1$s</string>
<string name="lockdown_session_minutes">Session cap (minutes)</string>
<string name="lockdown_session_minutes_help">Per-boot uptime cap. 0 = unlimited.</string>
<string name="lockdown_session_no_time_limit">No time limit</string>
<string name="lockdown_set_passphrase">Set Passphrase</string>
<string name="lockdown_show_passphrase">Show</string>
<string name="lockdown_submit">Submit</string>
<string name="locked">Locked</string>
<!-- LOG -->
<string name="log_retention_days">MeshLog retention period</string>

View File

@@ -47,6 +47,7 @@ kotlin {
androidMain.dependencies {
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)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}
}

View File

@@ -24,6 +24,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.TracerouteResponse
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ClientNotification
@@ -115,4 +117,32 @@ open class ServiceRepositoryImpl : ServiceRepository {
override fun clearNeighborInfoResponse() {
setNeighborInfoResponse(null)
}
private val _lockdownState = MutableStateFlow<LockdownState>(LockdownState.None)
override val lockdownState: StateFlow<LockdownState>
get() = _lockdownState
override fun setLockdownState(state: LockdownState) {
_lockdownState.value = state
}
override fun clearLockdownState() {
_lockdownState.value = LockdownState.None
}
private val _lockdownTokenInfo = MutableStateFlow<LockdownTokenInfo?>(null)
override val lockdownTokenInfo: StateFlow<LockdownTokenInfo?>
get() = _lockdownTokenInfo
override fun setLockdownTokenInfo(info: LockdownTokenInfo?) {
_lockdownTokenInfo.value = info
}
private val _sessionAuthorized = MutableStateFlow(false)
override val sessionAuthorized: StateFlow<Boolean>
get() = _sessionAuthorized
override fun setSessionAuthorized(authorized: Boolean) {
_sessionAuthorized.value = authorized
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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"))
}
}

View File

@@ -34,6 +34,8 @@ import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.LockdownState
import org.meshtastic.core.model.service.LockdownTokenInfo
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigHandler
@@ -151,6 +153,16 @@ class TAKMeshIntegrationTest {
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {}
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {}
override fun sendLockdownPassphrase(
passphrase: String,
boots: Int,
hours: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {}
override fun sendLockNow() {}
}
private class FakeServiceRepository : ServiceRepository {
@@ -192,6 +204,20 @@ class TAKMeshIntegrationTest {
override fun setNeighborInfoResponse(value: String?) {}
override fun clearNeighborInfoResponse() {}
override val lockdownState: StateFlow<LockdownState> = MutableStateFlow(LockdownState.None)
override fun setLockdownState(state: LockdownState) {}
override fun clearLockdownState() {}
override val lockdownTokenInfo: StateFlow<LockdownTokenInfo?> = MutableStateFlow(null)
override fun setLockdownTokenInfo(info: LockdownTokenInfo?) {}
override val sessionAuthorized: StateFlow<Boolean> = MutableStateFlow(false)
override fun setSessionAuthorized(authorized: Boolean) {}
}
private class FakeMeshConfigHandler : MeshConfigHandler {

View File

@@ -0,0 +1,67 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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 lastDisable: Boolean = false
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,
disable: Boolean,
) {
lastPassphrase = passphrase
lastBoots = boots
lastHours = hours
lastMaxSessionSeconds = maxSessionSeconds
lastDisable = disable
}
override fun lockNow() {
lockNowCalled = true
}
}

View File

@@ -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.TracerouteResponse
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ClientNotification
@@ -95,4 +97,29 @@ class FakeServiceRepository : ServiceRepository {
override fun clearNeighborInfoResponse() {
_neighborInfoResponse.value = null
}
private val _lockdownState = MutableStateFlow<LockdownState>(LockdownState.None)
override val lockdownState: StateFlow<LockdownState> = _lockdownState
override fun setLockdownState(state: LockdownState) {
_lockdownState.value = state
}
override fun clearLockdownState() {
_lockdownState.value = LockdownState.None
}
private val _lockdownTokenInfo = MutableStateFlow<LockdownTokenInfo?>(null)
override val lockdownTokenInfo: StateFlow<LockdownTokenInfo?> = _lockdownTokenInfo
override fun setLockdownTokenInfo(info: LockdownTokenInfo?) {
_lockdownTokenInfo.value = info
}
private val _sessionAuthorized = MutableStateFlow(false)
override val sessionAuthorized: StateFlow<Boolean> = _sessionAuthorized
override fun setSessionAuthorized(authorized: Boolean) {
_sessionAuthorized.value = authorized
}
}

View File

@@ -74,6 +74,8 @@ class ConnectionsViewModel(
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
val connectionState = serviceRepository.connectionState
val lockdownState = serviceRepository.lockdownState
val sessionAuthorized = serviceRepository.sessionAuthorized
val myNodeInfo: StateFlow<MyNodeInfo?> = nodeRepository.myNodeInfo

View File

@@ -50,6 +50,8 @@ import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DeepLinkRouter
import org.meshtastic.core.repository.EventFirmwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.LockdownPassphraseStore
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
@@ -81,6 +83,7 @@ class UIViewModel(
private val nodeDB: NodeRepository,
protected val serviceRepository: ServiceRepository,
private val radioController: RadioController,
private val lockdownCoordinator: LockdownCoordinator,
radioInterfaceService: RadioInterfaceService,
meshLogRepository: MeshLogRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
@@ -142,6 +145,27 @@ 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,
disable: Boolean = false,
) {
lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable)
}
fun sendLockNow() {
lockdownCoordinator.lockNow()
}
fun clearLockdownState() {
serviceRepository.clearLockdownState()
}
/** Emits events for mesh network send/receive activity. */
val meshActivity: Flow<MeshActivity> = radioInterfaceService.meshActivity
@@ -300,4 +324,8 @@ class UIViewModel(
fun onAppIntroCompleted() {
uiPrefs.setAppIntroCompleted(true)
}
companion object {
private const val DEFAULT_BOOT_TTL = LockdownPassphraseStore.DEFAULT_BOOTS
}
}

View File

@@ -119,6 +119,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()
@@ -269,11 +270,13 @@ fun ConnectionsScreen(
// Region warning sits outside the animated card so it does not affect the
// CONNECTED ↔ CONNECTING ↔ NO_DEVICE size transition.
val isPhysicalDevice =
selectedDevice != MOCK_DEVICE_PREFIX && selectedDevice != REPLAY_DEVICE_PREFIX
if (
uiState == ConnectionUiState.CONNECTED_WITH_NODE &&
regionUnset &&
selectedDevice != MOCK_DEVICE_PREFIX &&
selectedDevice != REPLAY_DEVICE_PREFIX
sessionAuthorized &&
isPhysicalDevice
) {
Spacer(modifier = Modifier.height(8.dp))
Card(modifier = Modifier.fillMaxWidth()) {

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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

View File

@@ -0,0 +1,334 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.lockdown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.service.LockdownState
import org.meshtastic.core.model.service.LockdownTokenInfo
import org.meshtastic.core.repository.LockdownPassphraseStore
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.lockdown_boots_remaining
import org.meshtastic.core.resources.lockdown_confirm_passphrase
import org.meshtastic.core.resources.lockdown_disable
import org.meshtastic.core.resources.lockdown_disable_message
import org.meshtastic.core.resources.lockdown_enable
import org.meshtastic.core.resources.lockdown_enable_ack
import org.meshtastic.core.resources.lockdown_enable_warning
import org.meshtastic.core.resources.lockdown_hide_passphrase
import org.meshtastic.core.resources.lockdown_hours_until_expiry
import org.meshtastic.core.resources.lockdown_lock_now
import org.meshtastic.core.resources.lockdown_mode
import org.meshtastic.core.resources.lockdown_mode_setting_up
import org.meshtastic.core.resources.lockdown_mode_summary_locked
import org.meshtastic.core.resources.lockdown_mode_summary_off
import org.meshtastic.core.resources.lockdown_mode_summary_unlocked
import org.meshtastic.core.resources.lockdown_passphrase
import org.meshtastic.core.resources.lockdown_passphrases_do_not_match
import org.meshtastic.core.resources.lockdown_session_minutes
import org.meshtastic.core.resources.lockdown_session_minutes_help
import org.meshtastic.core.resources.lockdown_set_passphrase
import org.meshtastic.core.resources.lockdown_show_passphrase
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Visibility
import org.meshtastic.core.ui.icon.VisibilityOff
import org.meshtastic.feature.settings.radio.component.NodeActionButton
/**
* Runtime lockdown-mode toggle for the security settings screen.
*
* The switch and its dialogs are driven entirely by the latest [lockdownState]:
* - [LockdownState.Disabled] / [LockdownState.NeedsProvision] → OFF; turning ON opens the set-passphrase dialog with
* the one-time irreversible warning.
* - [LockdownState.Locked] → ON (locked); authentication is handled by the global lockdown dialog, so the switch is
* read-only here.
* - [LockdownState.Unlocked] → ON; turning OFF opens the disable dialog, plus a "Lock now" affordance and session info.
*
* Visibility is gated on [supported] — the firmware-version capability from `Capabilities.supportsLockdown` (lockdown
* ships in firmware v2.8.0). [lockdownState] drives the switch position once a `LockdownStatus` arrives.
*/
@Composable
fun ColumnScope.LockdownModeSetting(
supported: Boolean,
lockdownState: LockdownState,
tokenInfo: LockdownTokenInfo?,
connected: Boolean,
containerColor: Color,
onEnable: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit,
onDisable: (passphrase: String) -> Unit,
onLockNow: () -> Unit,
modifier: Modifier = Modifier,
) {
if (!supported) return
var showEnableDialog by rememberSaveable { mutableStateOf(false) }
var showDisableDialog by rememberSaveable { mutableStateOf(false) }
val lockdownOn = lockdownState is LockdownState.Locked || lockdownState is LockdownState.Unlocked
val unlocked = lockdownState is LockdownState.Unlocked
// Only DISABLED/NEEDS_PROVISION (turn on) and UNLOCKED (turn off) are actionable from here; LOCKED auth is driven
// by the blocking global dialog.
val toggleEnabled =
connected &&
(lockdownState is LockdownState.Disabled || lockdownState is LockdownState.NeedsProvision || unlocked)
val summary =
when (lockdownState) {
is LockdownState.Unlocked -> stringResource(Res.string.lockdown_mode_summary_unlocked)
is LockdownState.Locked -> stringResource(Res.string.lockdown_mode_summary_locked)
is LockdownState.NeedsProvision -> stringResource(Res.string.lockdown_mode_setting_up)
else -> stringResource(Res.string.lockdown_mode_summary_off)
}
SwitchPreference(
modifier = modifier,
title = stringResource(Res.string.lockdown_mode),
summary = summary,
checked = lockdownOn,
enabled = toggleEnabled,
onCheckedChange = { turnOn -> if (turnOn) showEnableDialog = true else showDisableDialog = true },
containerColor = containerColor,
)
if (unlocked) {
LockdownSessionStatus(tokenInfo = tokenInfo)
NodeActionButton(
modifier = Modifier.padding(horizontal = SPACING_DP.dp),
title = stringResource(Res.string.lockdown_lock_now),
enabled = connected,
onClick = onLockNow,
)
}
if (showEnableDialog) {
EnableLockdownDialog(
onConfirm = { passphrase, boots, hours, sessionMinutes ->
showEnableDialog = false
onEnable(passphrase, boots, hours, sessionMinutes)
},
onDismiss = { showEnableDialog = false },
)
}
if (showDisableDialog) {
DisableLockdownDialog(
onConfirm = { passphrase ->
showDisableDialog = false
onDisable(passphrase)
},
onDismiss = { showDisableDialog = false },
)
}
}
@Suppress("LongMethod")
@Composable
private fun EnableLockdownDialog(
onConfirm: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit,
onDismiss: () -> Unit,
) {
var passphrase by rememberSaveable { mutableStateOf("") }
var confirmPassphrase by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
var boots by rememberSaveable { mutableIntStateOf(LockdownPassphraseStore.DEFAULT_BOOTS) }
var hours by rememberSaveable { mutableIntStateOf(0) }
var sessionMinutes by rememberSaveable { mutableIntStateOf(0) }
var acknowledged by rememberSaveable { mutableStateOf(false) }
val passphraseValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN
val matches = passphrase == confirmPassphrase
val isValid = passphraseValid && matches && acknowledged
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.lockdown_set_passphrase)) },
text = {
Column {
Text(
text = stringResource(Res.string.lockdown_enable_warning),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
PassphraseField(
value = passphrase,
onValueChange = { passphrase = it },
label = stringResource(Res.string.lockdown_passphrase),
passwordVisible = passwordVisible,
onToggleVisibility = { passwordVisible = !passwordVisible },
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
OutlinedTextField(
value = confirmPassphrase,
onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) confirmPassphrase = it },
label = { Text(stringResource(Res.string.lockdown_confirm_passphrase)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
isError = confirmPassphrase.isNotEmpty() && !matches,
supportingText =
if (confirmPassphrase.isNotEmpty() && !matches) {
{ Text(stringResource(Res.string.lockdown_passphrases_do_not_match)) }
} else {
null
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedTextField(
value = boots.toString(),
onValueChange = { str -> str.toIntOrNull()?.let { boots = it.coerceIn(1, MAX_BYTE_VALUE) } },
label = { Text(stringResource(Res.string.lockdown_boots_remaining)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(SPACING_DP.dp))
OutlinedTextField(
value = hours.toString(),
onValueChange = { str -> str.toIntOrNull()?.let { hours = it.coerceAtLeast(0) } },
label = { Text(stringResource(Res.string.lockdown_hours_until_expiry)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(SPACING_DP.dp))
OutlinedTextField(
value = sessionMinutes.toString(),
onValueChange = { str -> str.toIntOrNull()?.let { sessionMinutes = it.coerceAtLeast(0) } },
label = { Text(stringResource(Res.string.lockdown_session_minutes)) },
supportingText = { Text(stringResource(Res.string.lockdown_session_minutes_help)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it })
Text(stringResource(Res.string.lockdown_enable_ack))
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(passphrase, boots, hours, sessionMinutes) }, enabled = isValid) {
Text(stringResource(Res.string.lockdown_enable))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}
@Composable
private fun DisableLockdownDialog(onConfirm: (passphrase: String) -> Unit, onDismiss: () -> Unit) {
var passphrase by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
val isValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.lockdown_disable)) },
text = {
Column {
Text(stringResource(Res.string.lockdown_disable_message))
Spacer(modifier = Modifier.height(SPACING_DP.dp))
PassphraseField(
value = passphrase,
onValueChange = { passphrase = it },
label = stringResource(Res.string.lockdown_passphrase),
passwordVisible = passwordVisible,
onToggleVisibility = { passwordVisible = !passwordVisible },
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(passphrase) }, enabled = isValid) {
Text(stringResource(Res.string.lockdown_disable))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}
@Composable
private fun PassphraseField(
value: String,
onValueChange: (String) -> Unit,
label: String,
passwordVisible: Boolean,
onToggleVisibility: () -> Unit,
) {
OutlinedTextField(
value = value,
onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) onValueChange(it) },
label = { Text(label) },
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = onToggleVisibility) {
Icon(
imageVector = if (passwordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility,
contentDescription =
stringResource(
if (passwordVisible) {
Res.string.lockdown_hide_passphrase
} else {
Res.string.lockdown_show_passphrase
},
),
)
}
},
modifier = Modifier.fillMaxWidth(),
)
}
// Firmware maximum: AdminMessage.lockdown_auth.passphrase is limited to 64 bytes.
private const val MAX_PASSPHRASE_LEN = 64
private const val MAX_BYTE_VALUE = 255
private const val SPACING_DP = 8

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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

View File

@@ -57,6 +57,8 @@ 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.LockdownPassphraseStore
import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeRepository
@@ -137,7 +139,33 @@ 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
val lockdownState = serviceRepository.lockdownState
fun sendLockNow() {
safeLaunch(tag = "sendLockNow") { lockdownCoordinator.lockNow() }
}
/**
* Submits a lockdown passphrase: enables lockdown (from DISABLED), authenticates ([disable]=false from LOCKED), or
* turns lockdown off ([disable]=true from UNLOCKED).
*/
fun submitLockdownPassphrase(
passphrase: String,
boots: Int = LockdownPassphraseStore.DEFAULT_BOOTS,
hours: Int = 0,
maxSessionSeconds: Int = 0,
disable: Boolean = false,
) {
safeLaunch(tag = "submitLockdownPassphrase") {
lockdownCoordinator.submitPassphrase(passphrase, boots, hours, maxSessionSeconds, disable)
}
}
val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed
fun toggleAnalyticsAllowed() {

View File

@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -33,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.util.encodeToString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.admin_key
@@ -62,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.LockdownModeSetting
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.Config
import kotlin.random.Random
@@ -77,6 +80,8 @@ expect fun ExportSecurityConfigButton(
@Suppress("LongMethod")
fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val firmwareVersion = state.metadata?.firmware_version
val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) }
val securityConfig = state.radioConfig.security ?: Config.SecurityConfig()
val formState = rememberConfigState(initialValue = securityConfig)
@@ -203,6 +208,28 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
onCheckedChange = { formState.value = formState.value.copy(is_managed = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle()
val tokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle()
LockdownModeSetting(
supported = capabilities.supportsLockdown,
lockdownState = lockdownState,
tokenInfo = tokenInfo,
connected = state.connected,
containerColor = CardDefaults.cardColors().containerColor,
onEnable = { passphrase, boots, hours, sessionMinutes ->
viewModel.submitLockdownPassphrase(
passphrase = passphrase,
boots = boots,
hours = hours,
maxSessionSeconds = sessionMinutes * SECONDS_PER_MINUTE,
)
},
onDisable = { passphrase ->
viewModel.submitLockdownPassphrase(passphrase = passphrase, disable = true)
},
onLockNow = { viewModel.sendLockNow() },
)
}
}
}
@@ -235,3 +262,5 @@ fun PrivateKeyRegenerateDialog(
)
}
}
private const val SECONDS_PER_MINUTE = 60

View File

@@ -52,6 +52,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
@@ -132,6 +133,7 @@ class ProfileRoundTripTest {
locationService = locationService,
fileService = fileService,
mqttManager = mqttManager,
lockdownCoordinator = FakeLockdownCoordinator(),
)
}

View File

@@ -60,6 +60,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
@@ -163,6 +164,7 @@ class RadioConfigViewModelTest {
locationService = locationService,
fileService = fileService,
mqttManager = mqttManager,
lockdownCoordinator = FakeLockdownCoordinator(),
)
@Test
@@ -557,6 +559,7 @@ class RadioConfigViewModelTest {
locationService = locationService,
fileService = fileService,
mqttManager = mqttManager,
lockdownCoordinator = FakeLockdownCoordinator(),
)
assertEquals(456, viewModel.destNode.value?.num)
}

View File

@@ -126,6 +126,7 @@ androidx-car-app-testing = { module = "androidx.car.app:app-testing", version.re
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.19.0" }
androidx-core-location-altitude = { module = "androidx.core:core-location-altitude", version = "1.0.0" }
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" }

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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<MeshConnectionManager> (breaks DI cycle)
UIViewModel / ConnectionsViewModel -> ServiceRepository.lockdownState
RadioConfigViewModel -> ServiceRepository.lockdownTokenInfo / sessionAuthorized
LockdownDialog -> UIViewModel.sendLockdownUnlock() / disconnect callback
SecurityConfigScreen -> RadioConfigViewModel.sendLockNow()
```

View File

@@ -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 <PR>` 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.

View File

@@ -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.

View File

@@ -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<LockdownState>` 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.

View File

@@ -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`

View File

@@ -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<LockdownState>` and `sessionAuthorized: StateFlow<Boolean>` 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