mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
986c60ce88
commit
e7ba8e8497
26 changed files with 753 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue