feat(lockdown): implement coordinator and typed status dispatch

- CommandSenderImpl: build AdminMessage.lockdown_auth = LockdownAuth(...)
  for provision/unlock and lock_now=true for the lock command.
- FromRadioPacketHandlerImpl: route the new FromRadio.lockdown_status
  variant to the coordinator; also notify the coordinator on
  config_complete_id.
- MeshActionHandlerImpl: forward handleSendLockdownUnlock/handleSendLockNow
  to the coordinator.
- MeshConnectionManagerImpl: call coordinator.onConnect/onDisconnect; add
  clearRadioConfig to purge cached config after a lock-now ACK.
- ServiceRepositoryImpl: back the lockdownState/lockdownTokenInfo/
  sessionAuthorized flows.
- LockdownHandlerImpl: orchestration. Switches on LockdownStatus.State
  (NEEDS_PROVISION / LOCKED / UNLOCKED / UNLOCK_FAILED), auto-replays
  stored passphrase on LOCKED, clears stored passphrase on a fresh
  UNLOCK_FAILED, surfaces backoff_seconds on rate-limit. Tracks a
  wasLockNow flag locally so the next LOCKED status after a lock-now
  command is translated to LockdownState.LockNowAcknowledged for an
  immediate UI disconnect (the new schema has no explicit ACK type).
- LockdownPassphraseStore: per-device EncryptedSharedPreferences store
  for auto-unlock. Not biometric-gated by design.
- Add androidx.security:security-crypto dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
niccellular
2026-05-13 09:00:53 -04:00
parent 913c3c2ad1
commit f6e97d7ff7
9 changed files with 369 additions and 1 deletions
@@ -42,11 +42,13 @@ import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
import org.meshtastic.proto.Data
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LockdownAuth
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import kotlin.math.absoluteValue
import kotlin.random.Random
import kotlin.time.Duration.Companion.hours
@@ -355,6 +357,38 @@ class CommandSenderImpl(
}
}
override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int) {
val validUntilEpoch =
if (hours > 0) (nowMillis / 1000L + hours.toLong() * SECONDS_PER_HOUR).toInt() else 0
val lockdownAuth =
LockdownAuth(
passphrase = passphrase.encodeToByteArray().toByteString(),
boots_remaining = boots.coerceAtLeast(0),
valid_until_epoch = validUntilEpoch,
)
sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth))
}
override fun sendLockNow() {
sendLockdownAdmin(AdminMessage(lockdown_auth = LockdownAuth(lock_now = true)))
}
private fun sendLockdownAdmin(adminMessage: AdminMessage) {
val myNum = nodeManager.myNodeNum ?: return
val packet =
MeshPacket(
to = myNum,
id = generatePacketId(),
channel = 0,
want_ack = true,
hop_limit = DEFAULT_HOP_LIMIT,
hop_start = DEFAULT_HOP_LIMIT,
priority = MeshPacket.Priority.RELIABLE,
decoded = Data(portnum = PortNum.ADMIN_APP, payload = adminMessage.encode().toByteString()),
)
packetHandler.sendToRadio(ToRadio(packet = packet))
}
fun resolveNodeNum(toId: String): Int = when (toId) {
DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
else -> {
@@ -436,5 +470,7 @@ class CommandSenderImpl(
private const val HEX_RADIX = 16
private const val DEFAULT_HOP_LIMIT = 3
private const val SECONDS_PER_HOUR = 3600
}
}
@@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.Notification
@@ -37,6 +38,7 @@ class FromRadioPacketHandlerImpl(
private val mqttManager: MqttManager,
private val packetHandler: PacketHandler,
private val notificationManager: NotificationManager,
private val lockdownCoordinator: LockdownCoordinator,
) : FromRadioPacketHandler {
@Suppress("CyclomaticComplexMethod")
override fun handleFromRadio(proto: FromRadio) {
@@ -50,6 +52,7 @@ class FromRadioPacketHandlerImpl(
val moduleConfig = proto.moduleConfig
val channel = proto.channel
val clientNotification = proto.clientNotification
val lockdownStatus = proto.lockdown_status
when {
myInfo != null -> router.value.configFlowManager.handleMyInfo(myInfo)
@@ -58,12 +61,16 @@ class FromRadioPacketHandlerImpl(
router.value.configFlowManager.handleNodeInfo(nodeInfo)
serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})")
}
configCompleteId != null -> router.value.configFlowManager.handleConfigComplete(configCompleteId)
configCompleteId != null -> {
router.value.configFlowManager.handleConfigComplete(configCompleteId)
lockdownCoordinator.onConfigComplete()
}
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
queueStatus != null -> packetHandler.handleQueueStatus(queueStatus)
config != null -> router.value.configHandler.handleDeviceConfig(config)
moduleConfig != null -> router.value.configHandler.handleModuleConfig(moduleConfig)
channel != null -> router.value.configHandler.handleChannel(channel)
lockdownStatus != null -> lockdownCoordinator.handleLockdownStatus(lockdownStatus)
clientNotification != null -> {
serviceRepository.setClientNotification(clientNotification)
notificationManager.dispatch(
@@ -33,6 +33,7 @@ import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
@@ -63,6 +64,7 @@ class MeshActionHandlerImpl(
private val databaseManager: DatabaseManager,
private val notificationManager: NotificationManager,
private val messageProcessor: Lazy<MeshMessageProcessor>,
private val lockdownCoordinator: LockdownCoordinator,
) : MeshActionHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -351,4 +353,12 @@ class MeshActionHandlerImpl(
}
}
}
override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {
lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl)
}
override fun handleSendLockNow() {
lockdownCoordinator.lockNow()
}
}
@@ -38,6 +38,7 @@ import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshServiceNotifications
@@ -88,6 +89,7 @@ class MeshConnectionManagerImpl(
private val packetRepository: PacketRepository,
private val workerManager: MeshWorkerManager,
private val appWidgetUpdater: AppWidgetUpdater,
private val lockdownCoordinator: LockdownCoordinator,
) : MeshConnectionManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var sleepTimeout: Job? = null
@@ -182,6 +184,7 @@ class MeshConnectionManagerImpl(
serviceBroadcasts.broadcastConnection()
Logger.i { "Starting mesh handshake (Stage 1)" }
connectTimeMsec = nowMillis
lockdownCoordinator.onConnect()
startConfigOnly()
}
@@ -238,6 +241,7 @@ class MeshConnectionManagerImpl(
private fun handleDisconnected() {
serviceRepository.setConnectionState(ConnectionState.Disconnected)
lockdownCoordinator.onDisconnect()
packetHandler.stopPacketQueue()
locationManager.stop()
mqttManager.stop()
@@ -258,6 +262,14 @@ class MeshConnectionManagerImpl(
action()
}
override fun clearRadioConfig() {
scope.handledLaunch {
radioConfigRepository.clearLocalConfig()
radioConfigRepository.clearChannelSet()
radioConfigRepository.clearLocalModuleConfig()
}
}
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE)) }
startHandshakeStallGuard(2, action)
+1
View File
@@ -49,6 +49,7 @@ kotlin {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.security.crypto)
implementation(libs.koin.android)
implementation(libs.koin.androidx.workmanager)
}
@@ -0,0 +1,189 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.model.service.LockdownState
import org.meshtastic.core.model.service.LockdownTokenInfo
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.LockdownStatus
@Single(binds = [LockdownCoordinator::class])
class LockdownHandlerImpl(
private val serviceRepository: ServiceRepository,
private val commandSender: CommandSender,
private val passphraseStore: LockdownPassphraseStore,
private val radioInterfaceService: RadioInterfaceService,
) : LockdownCoordinator, KoinComponent {
private val connectionManager: MeshConnectionManager by inject()
@Volatile private var wasAutoAttempt = false
@Volatile private var wasLockNow = false
@Volatile private var pendingPassphrase: String? = null
@Volatile private var pendingBoots: Int = LockdownPassphraseStore.DEFAULT_BOOTS
@Volatile private var pendingHours: Int = 0
/** Called when the BLE connection is established, before the first config request. */
override fun onConnect() {
serviceRepository.setSessionAuthorized(false)
wasAutoAttempt = false
wasLockNow = false
pendingPassphrase = null
pendingBoots = LockdownPassphraseStore.DEFAULT_BOOTS
pendingHours = 0
}
/** Called when the BLE connection is lost. */
override fun onDisconnect() {
serviceRepository.setSessionAuthorized(false)
serviceRepository.setLockdownTokenInfo(null)
serviceRepository.setLockdownState(LockdownState.None)
wasAutoAttempt = false
wasLockNow = false
pendingPassphrase = null
}
/**
* Called on every config_complete_id. Once [sessionAuthorized] is true (set on UNLOCKED),
* this is a no-op preventing the startConfigOnly config_complete_id from triggering any
* further lockdown handling.
*/
override fun onConfigComplete() {
if (serviceRepository.sessionAuthorized.value) return
}
/** Routes typed firmware [LockdownStatus] to per-state handlers. */
override fun handleLockdownStatus(status: LockdownStatus) {
when (status.state) {
LockdownStatus.State.NEEDS_PROVISION -> handleNeedsProvision()
LockdownStatus.State.LOCKED -> handleLocked(status.lock_reason)
LockdownStatus.State.UNLOCKED -> handleUnlocked(status)
LockdownStatus.State.UNLOCK_FAILED -> handleUnlockFailed(status.backoff_seconds)
LockdownStatus.State.STATE_UNSPECIFIED -> Unit
}
}
private fun handleLockNowAcknowledged() {
Logger.i { "Lockdown: Lock Now acknowledged — resetting session authorization" }
serviceRepository.setSessionAuthorized(false)
wasAutoAttempt = false
wasLockNow = false
pendingPassphrase = null
// Purge cached config; fresh config is loaded after successful re-authentication.
connectionManager.clearRadioConfig()
// Signal the UI to disconnect — no dialog, just drop the connection.
serviceRepository.setLockdownState(LockdownState.LockNowAcknowledged)
}
private fun handleLocked(lockReason: String) {
if (wasLockNow) {
handleLockNowAcknowledged()
return
}
val deviceAddress = radioInterfaceService.getDeviceAddress()
if (deviceAddress != null) {
val stored = passphraseStore.getPassphrase(deviceAddress)
if (stored != null) {
Logger.i { "Lockdown: Auto-unlocking (reason=$lockReason) with stored passphrase for $deviceAddress" }
wasAutoAttempt = true
commandSender.sendLockdownPassphrase(stored.passphrase, stored.boots, stored.hours)
return
}
}
serviceRepository.setLockdownState(LockdownState.Locked(lockReason))
}
private fun handleNeedsProvision() {
serviceRepository.setLockdownState(LockdownState.NeedsProvision)
}
private fun handleUnlocked(status: LockdownStatus) {
val deviceAddress = radioInterfaceService.getDeviceAddress()
val passphrase = pendingPassphrase
if (deviceAddress != null && passphrase != null) {
passphraseStore.savePassphrase(deviceAddress, passphrase, pendingBoots, pendingHours)
Logger.i { "Lockdown: Saved passphrase for $deviceAddress" }
}
pendingPassphrase = null
serviceRepository.setLockdownTokenInfo(
LockdownTokenInfo(
bootsRemaining = status.boots_remaining,
expiryEpoch = status.valid_until_epoch.toLong() and UINT32_MASK,
),
)
serviceRepository.setLockdownState(LockdownState.Unlocked)
// Mark session authorized BEFORE calling startConfigOnly(). When the resulting
// config_complete_id arrives, onConfigComplete() will see sessionAuthorized=true and
// return immediately — no passphrase re-send, no loop.
serviceRepository.setSessionAuthorized(true)
connectionManager.startConfigOnly()
}
private fun handleUnlockFailed(backoffSeconds: Int) {
pendingPassphrase = null
if (wasAutoAttempt) {
wasAutoAttempt = false
if (backoffSeconds > 0) {
Logger.i { "Lockdown: Auto-unlock rate-limited (backoff=${backoffSeconds}s)" }
serviceRepository.setLockdownState(LockdownState.UnlockBackoff(backoffSeconds))
} else {
val deviceAddress = radioInterfaceService.getDeviceAddress()
if (deviceAddress != null) {
passphraseStore.clearPassphrase(deviceAddress)
Logger.i { "Lockdown: Auto-unlock failed (wrong passphrase), cleared stored passphrase for $deviceAddress" }
}
serviceRepository.setLockdownState(LockdownState.Locked())
}
return
}
if (backoffSeconds > 0) {
Logger.i { "Lockdown: Unlock failed with backoff of ${backoffSeconds}s" }
serviceRepository.setLockdownState(LockdownState.UnlockBackoff(backoffSeconds))
} else {
serviceRepository.setLockdownState(LockdownState.UnlockFailed)
}
}
override fun submitPassphrase(passphrase: String, boots: Int, hours: Int) {
pendingPassphrase = passphrase
pendingBoots = boots
pendingHours = hours
wasAutoAttempt = false
wasLockNow = false
serviceRepository.setLockdownState(LockdownState.None) // hide dialog while awaiting response
commandSender.sendLockdownPassphrase(passphrase, boots, hours)
}
override fun lockNow() {
wasLockNow = true
commandSender.sendLockNow()
}
companion object {
private const val UINT32_MASK = 0xFFFFFFFFL
}
}
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <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 org.koin.core.annotation.Single
data class StoredPassphrase(
val passphrase: String,
val boots: Int,
val hours: Int,
)
/**
* Encrypted per-device storage for lockdown passphrases.
*
* Uses EncryptedSharedPreferences backed by an AES-256-GCM MasterKey (hardware keystore when
* available). The key is intentionally NOT gated behind biometric authentication so that
* auto-unlock can run in the background without user interaction.
*/
@Single
class LockdownPassphraseStore(app: Application) {
private val prefs: SharedPreferences by lazy {
val masterKey =
MasterKey.Builder(app).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
EncryptedSharedPreferences.create(
app,
PREFS_FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun getPassphrase(deviceAddress: String): StoredPassphrase? {
val key = sanitizeKey(deviceAddress)
val passphrase = prefs.getString("${key}_passphrase", null) ?: return null
val boots = prefs.getInt("${key}_boots", DEFAULT_BOOTS)
val hours = prefs.getInt("${key}_hours", 0)
return StoredPassphrase(passphrase, boots, hours)
}
fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) {
val key = sanitizeKey(deviceAddress)
prefs
.edit()
.putString("${key}_passphrase", passphrase)
.putInt("${key}_boots", boots)
.putInt("${key}_hours", hours)
.apply()
}
fun clearPassphrase(deviceAddress: String) {
val key = sanitizeKey(deviceAddress)
prefs.edit().remove("${key}_passphrase").remove("${key}_boots").remove("${key}_hours").apply()
}
private fun sanitizeKey(address: String): String = address.replace(":", "_")
companion object {
private const val PREFS_FILE_NAME = "lockdown_passphrase_store"
const val DEFAULT_BOOTS = 50
}
}
@@ -26,6 +26,8 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.service.LockdownState
import org.meshtastic.core.model.service.LockdownTokenInfo
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.repository.ServiceRepository
@@ -125,4 +127,32 @@ open class ServiceRepositoryImpl : ServiceRepository {
override suspend fun onServiceAction(action: ServiceAction) {
_serviceAction.send(action)
}
private val _lockdownState = MutableStateFlow<LockdownState>(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
}
}
+1
View File
@@ -84,6 +84,7 @@ androidx-camera-viewfinder-compose = { module = "androidx.camera.viewfinder:view
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.18.0" }
androidx-core-location-altitude = { module = "androidx.core:core-location-altitude", version = "1.0.0-rc01" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version = "1.2.0" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version = "1.1.0-alpha06" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }