mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-25 22:15:33 -04:00
feat(lockdown): firmware lockdown mode (provision / unlock / lock-now) (#5939)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
30
.skills/compose-ui/strings-index.txt
generated
30
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"feature_directory": "specs/20260521-153452-car-app-library-integration"
|
||||
"feature_directory": "specs/20260513-075218-lockdown-mode"
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
62
specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md
Normal file
62
specs/20260513-075218-lockdown-mode/contracts/lockdown-ui.md
Normal 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
|
||||
77
specs/20260513-075218-lockdown-mode/data-model.md
Normal file
77
specs/20260513-075218-lockdown-mode/data-model.md
Normal 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()
|
||||
```
|
||||
103
specs/20260513-075218-lockdown-mode/plan.md
Normal file
103
specs/20260513-075218-lockdown-mode/plan.md
Normal 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.
|
||||
91
specs/20260513-075218-lockdown-mode/quickstart.md
Normal file
91
specs/20260513-075218-lockdown-mode/quickstart.md
Normal 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.
|
||||
108
specs/20260513-075218-lockdown-mode/research.md
Normal file
108
specs/20260513-075218-lockdown-mode/research.md
Normal 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.
|
||||
218
specs/20260513-075218-lockdown-mode/spec.md
Normal file
218
specs/20260513-075218-lockdown-mode/spec.md
Normal 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`
|
||||
213
specs/20260513-075218-lockdown-mode/tasks.md
Normal file
213
specs/20260513-075218-lockdown-mode/tasks.md
Normal 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
|
||||
Reference in New Issue
Block a user