feat(lockdown): thread max_session_seconds through coordinator and UI

End-to-end plumbing for LockdownAuth.max_session_seconds (per-boot
uptime cap on the unlocked session; 0 = unlimited).

Wire:
- CommandSenderImpl populates LockdownAuth.max_session_seconds in the
  outbound admin packet (clamped non-negative).

Coordinator + persistence:
- LockdownCoordinator.submitPassphrase gains optional maxSessionSeconds
  (default 0); persisted alongside boots/hours and replayed by
  auto-unlock so cached sessions keep the operator's cap on reconnect.
- StoredPassphrase gains a new field with a default of 0 so existing
  call sites stay source-compatible.
- LockdownPassphraseStore (Android EncryptedSharedPreferences impl):
  reads/writes the new field with a `_maxSessionSeconds` key suffix;
  legacy entries decode to 0.
- LockdownPassphraseStore (JVM file-backed impl): bumps the per-entry
  on-disk serialization from 3-line to 4-line; legacy 3-line entries
  still decode (treated as maxSessionSeconds=0).

IPC + radio plumbing:
- IMeshService.sendLockdownUnlock AIDL gains a 4th int parameter.
- MeshService stub, MeshActionHandler, RadioController interface, and
  both impls (AndroidRadioControllerImpl, DirectRadioControllerImpl)
  thread the field through.
- FakeIMeshService, FakeRadioController, FakeLockdownCoordinator
  updated to match.

UI:
- LockdownDialog adds a single optional "Session cap (minutes)" field
  below the boots/hours row. Operators enter minutes for ergonomics;
  the dialog multiplies by 60 before passing to the coordinator. Blank
  or 0 = unlimited (firmware default).
- UIViewModel.sendLockdownUnlock gains the new param with default 0.
- New string resources: lockdown_session_minutes,
  lockdown_session_minutes_help. Strings re-sorted via
  scripts/sort-strings.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
niccellular
2026-05-18 16:57:35 -04:00
parent b5b56a3f11
commit 6ce565f16c
22 changed files with 156 additions and 42 deletions
+2
View File
@@ -599,6 +599,8 @@ 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
@@ -72,7 +72,9 @@ fun MainScreen() {
val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle()
LockdownDialog(
lockdownState = lockdownState,
onSubmit = { passphrase, boots, hours -> viewModel.sendLockdownUnlock(passphrase, boots, hours) },
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
@@ -145,3 +147,5 @@ private fun AndroidAppVersionCheck(viewModel: UIViewModel) {
}
}
}
private const val SECONDS_PER_MINUTE = 60
@@ -206,7 +206,7 @@ interface IMeshService {
void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash);
/// Send a lockdown passphrase to authenticate with a TAK-locked device
void sendLockdownUnlock(in String passphrase, in int bootTtl, in int hourTtl);
void sendLockdownUnlock(in String passphrase, in int bootTtl, in int hourTtl, in int maxSessionSeconds);
/// Send a Lock Now command to the connected TAK-enabled device
void sendLockNow();
@@ -375,7 +375,7 @@ class CommandSenderImpl(
}
}
override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int) {
override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) {
val validUntilEpoch =
if (hours > 0) {
(nowMillis / MILLIS_PER_SECOND + hours.toLong() * SECONDS_PER_HOUR).toInt()
@@ -387,6 +387,7 @@ class CommandSenderImpl(
passphrase = passphrase.encodeToByteArray().toByteString(),
boots_remaining = boots.coerceAtLeast(0),
valid_until_epoch = validUntilEpoch,
max_session_seconds = maxSessionSeconds.coerceAtLeast(0),
)
sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth))
}
@@ -56,6 +56,8 @@ class LockdownCoordinatorImpl(
@Volatile private var pendingHours: Int = 0
@Volatile private var pendingMaxSessionSeconds: Int = 0
override fun onConnect() {
serviceRepository.setSessionAuthorized(false)
resetTransientState()
@@ -108,7 +110,12 @@ class LockdownCoordinatorImpl(
if (stored != null) {
Logger.i { "Lockdown: Auto-unlocking with stored passphrase" }
wasAutoAttempt = true
commandSender.sendLockdownPassphrase(stored.passphrase, stored.boots, stored.hours)
commandSender.sendLockdownPassphrase(
stored.passphrase,
stored.boots,
stored.hours,
stored.maxSessionSeconds,
)
return
}
}
@@ -126,7 +133,13 @@ class LockdownCoordinatorImpl(
// Only save on manual submit — auto-unlock already has a stored passphrase.
if (deviceAddress != null && passphrase != null) {
try {
passphraseStore.savePassphrase(deviceAddress, passphrase, pendingBoots, pendingHours)
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)" }
@@ -174,14 +187,15 @@ class LockdownCoordinatorImpl(
}
}
override fun submitPassphrase(passphrase: String, boots: Int, hours: Int) {
override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) {
pendingPassphrase = passphrase
pendingBoots = boots
pendingHours = hours
pendingMaxSessionSeconds = maxSessionSeconds
wasAutoAttempt = false
wasLockNow = false
serviceRepository.setLockdownState(LockdownState.None)
commandSender.sendLockdownPassphrase(passphrase, boots, hours)
commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds)
}
override fun lockNow() {
@@ -195,5 +209,6 @@ class LockdownCoordinatorImpl(
pendingPassphrase = null
pendingBoots = LockdownPassphraseStore.DEFAULT_BOOTS
pendingHours = 0
pendingMaxSessionSeconds = 0
}
}
@@ -404,8 +404,8 @@ class MeshActionHandlerImpl(
}
}
override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {
lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl)
override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {
lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds)
}
override fun handleSendLockNow() {
@@ -341,7 +341,7 @@ interface RadioController {
fun setDeviceAddress(address: String)
/** Submits a lockdown passphrase to authenticate with a TAK-locked device. */
suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int)
suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int = 0)
/** Sends a Lock Now command to the connected TAK-enabled device. */
suspend fun sendLockNow()
@@ -85,7 +85,12 @@ interface CommandSender {
fun requestNeighborInfo(requestId: Int, destNum: Int)
/** Sends a lockdown passphrase to authenticate with a TAK-locked device. */
fun sendLockdownPassphrase(passphrase: String, boots: Int = 0, hours: Int = 0)
fun sendLockdownPassphrase(
passphrase: String,
boots: Int = 0,
hours: Int = 0,
maxSessionSeconds: Int = 0,
)
/** Sends a Lock Now command to immediately lock a TAK-enabled device. */
fun sendLockNow()
@@ -43,7 +43,7 @@ interface LockdownCoordinator {
fun handleLockdownStatus(status: LockdownStatus)
/** Submits a passphrase to authenticate with the locked device. */
fun submitPassphrase(passphrase: String, boots: Int, hours: Int)
fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int = 0)
/** Sends a Lock Now command to the connected device. */
fun lockNow()
@@ -16,8 +16,19 @@
*/
package org.meshtastic.core.repository
/** Stored passphrase entry with associated TTL parameters. */
data class StoredPassphrase(val passphrase: String, val boots: Int, val hours: Int) {
/**
* 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" }
}
@@ -35,7 +46,13 @@ interface LockdownPassphraseStore {
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)
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)
@@ -118,7 +118,7 @@ interface MeshActionHandler {
fun handleUpdateLastAddress(deviceAddr: String?)
/** Submits a lockdown passphrase to authenticate with a TAK-locked device. */
fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int)
fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int = 0)
/** Sends a Lock Now command to the connected TAK-enabled device. */
fun handleSendLockNow()
@@ -623,6 +623,8 @@
<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>
@@ -221,8 +221,8 @@ class AndroidRadioControllerImpl(
context.startForegroundService(intent)
}
override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {
serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl)
override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {
serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds)
}
override suspend fun sendLockNow() {
@@ -61,23 +61,36 @@ class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore {
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)
return StoredPassphrase(passphrase, boots, hours)
val maxSessionSeconds = p.getInt("${key}_maxSessionSeconds", 0)
return StoredPassphrase(passphrase, boots, hours, maxSessionSeconds)
}
override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) {
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").apply()
p.edit()
.remove("${key}_passphrase")
.remove("${key}_boots")
.remove("${key}_hours")
.remove("${key}_maxSessionSeconds")
.apply()
}
private fun sanitizeKey(address: String): String = address.replace(":", "_")
@@ -402,8 +402,18 @@ class MeshService : Service() {
router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash)
}
override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) = toRemoteExceptions {
router.actionHandler.handleSendLockdownUnlock(passphrase.orEmpty(), bootTtl, hourTtl)
override fun sendLockdownUnlock(
passphrase: String?,
bootTtl: Int,
hourTtl: Int,
maxSessionSeconds: Int,
) = toRemoteExceptions {
router.actionHandler.handleSendLockdownUnlock(
passphrase.orEmpty(),
bootTtl,
hourTtl,
maxSessionSeconds,
)
}
override fun sendLockNow() = toRemoteExceptions { router.actionHandler.handleSendLockNow() }
@@ -126,7 +126,7 @@ open class FakeIMeshService : IMeshService.Stub() {
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) {}
override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {}
override fun sendLockNow() {}
}
@@ -235,8 +235,8 @@ class DirectRadioControllerImpl(
radioInterfaceService.setDeviceAddress(address)
}
override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {
actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl)
override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {
actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds)
}
override suspend fun sendLockNow() {
@@ -71,9 +71,15 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore {
}
}
override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) {
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)
val plaintext = serialize(passphrase, boots, hours, maxSessionSeconds)
val encrypted = encrypt(key, plaintext)
entryFile(deviceAddress).writeBytes(encrypted)
}
@@ -114,24 +120,42 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore {
// region Serialization (simple line-based to avoid adding kotlinx-serialization dependency)
private fun serialize(passphrase: String, boots: Int, hours: Int): ByteArray =
"$boots\n$hours\n$passphrase".encodeToByteArray()
// 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()
val lines = text.split("\n", limit = 3)
if (lines.size < SERIALIZED_LINE_COUNT) {
// 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 = lines[0].toIntOrNull()
val hours = lines[1].toIntOrNull()
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 = lines[2], boots = boots, hours = hours)
return StoredPassphrase(passphrase = v1[2], boots = boots, hours = hours)
}
// endregion
@@ -172,6 +196,7 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore {
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 = 3
private const val SERIALIZED_LINE_COUNT_V1 = 3
private const val SERIALIZED_LINE_COUNT_V2 = 4
}
}
@@ -27,6 +27,7 @@ class FakeLockdownCoordinator : LockdownCoordinator {
var lastPassphrase: String? = null
var lastBoots: Int? = null
var lastHours: Int? = null
var lastMaxSessionSeconds: Int? = null
var lockNowCalled = false
override fun onConnect() {
@@ -45,10 +46,11 @@ class FakeLockdownCoordinator : LockdownCoordinator {
lastStatus = status
}
override fun submitPassphrase(passphrase: String, boots: Int, hours: Int) {
override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) {
lastPassphrase = passphrase
lastBoots = boots
lastHours = hours
lastMaxSessionSeconds = maxSessionSeconds
}
override fun lockNow() {
@@ -162,7 +162,7 @@ class FakeRadioController :
lastSetDeviceAddress = address
}
override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {}
override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {}
override suspend fun sendLockNow() {}
@@ -141,8 +141,13 @@ class UIViewModel(
val lockdownState = serviceRepository.lockdownState
val lockdownTokenInfo = serviceRepository.lockdownTokenInfo
fun sendLockdownUnlock(passphrase: String, bootTtl: Int = DEFAULT_BOOT_TTL, hourTtl: Int = 0) {
viewModelScope.launch { radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl) }
fun sendLockdownUnlock(
passphrase: String,
bootTtl: Int = DEFAULT_BOOT_TTL,
hourTtl: Int = 0,
maxSessionSeconds: Int = 0,
) {
viewModelScope.launch { radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) }
}
fun sendLockNow() {
@@ -57,6 +57,8 @@ 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
@@ -75,7 +77,7 @@ import org.meshtastic.core.ui.icon.VisibilityOff
@Composable
fun LockdownDialog(
lockdownState: LockdownState,
onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit,
onSubmit: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit,
onDisconnect: () -> Unit,
) {
val shouldShow =
@@ -93,6 +95,7 @@ fun LockdownDialog(
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 =
@@ -208,10 +211,20 @@ fun LockdownDialog(
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) }, enabled = isValid) {
TextButton(onClick = { onSubmit(passphrase, boots, hours, sessionMinutes) }, enabled = isValid) {
Text(stringResource(Res.string.lockdown_submit))
}
},