feature: Add TAK passphrase lock/unlock support

Implement the client-side TAK passphrase authentication flow for
  devices running TAK-locked firmware.

  Key components:
  - TakPassphraseStore: per-device passphrase persistence using
    EncryptedSharedPreferences (Android Keystore AES-256-GCM), with
    boot and hour TTL fields stored alongside the passphrase
  - TakLockHandler: orchestrates the full lock/unlock lifecycle —
    auto-unlock on reconnect using stored credentials, passphrase
    submission, token info parsing, and backoff/failure handling
  - MeshCommandSender: sendTakPassphrase() and sendTakLockNow() build
    plain local packets that bypass PKC signing and session_passkey;
    hour TTL is encoded as an absolute Unix epoch as required by firmware
  - ServiceRepository: TakLockState sealed class (None, Locked,
    NeedsProvision, Unlocked, LockNowAcknowledged, UnlockFailed,
    UnlockBackoff), TakTokenInfo (boots remaining + expiry epoch), and
    sessionAuthorized flag
  - TakUnlockDialog: Compose dialog for passphrase entry, shown on
    Locked and NeedsProvision states; onDismissRequest is a no-op to
    prevent race conditions with firmware response timing; cancel
    disconnects the user and navigates to the Connections tab
  - Lock Now (Security settings): immediately disconnects the client
    after informing firmware, purges cached config, navigates away
    without showing a passphrase dialog
  - ConnectionsScreen: suppress "region unset" prompt while the device
    is TAK-locked, since pre-auth config is zeroed/redacted and would
    lead the user to a blank LoRa settings screen
  - AIDL: sendTakUnlock() and sendTakLockNow() wired through
    MeshService → MeshActionHandler → TakLockHandler
  - Security settings: "Lock Now (TAK)" button and token info display
    showing boots remaining and expiry date
This commit is contained in:
niccellular
2026-02-27 08:31:05 -05:00
parent 986c60ce88
commit e7ba8e8497
26 changed files with 753 additions and 8 deletions

View File

@@ -243,6 +243,7 @@ dependencies {
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
implementation(libs.accompanist.permissions)
implementation(libs.androidx.security.crypto)
implementation(libs.kermit)
implementation(libs.nordic.client.android)

View File

@@ -58,6 +58,8 @@ import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TakLockState
import org.meshtastic.core.service.TakTokenInfo
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
@@ -69,6 +71,9 @@ import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
private const val DEFAULT_BOOT_TTL = 50
// Given a human name, strip out the first letter of the first three words and return that as the
// initials for
// that user, ignoring emojis. If the original name is only one word, strip vowels from the original
@@ -127,6 +132,21 @@ constructor(
meshServiceNotifications.clearClientNotification(notification)
}
val takLockState: StateFlow<TakLockState> = serviceRepository.takLockState
val takTokenInfo: StateFlow<TakTokenInfo?> = serviceRepository.takTokenInfo
fun sendTakUnlock(passphrase: String, bootTtl: Int = DEFAULT_BOOT_TTL, hourTtl: Int = 0) {
serviceRepository.meshService?.sendTakUnlock(passphrase, bootTtl, hourTtl)
}
fun sendTakLockNow() {
serviceRepository.meshService?.sendTakLockNow()
}
fun clearTakLockState() {
serviceRepository.clearTakLockState()
}
/**
* Emits events for mesh network send/receive activity. This is a SharedFlow to ensure all events are delivered,
* even if they are the same.

View File

@@ -57,15 +57,23 @@ constructor(
router.configFlowManager.handleNodeInfo(nodeInfo)
serviceRepository.setStatusMessage("Nodes (${router.configFlowManager.newNodeCount})")
}
configCompleteId != null -> router.configFlowManager.handleConfigComplete(configCompleteId)
configCompleteId != null -> {
router.configFlowManager.handleConfigComplete(configCompleteId)
router.takLockHandler.onConfigComplete()
}
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
queueStatus != null -> packetHandler.handleQueueStatus(queueStatus)
config != null -> router.configHandler.handleDeviceConfig(config)
moduleConfig != null -> router.configHandler.handleModuleConfig(moduleConfig)
channel != null -> router.configHandler.handleChannel(channel)
clientNotification != null -> {
serviceRepository.setClientNotification(clientNotification)
serviceNotifications.showClientNotification(clientNotification)
val msg = clientNotification.message
if (msg.startsWith("TAK_")) {
router.takLockHandler.handleTakNotification(msg)
} else {
serviceRepository.setClientNotification(clientNotification)
serviceNotifications.showClientNotification(clientNotification)
}
packetHandler.removeResponse(clientNotification.reply_id ?: 0, complete = false)
}
// Logging-only variants are handled by MeshMessageProcessor before dispatching here

View File

@@ -59,6 +59,7 @@ constructor(
private val databaseManager: DatabaseManager,
private val serviceNotifications: MeshServiceNotifications,
private val messageProcessor: Lazy<MeshMessageProcessor>,
private val takLockHandler: TakLockHandler,
) {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -330,6 +331,14 @@ constructor(
}
}
fun handleSendTakUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {
takLockHandler.submitPassphrase(passphrase, bootTtl, hourTtl)
}
fun handleSendTakLockNow() {
takLockHandler.lockNow()
}
fun handleUpdateLastAddress(deviceAddr: String?) {
val currentAddr = meshPrefs.deviceAddress
if (deviceAddr != currentAddr) {

View File

@@ -36,6 +36,7 @@ import org.meshtastic.core.model.util.isWithinSizeLimit
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.Constants
import org.meshtastic.proto.Data
import org.meshtastic.proto.LocalConfig
@@ -44,6 +45,7 @@ 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 java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicLong
@@ -467,6 +469,68 @@ constructor(
),
)
fun sendTakPassphrase(passphrase: String, boots: Int = 0, hours: Int = 0) {
val myNum = nodeManager?.myNodeNum ?: return
// The firmware expects slot 2 as an absolute Unix epoch (seconds), not a duration.
// Convert hours duration → absolute epoch; 0 hours means no time-based expiry (until=0).
val adminKeyList = if (boots > 0 || hours > 0) {
val untilEpoch = if (hours > 0) System.currentTimeMillis() / 1000L + hours.toLong() * 3600L else 0L
val untilBytes = ByteArray(INT_BYTE_SIZE)
untilBytes[0] = (untilEpoch and BYTE_MASK.toLong()).toByte()
untilBytes[1] = ((untilEpoch shr BYTE_BITS) and BYTE_MASK.toLong()).toByte()
untilBytes[2] = ((untilEpoch shr (BYTE_BITS * 2)) and BYTE_MASK.toLong()).toByte()
untilBytes[3] = ((untilEpoch shr (BYTE_BITS * 3)) and BYTE_MASK.toLong()).toByte()
listOf(
ByteString.EMPTY, // slot 0 unused
ByteString.of(boots.coerceIn(1, MAX_BYTE_VALUE).toByte()), // slot 1: boots u8
untilBytes.toByteString(), // slot 2: until epoch LE u32
)
} else {
emptyList()
}
val securityConfig = Config.SecurityConfig(
private_key = passphrase.encodeToByteArray().toByteString(),
admin_key = adminKeyList,
)
val adminMessage = AdminMessage(set_config = Config(security = securityConfig))
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 sendTakLockNow() {
val myNum = nodeManager?.myNodeNum ?: return
val securityConfig = Config.SecurityConfig(
private_key = ByteString.of(TAK_LOCK_BYTE),
)
val adminMessage = AdminMessage(set_config = Config(security = securityConfig))
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))
}
companion object {
private const val PACKET_ID_MASK = 0xffffffffL
private const val PACKET_ID_SHIFT_BITS = 32
@@ -478,5 +542,12 @@ constructor(
private const val HEX_RADIX = 16
private const val DEFAULT_HOP_LIMIT = 3
private const val MAX_BYTE_VALUE = 255
private const val INT_BYTE_SIZE = 4
private const val BYTE_MASK = 0xFF
private const val BYTE_BITS = 8
@Suppress("MagicNumber")
private val TAK_LOCK_BYTE = 0xFF.toByte()
}
}

View File

@@ -30,6 +30,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import dagger.Lazy
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.NodeRepository
@@ -69,6 +70,7 @@ constructor(
private val commandSender: MeshCommandSender,
private val nodeManager: MeshNodeManager,
private val analytics: PlatformAnalytics,
private val takLockHandler: Lazy<TakLockHandler>,
) {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var sleepTimeout: Job? = null
@@ -142,6 +144,7 @@ constructor(
Logger.d { "Starting connect" }
connectTimeMsec = System.currentTimeMillis()
scope.handledLaunch { nodeRepository.clearMyNodeInfo() }
takLockHandler.get().onConnect()
startConfigOnly()
}
@@ -180,6 +183,7 @@ constructor(
private fun handleDisconnected() {
connectionStateHolder.setState(ConnectionState.Disconnected)
takLockHandler.get().onDisconnect()
packetHandler.stopPacketQueue()
locationManager.stop()
mqttManager.stop()
@@ -194,6 +198,14 @@ constructor(
serviceBroadcasts.broadcastConnection()
}
fun clearRadioConfig() {
scope.handledLaunch {
radioConfigRepository.clearLocalConfig()
radioConfigRepository.clearChannelSet()
radioConfigRepository.clearLocalModuleConfig()
}
}
fun startConfigOnly() {
packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE))
}

View File

@@ -36,6 +36,7 @@ constructor(
val configFlowManager: MeshConfigFlowManager,
val mqttManager: MeshMqttManager,
val actionHandler: MeshActionHandler,
val takLockHandler: TakLockHandler,
) {
fun start(scope: CoroutineScope) {
dataHandler.start(scope)

View File

@@ -378,5 +378,13 @@ class MeshService : Service() {
toRemoteExceptions {
router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash)
}
override fun sendTakUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) = toRemoteExceptions {
router.actionHandler.handleSendTakUnlock(passphrase, bootTtl, hourTtl)
}
override fun sendTakLockNow() = toRemoteExceptions {
router.actionHandler.handleSendTakLockNow()
}
}
}

View File

@@ -0,0 +1,204 @@
/*
* 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 com.geeksville.mesh.service
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.Lazy
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TakLockState
import org.meshtastic.core.service.TakTokenInfo
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TakLockHandler @Inject constructor(
private val serviceRepository: ServiceRepository,
private val commandSender: MeshCommandSender,
private val passphraseStore: TakPassphraseStore,
private val radioInterfaceService: RadioInterfaceService,
private val connectionManager: Lazy<MeshConnectionManager>,
) {
@Volatile private var wasAutoAttempt = false
@Volatile private var pendingPassphrase: String? = null
@Volatile private var pendingBoots: Int = TakPassphraseStore.DEFAULT_BOOTS
@Volatile private var pendingHours: Int = 0
/** Called when the BLE connection is established, before the first config request. */
fun onConnect() {
serviceRepository.setSessionAuthorized(false)
wasAutoAttempt = false
pendingPassphrase = null
pendingBoots = TakPassphraseStore.DEFAULT_BOOTS
pendingHours = 0
}
/** Called when the BLE connection is lost. */
fun onDisconnect() {
serviceRepository.setSessionAuthorized(false)
serviceRepository.setTakTokenInfo(null)
serviceRepository.setTakLockState(TakLockState.None)
wasAutoAttempt = false
pendingPassphrase = null
}
/**
* Called on every config_complete_id. Once [sessionAuthorized] is true (set on TAK_UNLOCKED),
* this is a no-op — preventing the startConfigOnly config_complete_id from triggering any
* further TAK handling. The dialog state is driven entirely by clientNotifications.
*/
fun onConfigComplete() {
// Session already authenticated — this config_complete_id is from the startConfigOnly()
// issued after TAK_UNLOCKED. Nothing to do.
if (serviceRepository.sessionAuthorized.value) return
}
/**
* Routes incoming TAK clientNotification messages:
* - TAK_NEEDS_PROVISION → device has no passphrase → show "Set Passphrase" dialog
* - TAK_LOCKED:<reason> → device is locked → auto-unlock with stored passphrase or show dialog
* - TAK_UNLOCKED → accepted; save passphrase, authorize session, re-sync config
* - TAK_UNLOCK_FAILED → wrong passphrase; clear stored or increment retry counter
*/
fun handleTakNotification(message: String?) {
when {
message == TAK_NEEDS_PROVISION -> handleNeedsProvision()
// Exact "TAK_LOCKED" = Lock Now was acknowledged by the device → re-lock the session.
// "TAK_LOCKED:<reason>" (with colon) = connect-time lock → try auto-unlock or show dialog.
message == TAK_LOCKED_ACK -> handleLockNowAcknowledged()
message != null && message.startsWith(TAK_LOCKED_WITH_REASON_PREFIX) -> handleLocked()
message != null && message.startsWith(TAK_UNLOCKED_PREFIX) -> handleUnlocked(message)
message != null && message.startsWith(TAK_UNLOCK_FAILED_PREFIX) -> handleUnlockFailed(message)
}
}
private fun handleLockNowAcknowledged() {
Logger.i { "TAK: Lock Now acknowledged — resetting session authorization" }
serviceRepository.setSessionAuthorized(false)
// Do NOT clear takTokenInfo here — keep it so the dialog pre-fills with the last-known
// TTL values. It is refreshed by the next TAK_UNLOCKED response.
wasAutoAttempt = false
pendingPassphrase = null
// Immediately purge the cached config — it's stale from the authenticated session.
// The fresh config is loaded in handleUnlocked() after successful re-authentication.
connectionManager.get().clearRadioConfig()
// Signal the UI to disconnect — no dialog, just drop the connection.
serviceRepository.setTakLockState(TakLockState.LockNowAcknowledged)
}
private fun handleLocked() {
val deviceAddress = radioInterfaceService.getDeviceAddress()
if (deviceAddress != null) {
val stored = passphraseStore.getPassphrase(deviceAddress)
if (stored != null) {
Logger.i { "TAK: Auto-unlocking (TAK_LOCKED) with stored passphrase for $deviceAddress" }
wasAutoAttempt = true
commandSender.sendTakPassphrase(stored.passphrase, stored.boots, stored.hours)
return
}
}
serviceRepository.setTakLockState(TakLockState.Locked)
}
private fun handleNeedsProvision() {
serviceRepository.setTakLockState(TakLockState.NeedsProvision)
}
private fun handleUnlocked(message: String) {
val deviceAddress = radioInterfaceService.getDeviceAddress()
val passphrase = pendingPassphrase
if (deviceAddress != null && passphrase != null) {
passphraseStore.savePassphrase(deviceAddress, passphrase, pendingBoots, pendingHours)
Logger.i { "TAK: Saved passphrase for $deviceAddress" }
}
pendingPassphrase = null
serviceRepository.setTakTokenInfo(parseTokenInfo(message))
serviceRepository.setTakLockState(TakLockState.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.get().startConfigOnly()
}
/** Parses boots= and until= fields from TAK_UNLOCKED:boots=N:until=EPOCH: */
private fun parseTokenInfo(message: String): TakTokenInfo? {
var boots = -1
var until = 0L
for (segment in message.split(":")) {
when {
segment.startsWith("boots=") -> boots = segment.removePrefix("boots=").toIntOrNull() ?: -1
segment.startsWith("until=") -> until = segment.removePrefix("until=").toLongOrNull() ?: 0L
}
}
return if (boots >= 0) TakTokenInfo(boots, until) else null
}
private fun handleUnlockFailed(message: String) {
pendingPassphrase = null
// Parse backoff=N first — applies to both auto and manual attempts.
val backoffSeconds = message.split(":").firstNotNullOfOrNull { segment ->
if (segment.startsWith("backoff=")) segment.removePrefix("backoff=").toIntOrNull() else null
}
if (wasAutoAttempt) {
wasAutoAttempt = false
if (backoffSeconds != null && backoffSeconds > 0) {
// Rate-limited — stored passphrase may still be correct; keep it and show countdown.
Logger.i { "TAK: Auto-unlock rate-limited (backoff=${backoffSeconds}s)" }
serviceRepository.setTakLockState(TakLockState.UnlockBackoff(backoffSeconds))
} else {
// Wrong passphrase — clear stored passphrase.
val deviceAddress = radioInterfaceService.getDeviceAddress()
if (deviceAddress != null) {
passphraseStore.clearPassphrase(deviceAddress)
Logger.i { "TAK: Auto-unlock failed (wrong passphrase), cleared stored passphrase for $deviceAddress" }
}
serviceRepository.setTakLockState(TakLockState.Locked)
}
return
}
// Manual attempt.
if (backoffSeconds != null && backoffSeconds > 0) {
Logger.i { "TAK: Unlock failed with backoff of ${backoffSeconds}s" }
serviceRepository.setTakLockState(TakLockState.UnlockBackoff(backoffSeconds))
} else {
serviceRepository.setTakLockState(TakLockState.UnlockFailed)
}
}
fun submitPassphrase(passphrase: String, boots: Int, hours: Int) {
pendingPassphrase = passphrase
pendingBoots = boots
pendingHours = hours
wasAutoAttempt = false
serviceRepository.setTakLockState(TakLockState.None) // hide dialog while awaiting response
commandSender.sendTakPassphrase(passphrase, boots, hours)
}
fun lockNow() {
commandSender.sendTakLockNow()
}
companion object {
private const val TAK_LOCKED_ACK = "TAK_LOCKED" // exact: Lock Now ACK
private const val TAK_LOCKED_WITH_REASON_PREFIX = "TAK_LOCKED:" // connect-time lock
private const val TAK_NEEDS_PROVISION = "TAK_NEEDS_PROVISION"
private const val TAK_UNLOCKED_PREFIX = "TAK_UNLOCKED"
private const val TAK_UNLOCK_FAILED_PREFIX = "TAK_UNLOCK_FAILED"
}
}

View File

@@ -0,0 +1,80 @@
/*
* 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 com.geeksville.mesh.service
import android.app.Application
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import javax.inject.Inject
import javax.inject.Singleton
data class StoredPassphrase(
val passphrase: String,
val boots: Int,
val hours: Int,
)
@Singleton
class TakPassphraseStore @Inject constructor(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 = "tak_passphrase_store"
const val DEFAULT_BOOTS = 50
}
}

View File

@@ -113,6 +113,7 @@ import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.TakLockState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.app_too_old
import org.meshtastic.core.strings.bottom_nav_settings
@@ -217,8 +218,37 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
}
}
val takLockState by uIViewModel.takLockState.collectAsStateWithLifecycle()
val takTokenInfo by uIViewModel.takTokenInfo.collectAsStateWithLifecycle()
LaunchedEffect(takLockState) {
if (takLockState is TakLockState.LockNowAcknowledged) {
uIViewModel.clearTakLockState()
scanModel.disconnect()
navController.navigate(TopLevelDestination.Connections.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
}
TakUnlockDialog(
takLockState = takLockState,
takTokenInfo = takTokenInfo,
onSubmit = { pass, boots, hours -> uIViewModel.sendTakUnlock(pass, boots, hours) },
onDismiss = {
uIViewModel.clearTakLockState()
scanModel.disconnect()
navController.navigate(TopLevelDestination.Connections.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
)
val clientNotification by uIViewModel.clientNotification.collectAsStateWithLifecycle()
clientNotification?.let { notification ->
if (notification.message?.startsWith("TAK_") == true) return@let
var message = notification.message
val compromisedKeys =
if (notification.low_entropy_key != null || notification.duplicated_public_key != null) {

View File

@@ -0,0 +1,182 @@
/*
* 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 com.geeksville.mesh.ui
import androidx.activity.compose.BackHandler
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.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
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.meshtastic.core.service.TakLockState
import org.meshtastic.core.service.TakTokenInfo
@Suppress("LongMethod")
@Composable
fun TakUnlockDialog(
takLockState: TakLockState,
takTokenInfo: TakTokenInfo? = null,
onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit,
onDismiss: () -> Unit,
) {
val shouldShow = when (takLockState) {
is TakLockState.Locked -> true
is TakLockState.NeedsProvision -> true
is TakLockState.UnlockFailed -> true
is TakLockState.UnlockBackoff -> true
else -> false
}
BackHandler(enabled = shouldShow, onBack = onDismiss)
if (!shouldShow) return
var passphrase by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
// Pre-fill from most recent TAK_UNLOCKED token info when available.
val initialBoots = takTokenInfo?.bootsRemaining ?: DEFAULT_BOOTS
val initialHours = if ((takTokenInfo?.expiryEpoch ?: 0L) > 0L) {
((takTokenInfo!!.expiryEpoch - System.currentTimeMillis() / 1000) / 3600)
.toInt().coerceAtLeast(0)
} else {
0
}
var boots by rememberSaveable { mutableIntStateOf(initialBoots) }
var hours by rememberSaveable { mutableIntStateOf(initialHours) }
val isProvisioning = takLockState is TakLockState.NeedsProvision
val title = if (isProvisioning) "Set Passphrase" else "Enter Passphrase"
val isValid = passphrase.isNotEmpty() && passphrase.length <= MAX_PASSPHRASE_LEN
AlertDialog(
onDismissRequest = {},
title = { Text(text = title) },
text = {
Column {
when (takLockState) {
is TakLockState.UnlockFailed -> {
Text(
text = "Incorrect passphrase.",
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
}
is TakLockState.UnlockBackoff -> {
Text(
text = "Try again in ${takLockState.backoffSeconds} seconds.",
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
}
else -> {}
}
OutlinedTextField(
value = passphrase,
onValueChange = { if (it.length <= MAX_PASSPHRASE_LEN) passphrase = it },
label = { Text("Passphrase") },
singleLine = true,
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = if (passwordVisible) "Hide" else "Show",
)
}
},
modifier = Modifier.fillMaxWidth(),
)
// Boot/Hour TTL fields always shown — operator can renew the token window on every unlock
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("Boot TTL") },
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("Hour TTL") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
}
}
},
confirmButton = {
TextButton(
onClick = { onSubmit(passphrase, boots, hours) },
enabled = isValid,
) {
Text("Submit")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
private const val DEFAULT_BOOTS = 50
private const val MAX_PASSPHRASE_LEN = 64
private const val MAX_BYTE_VALUE = 255
private const val SPACING_DP = 8

View File

@@ -70,6 +70,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.TakLockState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connected_device
@@ -125,7 +126,10 @@ fun ConnectionsScreen(
val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val bluetoothState by connectionsViewModel.bluetoothState.collectAsStateWithLifecycle()
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
val takLockState by connectionsViewModel.takLockState.collectAsStateWithLifecycle()
// A TAK-locked device sends zeroed config before auth — suppress region-unset until authorized.
val isTakAuthorized = takLockState == TakLockState.None || takLockState == TakLockState.Unlocked
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET && isTakAuthorized
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()

View File

@@ -48,6 +48,8 @@ constructor(
val connectionState = serviceRepository.connectionState
val takLockState = serviceRepository.takLockState
val myNodeInfo: StateFlow<MyNodeEntity?> = nodeRepository.myNodeInfo
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo

View File

@@ -189,4 +189,10 @@ interface IMeshService {
* hash is the 32-byte firmware SHA256 hash (optional, can be null)
*/
void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash);
/// Send TAK unlock passphrase to the device
void sendTakUnlock(in String passphrase, in int bootTtl, in int hourTtl);
/// Lock the device with TAK lock immediately
void sendTakLockNow();
}

View File

@@ -28,9 +28,34 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
sealed class TakLockState {
data object None : TakLockState()
data object Locked : TakLockState()
data object NeedsProvision : TakLockState()
data object Unlocked : TakLockState()
/** Lock Now ACK received — client should disconnect immediately, no dialog. */
data object LockNowAcknowledged : TakLockState()
/** Wrong passphrase — retry immediately. */
data object UnlockFailed : TakLockState()
/** Too many attempts — must wait [backoffSeconds] before retrying. */
data class UnlockBackoff(val backoffSeconds: Int) : TakLockState()
}
/**
* TAK session token metadata parsed from the TAK_UNLOCKED:boots=N:until=EPOCH: notification.
*
* @param bootsRemaining Number of reboots before the token expires.
* @param expiryEpoch Unix epoch seconds; 0 means no time-based expiry.
*/
data class TakTokenInfo(
val bootsRemaining: Int,
val expiryEpoch: Long,
)
sealed class RetryEvent {
abstract val packetId: Int
abstract val attemptNumber: Int
@@ -159,6 +184,38 @@ class ServiceRepository @Inject constructor() {
_serviceAction.send(action)
}
// TAK lock state
private val _takLockState: MutableStateFlow<TakLockState> = MutableStateFlow(TakLockState.None)
val takLockState: StateFlow<TakLockState>
get() = _takLockState
fun setTakLockState(state: TakLockState) {
_takLockState.value = state
}
fun clearTakLockState() {
_takLockState.value = TakLockState.None
}
// TAK token info (boots remaining + expiry) from the most recent TAK_UNLOCKED notification
private val _takTokenInfo: MutableStateFlow<TakTokenInfo?> = MutableStateFlow(null)
val takTokenInfo: StateFlow<TakTokenInfo?>
get() = _takTokenInfo
fun setTakTokenInfo(info: TakTokenInfo?) {
_takTokenInfo.value = info
}
// True once TAK passphrase is accepted for this BLE connection; false on disconnect.
private val _sessionAuthorized: MutableStateFlow<Boolean> = MutableStateFlow(false)
val sessionAuthorized: StateFlow<Boolean>
get() = _sessionAuthorized
fun setSessionAuthorized(authorized: Boolean) {
_sessionAuthorized.value = authorized
}
// Retry management
private val _retryEvents = MutableStateFlow<RetryEvent?>(null)
val retryEvents: StateFlow<RetryEvent?>

View File

@@ -120,4 +120,8 @@ open class FakeIMeshService : IMeshService.Stub() {
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {}
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
override fun sendTakUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) {}
override fun sendTakLockNow() {}
}

View File

@@ -117,4 +117,8 @@ open class FakeIMeshService : IMeshService.Stub() {
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {}
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
override fun sendTakUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) {}
override fun sendTakLockNow() {}
}

View File

@@ -135,6 +135,7 @@ fun SettingsScreen(
) {
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
val sessionAuthorized by settingsViewModel.sessionAuthorized.collectAsStateWithLifecycle()
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false)
val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle()
@@ -247,7 +248,7 @@ fun SettingsScreen(
Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp)) {
RadioConfigItemList(
state = state,
isManaged = localConfig.security?.is_managed ?: false,
isManaged = (localConfig.security?.is_managed ?: false) && !sessionAuthorized,
node = destNode,
excludedModulesUnlocked = excludedModulesUnlocked,
isOtaCapable = isOtaCapable,

View File

@@ -99,6 +99,9 @@ constructor(
val localConfig: StateFlow<LocalConfig> =
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
val sessionAuthorized: StateFlow<Boolean> =
serviceRepository.sessionAuthorized.stateInWhileSubscribed(initialValue = false)
val meshService: IMeshService?
get() = serviceRepository.meshService

View File

@@ -147,6 +147,12 @@ constructor(
private val _radioConfigState = MutableStateFlow(RadioConfigState())
val radioConfigState: StateFlow<RadioConfigState> = _radioConfigState
fun sendTakLockNow() {
meshService?.sendTakLockNow()
}
val takTokenInfo = serviceRepository.takTokenInfo
fun setPreserveFavorites(preserveFavorites: Boolean) {
viewModelScope.launch { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } }
}

View File

@@ -108,7 +108,7 @@ private fun ChannelConfigScreen(
enabled: Boolean,
onPositiveClicked: (List<ChannelSettings>) -> Unit,
) {
val primarySettings = settingsList.getOrNull(0) ?: return
val primarySettings = settingsList.getOrNull(0) ?: ChannelSettings()
val modemPresetName by remember(loraConfig) { mutableStateOf(Channel(loraConfig = loraConfig).name) }
val primaryChannel by remember(loraConfig) { mutableStateOf(Channel(primarySettings, loraConfig)) }
val capabilities by remember(firmwareVersion) { mutableStateOf(Capabilities(firmwareVersion)) }

View File

@@ -62,13 +62,14 @@ import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.hopLimits
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
@Composable
fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val loraConfig = state.radioConfig.lora ?: Config.LoRaConfig()
val primarySettings = state.channelList.getOrNull(0) ?: return
val primarySettings = state.channelList.getOrNull(0) ?: ChannelSettings()
val formState = rememberConfigState(initialValue = loraConfig)
val primaryChannel by remember(formState.value) { mutableStateOf(Channel(primarySettings, formState.value)) }

View File

@@ -28,6 +28,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -41,6 +42,9 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
@@ -83,6 +87,7 @@ import java.security.SecureRandom
@Composable
fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val takTokenInfo by viewModel.takTokenInfo.collectAsStateWithLifecycle(initialValue = null)
val node by viewModel.destNode.collectAsStateWithLifecycle()
val securityConfig = state.radioConfig.security ?: Config.SecurityConfig()
val formState = rememberConfigState(initialValue = securityConfig)
@@ -255,6 +260,31 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
onCheckedChange = { formState.value = formState.value.copy(admin_channel_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
NodeActionButton(
modifier = Modifier.padding(horizontal = 8.dp),
title = "Lock Now",
enabled = state.connected,
icon = Icons.TwoTone.Warning,
onClick = { viewModel.sendTakLockNow() },
)
takTokenInfo?.let { token ->
HorizontalDivider()
val expiryMs = token.expiryEpoch * 1000L
val expiryText = when {
expiryMs <= 0L -> "no time limit"
expiryMs <= System.currentTimeMillis() -> "expired"
else -> {
val fmt = SimpleDateFormat("MMM d yyyy", Locale.getDefault())
"expires ${fmt.format(Date(expiryMs))}"
}
}
Text(
text = "Token: ${token.bootsRemaining} boots remaining, $expiryText",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
}
}

View File

@@ -97,6 +97,7 @@ androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "ro
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", version.ref = "savedstate" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version = "1.1.0-alpha06" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
# AndroidX Compose