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

@ -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),
)
}
}
}
}