diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index a3f574d128..e298014406 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -658,12 +658,22 @@ location_sharing lockdown_backoff lockdown_boots_remaining lockdown_confirm_passphrase +lockdown_disable +lockdown_disable_message +lockdown_enable lockdown_enter_passphrase lockdown_hide_passphrase lockdown_hours_until_expiry lockdown_incorrect_passphrase +lockdown_irreversible_ack +lockdown_irreversible_warning lockdown_lock_now lockdown_lock_reason +lockdown_mode +lockdown_mode_setting_up +lockdown_mode_summary_locked +lockdown_mode_summary_off +lockdown_mode_summary_unlocked lockdown_passphrase lockdown_passphrases_do_not_match lockdown_session_boots_remaining diff --git a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl index ae07820378..37523996b1 100644 --- a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl @@ -205,8 +205,8 @@ 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, in int maxSessionSeconds); + /// Send a lockdown passphrase to authenticate with a locked device, or (disable=true) to turn lockdown off + void sendLockdownUnlock(in String passphrase, in int bootTtl, in int hourTtl, in int maxSessionSeconds, in boolean disable); /// Send a Lock Now command to the connected TAK-enabled device void sendLockNow(); diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index d8578f61de..c51a8c5623 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -375,7 +375,13 @@ class CommandSenderImpl( } } - override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { + override fun sendLockdownPassphrase( + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { val validUntilEpoch = if (hours > 0) { (nowMillis / MILLIS_PER_SECOND + hours.toLong() * SECONDS_PER_HOUR).toInt() @@ -388,6 +394,7 @@ class CommandSenderImpl( boots_remaining = boots.coerceAtLeast(0), valid_until_epoch = validUntilEpoch, max_session_seconds = maxSessionSeconds.coerceAtLeast(0), + disable = disable, ) sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth)) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt index 4ff43721a1..91b433f8a6 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt @@ -80,10 +80,28 @@ class LockdownCoordinatorImpl( LockdownStatus.State.LOCKED -> handleLocked(status.lock_reason) LockdownStatus.State.UNLOCKED -> handleUnlocked(status) LockdownStatus.State.UNLOCK_FAILED -> handleUnlockFailed(status.backoff_seconds) + LockdownStatus.State.DISABLED -> handleDisabled() LockdownStatus.State.STATE_UNSPECIFIED -> Logger.w { "Lockdown: Received STATE_UNSPECIFIED from firmware" } } } + @Suppress("TooGenericExceptionCaught") + private fun handleDisabled() { + // Lockdown-capable but currently OFF. Drop any stale stored passphrase so we don't try to auto-unlock later. + val deviceAddress = radioInterfaceService.getDeviceAddress() + if (deviceAddress != null) { + try { + passphraseStore.clearPassphrase(deviceAddress) + } catch (e: Exception) { + Logger.e(e) { "Lockdown: Failed to clear stored passphrase on DISABLED" } + } + } + resetTransientState() + serviceRepository.setSessionAuthorized(false) + serviceRepository.setLockdownTokenInfo(null) + serviceRepository.setLockdownState(LockdownState.Disabled) + } + private fun handleLockNowAcknowledged() { Logger.i { "Lockdown: Lock Now acknowledged — resetting session authorization" } serviceRepository.setSessionAuthorized(false) @@ -187,15 +205,36 @@ class LockdownCoordinatorImpl( } } - override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { - pendingPassphrase = passphrase - pendingBoots = boots - pendingHours = hours - pendingMaxSessionSeconds = maxSessionSeconds + @Suppress("TooGenericExceptionCaught") + override fun submitPassphrase( + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { wasAutoAttempt = false wasLockNow = false + if (disable) { + // Turning lockdown OFF: the device will reboot to DISABLED, so there is nothing to re-save. Drop any + // stored passphrase now so a later reconnect doesn't auto-unlock a device the user just disabled. + pendingPassphrase = null + val deviceAddress = radioInterfaceService.getDeviceAddress() + if (deviceAddress != null) { + try { + passphraseStore.clearPassphrase(deviceAddress) + } catch (e: Exception) { + Logger.e(e) { "Lockdown: Failed to clear stored passphrase while disabling" } + } + } + } else { + pendingPassphrase = passphrase + pendingBoots = boots + pendingHours = hours + pendingMaxSessionSeconds = maxSessionSeconds + } serviceRepository.setLockdownState(LockdownState.None) - commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds) + commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds, disable) } override fun lockNow() { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index 74b7d3b6d0..b3ef50b018 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -404,8 +404,14 @@ class MeshActionHandlerImpl( } } - override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { - lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds) + override fun handleSendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { + lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable) } override fun handleSendLockNow() { diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt index 0641825d37..c834177473 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt @@ -32,6 +32,7 @@ import org.meshtastic.proto.LockdownStatus import org.meshtastic.proto.Telemetry import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -67,12 +68,22 @@ class LockdownCoordinatorImplTest { var lastPassphrase: String? = null var lastBoots: Int = 0 var lastHours: Int = 0 + var lastMaxSessionSeconds: Int = 0 + var lastDisable: Boolean = false var lockNowCalled = false - override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int) { + override fun sendLockdownPassphrase( + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { lastPassphrase = passphrase lastBoots = boots lastHours = hours + lastMaxSessionSeconds = maxSessionSeconds + lastDisable = disable } override fun sendLockNow() { @@ -488,5 +499,39 @@ class LockdownCoordinatorImplTest { assertIs(serviceRepo.lockdownState.value) } + @Test + fun `submitPassphrase with disable forwards disable flag and clears stored passphrase`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saved[testDeviceAddress] = StoredPassphrase("original", 50, 0) + + coordinator.submitPassphrase("original", boots = 0, hours = 0, disable = true) + + assertTrue(commandSender.lastDisable) + assertTrue(passphraseStore.saved.isEmpty()) + } + + @Test + fun `submitPassphrase without disable does not set disable flag`() { + coordinator.submitPassphrase("p", boots = 5, hours = 0) + assertFalse(commandSender.lastDisable) + } + + // endregion + + // region DISABLED + + @Test + fun `DISABLED sets Disabled state, clears authorization, token, and stored passphrase`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saved[testDeviceAddress] = StoredPassphrase("stale", 50, 0) + + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.DISABLED)) + + assertIs(serviceRepo.lockdownState.value) + assertFalse(serviceRepo.sessionAuthorized.value) + assertNull(serviceRepo.lockdownTokenInfo.value) + assertTrue(passphraseStore.saved.isEmpty()) + } + // endregion } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt index 284dbe3bb5..9c7f9c6075 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt @@ -340,8 +340,17 @@ 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, maxSessionSeconds: Int = 0) + /** + * Submits a lockdown passphrase to authenticate with a locked device, or (when [disable] is `true`) to turn + * lockdown OFF. + */ + suspend fun sendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int = 0, + disable: Boolean = false, + ) /** Sends a Lock Now command to the connected TAK-enabled device. */ suspend fun sendLockNow() diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt index 9ceb34694e..55d62a5a47 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt @@ -32,6 +32,9 @@ sealed class LockdownState { data object Unlocked : LockdownState() + /** Device is lockdown-capable but lockdown is currently OFF. The toggle shows OFF. */ + data object Disabled : LockdownState() + /** Lock Now ACK received — client should disconnect immediately, no dialog. */ data object LockNowAcknowledged : LockdownState() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt index a3d1128ac6..cdb13b3242 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt @@ -84,12 +84,18 @@ interface CommandSender { /** Requests neighbor info from a specific node. */ fun requestNeighborInfo(requestId: Int, destNum: Int) - /** Sends a lockdown passphrase to authenticate with a TAK-locked device. */ + /** + * Sends a lockdown passphrase to authenticate with a locked device. + * + * @param disable when `true`, instructs the device to decrypt storage back to plaintext and leave lockdown (the off + * switch). The device reboots and reconnects reporting `DISABLED`. + */ fun sendLockdownPassphrase( passphrase: String, boots: Int = 0, hours: Int = 0, maxSessionSeconds: Int = 0, + disable: Boolean = false, ) /** Sends a Lock Now command to immediately lock a TAK-enabled device. */ diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt index 18e17ccf1b..0240ebc1d7 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt @@ -42,8 +42,19 @@ interface LockdownCoordinator { /** Routes an incoming typed [LockdownStatus] from FromRadio. */ fun handleLockdownStatus(status: LockdownStatus) - /** Submits a passphrase to authenticate with the locked device. */ - fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int = 0) + /** + * Submits a passphrase to authenticate with the locked device. + * + * @param disable when `true`, turns lockdown OFF (decrypt storage back to plaintext); the device reboots and + * reconnects reporting `DISABLED`. + */ + fun submitPassphrase( + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int = 0, + disable: Boolean = false, + ) /** Sends a Lock Now command to the connected device. */ fun lockNow() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt index b024bd24f1..4d144c1bc0 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt @@ -19,16 +19,10 @@ package org.meshtastic.core.repository /** * 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. + * @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, -) { +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" } } @@ -46,13 +40,7 @@ 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, - maxSessionSeconds: Int = 0, - ) + 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) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt index 19fd1f3577..8b4fb60183 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt @@ -117,8 +117,17 @@ interface MeshActionHandler { /** Updates the last used device address. */ fun handleUpdateLastAddress(deviceAddr: String?) - /** Submits a lockdown passphrase to authenticate with a TAK-locked device. */ - fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int = 0) + /** + * Submits a lockdown passphrase to authenticate with a locked device, or (when [disable] is `true`) to turn + * lockdown OFF. + */ + fun handleSendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int = 0, + disable: Boolean = false, + ) /** Sends a Lock Now command to the connected TAK-enabled device. */ fun handleSendLockNow() diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 966eeda646..c44013f24d 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -682,12 +682,22 @@ Try again in %1$d seconds. Boots remaining Confirm passphrase + Disable lockdown + Enter your passphrase to turn off lockdown. The device will decrypt its storage and reboot. + Enable lockdown Enter Passphrase Hide Hours until expiry Incorrect passphrase. + I understand this cannot be undone + Enabling lockdown permanently locks the debug port (SWD) on supported hardware. Recovery then requires a full erase. This cannot be undone. Lock Now Reason: %1$s + Lockdown mode + Setting up… + Active — enter your passphrase to unlock this connection. + Encrypt device storage and require a passphrase per connection. + Active — storage encrypted, this connection authenticated. Passphrase Passphrases do not match Session: %1$d reboots remaining diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt index a811bd0e2a..3f2096b4aa 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt @@ -221,8 +221,14 @@ class AndroidRadioControllerImpl( context.startForegroundService(intent) } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { - serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) + override suspend fun sendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { + serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable) } override suspend fun sendLockNow() { diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 8e6d6c1aa9..797acc22c5 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -455,12 +455,14 @@ class MeshService : Service() { bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int, + disable: Boolean, ) = toRemoteExceptions { router.actionHandler.handleSendLockdownUnlock( passphrase.orEmpty(), bootTtl, hourTtl, maxSessionSeconds, + disable, ) } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt index 0f0e54ee2a..8eb61b2e37 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt @@ -126,7 +126,13 @@ 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, maxSessionSeconds: Int) {} + override fun sendLockdownUnlock( + passphrase: String?, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) {} override fun sendLockNow() {} } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt index 318ee7981e..2b467729d3 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -235,8 +235,14 @@ class DirectRadioControllerImpl( radioInterfaceService.setDeviceAddress(address) } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { - actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) + override suspend fun sendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { + actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable) } override suspend fun sendLockNow() { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt index 9091242d3b..fbec8c9d5d 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt @@ -28,6 +28,7 @@ class FakeLockdownCoordinator : LockdownCoordinator { var lastBoots: Int? = null var lastHours: Int? = null var lastMaxSessionSeconds: Int? = null + var lastDisable: Boolean = false var lockNowCalled = false override fun onConnect() { @@ -46,11 +47,18 @@ class FakeLockdownCoordinator : LockdownCoordinator { lastStatus = status } - override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { + override fun submitPassphrase( + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) { lastPassphrase = passphrase lastBoots = boots lastHours = hours lastMaxSessionSeconds = maxSessionSeconds + lastDisable = disable } override fun lockNow() { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index 859fe07c17..a560a105e9 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -162,7 +162,13 @@ class FakeRadioController : lastSetDeviceAddress = address } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {} + override suspend fun sendLockdownUnlock( + passphrase: String, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + disable: Boolean, + ) {} override suspend fun sendLockNow() {} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 84dee5df94..20f88fffbd 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -146,8 +146,11 @@ class UIViewModel( bootTtl: Int = DEFAULT_BOOT_TTL, hourTtl: Int = 0, maxSessionSeconds: Int = 0, + disable: Boolean = false, ) { - viewModelScope.launch { radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) } + viewModelScope.launch { + radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable) + } } fun sendLockNow() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt new file mode 100644 index 0000000000..ae994aa231 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt @@ -0,0 +1,333 @@ +/* + * Copyright (c) 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 . + */ +package org.meshtastic.feature.settings.lockdown + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.service.LockdownState +import org.meshtastic.core.model.service.LockdownTokenInfo +import org.meshtastic.core.repository.LockdownPassphraseStore +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.lockdown_boots_remaining +import org.meshtastic.core.resources.lockdown_confirm_passphrase +import org.meshtastic.core.resources.lockdown_disable +import org.meshtastic.core.resources.lockdown_disable_message +import org.meshtastic.core.resources.lockdown_enable +import org.meshtastic.core.resources.lockdown_hide_passphrase +import org.meshtastic.core.resources.lockdown_hours_until_expiry +import org.meshtastic.core.resources.lockdown_irreversible_ack +import org.meshtastic.core.resources.lockdown_irreversible_warning +import org.meshtastic.core.resources.lockdown_lock_now +import org.meshtastic.core.resources.lockdown_mode +import org.meshtastic.core.resources.lockdown_mode_setting_up +import org.meshtastic.core.resources.lockdown_mode_summary_locked +import org.meshtastic.core.resources.lockdown_mode_summary_off +import org.meshtastic.core.resources.lockdown_mode_summary_unlocked +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.ui.component.SwitchPreference +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Visibility +import org.meshtastic.core.ui.icon.VisibilityOff +import org.meshtastic.feature.settings.radio.component.NodeActionButton + +/** + * Runtime lockdown-mode toggle for the security settings screen. + * + * The switch and its dialogs are driven entirely by the latest [lockdownState]: + * - [LockdownState.Disabled] / [LockdownState.NeedsProvision] → OFF; turning ON opens the set-passphrase dialog with + * the one-time irreversible warning. + * - [LockdownState.Locked] → ON (locked); authentication is handled by the global lockdown dialog, so the switch is + * read-only here. + * - [LockdownState.Unlocked] → ON; turning OFF opens the disable dialog, plus a "Lock now" affordance and session info. + * + * When [lockdownState] is [LockdownState.None] the device is not lockdown-capable (it never sent a `lockdown_status`), + * so nothing is rendered. + */ +@Composable +fun ColumnScope.LockdownModeSetting( + lockdownState: LockdownState, + tokenInfo: LockdownTokenInfo?, + connected: Boolean, + containerColor: Color, + onEnable: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit, + onDisable: (passphrase: String) -> Unit, + onLockNow: () -> Unit, + modifier: Modifier = Modifier, +) { + if (lockdownState is LockdownState.None) return + + var showEnableDialog by rememberSaveable { mutableStateOf(false) } + var showDisableDialog by rememberSaveable { mutableStateOf(false) } + + val lockdownOn = lockdownState is LockdownState.Locked || lockdownState is LockdownState.Unlocked + val unlocked = lockdownState is LockdownState.Unlocked + // Only DISABLED/NEEDS_PROVISION (turn on) and UNLOCKED (turn off) are actionable from here; LOCKED auth is driven + // by the blocking global dialog. + val toggleEnabled = + connected && + (lockdownState is LockdownState.Disabled || lockdownState is LockdownState.NeedsProvision || unlocked) + + val summary = + when (lockdownState) { + is LockdownState.Unlocked -> stringResource(Res.string.lockdown_mode_summary_unlocked) + is LockdownState.Locked -> stringResource(Res.string.lockdown_mode_summary_locked) + is LockdownState.NeedsProvision -> stringResource(Res.string.lockdown_mode_setting_up) + else -> stringResource(Res.string.lockdown_mode_summary_off) + } + + SwitchPreference( + modifier = modifier, + title = stringResource(Res.string.lockdown_mode), + summary = summary, + checked = lockdownOn, + enabled = toggleEnabled, + onCheckedChange = { turnOn -> if (turnOn) showEnableDialog = true else showDisableDialog = true }, + containerColor = containerColor, + ) + + if (unlocked) { + LockdownSessionStatus(tokenInfo = tokenInfo) + NodeActionButton( + modifier = Modifier.padding(horizontal = SPACING_DP.dp), + title = stringResource(Res.string.lockdown_lock_now), + enabled = connected, + onClick = onLockNow, + ) + } + + if (showEnableDialog) { + EnableLockdownDialog( + onConfirm = { passphrase, boots, hours, sessionMinutes -> + showEnableDialog = false + onEnable(passphrase, boots, hours, sessionMinutes) + }, + onDismiss = { showEnableDialog = false }, + ) + } + + if (showDisableDialog) { + DisableLockdownDialog( + onConfirm = { passphrase -> + showDisableDialog = false + onDisable(passphrase) + }, + onDismiss = { showDisableDialog = false }, + ) + } +} + +@Suppress("LongMethod") +@Composable +private fun EnableLockdownDialog( + onConfirm: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit, + onDismiss: () -> Unit, +) { + var passphrase by rememberSaveable { mutableStateOf("") } + var confirmPassphrase by rememberSaveable { mutableStateOf("") } + 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) } + var acknowledged by rememberSaveable { mutableStateOf(false) } + + val passphraseValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN + val matches = passphrase == confirmPassphrase + val isValid = passphraseValid && matches && acknowledged + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(Res.string.lockdown_set_passphrase)) }, + text = { + Column { + Text( + text = stringResource(Res.string.lockdown_irreversible_warning), + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + PassphraseField( + value = passphrase, + onValueChange = { passphrase = it }, + label = stringResource(Res.string.lockdown_passphrase), + passwordVisible = passwordVisible, + onToggleVisibility = { passwordVisible = !passwordVisible }, + ) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + OutlinedTextField( + value = confirmPassphrase, + onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) confirmPassphrase = it }, + label = { Text(stringResource(Res.string.lockdown_confirm_passphrase)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + isError = confirmPassphrase.isNotEmpty() && !matches, + supportingText = + if (confirmPassphrase.isNotEmpty() && !matches) { + { Text(stringResource(Res.string.lockdown_passphrases_do_not_match)) } + } else { + null + }, + modifier = Modifier.fillMaxWidth(), + ) + 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(stringResource(Res.string.lockdown_boots_remaining)) }, + 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(stringResource(Res.string.lockdown_hours_until_expiry)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + 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(), + ) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it }) + Text(stringResource(Res.string.lockdown_irreversible_ack)) + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(passphrase, boots, hours, sessionMinutes) }, enabled = isValid) { + Text(stringResource(Res.string.lockdown_enable)) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } }, + ) +} + +@Composable +private fun DisableLockdownDialog(onConfirm: (passphrase: String) -> Unit, onDismiss: () -> Unit) { + var passphrase by rememberSaveable { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + val isValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(Res.string.lockdown_disable)) }, + text = { + Column { + Text(stringResource(Res.string.lockdown_disable_message)) + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + PassphraseField( + value = passphrase, + onValueChange = { passphrase = it }, + label = stringResource(Res.string.lockdown_passphrase), + passwordVisible = passwordVisible, + onToggleVisibility = { passwordVisible = !passwordVisible }, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(passphrase) }, enabled = isValid) { + Text(stringResource(Res.string.lockdown_disable)) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } }, + ) +} + +@Composable +private fun PassphraseField( + value: String, + onValueChange: (String) -> Unit, + label: String, + passwordVisible: Boolean, + onToggleVisibility: () -> Unit, +) { + OutlinedTextField( + value = value, + onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) onValueChange(it) }, + label = { Text(label) }, + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = onToggleVisibility) { + Icon( + imageVector = if (passwordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, + contentDescription = + stringResource( + if (passwordVisible) { + Res.string.lockdown_hide_passphrase + } else { + Res.string.lockdown_show_passphrase + }, + ), + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) +} + +// Firmware maximum: AdminMessage.lockdown_auth.passphrase is limited to 64 bytes. +private const val MAX_PASSPHRASE_LEN = 64 +private const val MAX_BYTE_VALUE = 255 +private const val SPACING_DP = 8 diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index b9ff67b389..ec870bc4b1 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -60,6 +60,7 @@ import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.LocationService import org.meshtastic.core.repository.LockdownCoordinator +import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.repository.MapConsentPrefs import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.NodeRepository @@ -141,11 +142,28 @@ open class RadioConfigViewModel( val lockdownTokenInfo = serviceRepository.lockdownTokenInfo val sessionAuthorized = serviceRepository.sessionAuthorized + val lockdownState = serviceRepository.lockdownState fun sendLockNow() { safeLaunch(tag = "sendLockNow") { lockdownCoordinator.lockNow() } } + /** + * Submits a lockdown passphrase: enables lockdown (from DISABLED), authenticates ([disable]=false from LOCKED), or + * turns lockdown off ([disable]=true from UNLOCKED). + */ + fun submitLockdownPassphrase( + passphrase: String, + boots: Int = LockdownPassphraseStore.DEFAULT_BOOTS, + hours: Int = 0, + maxSessionSeconds: Int = 0, + disable: Boolean = false, + ) { + safeLaunch(tag = "submitLockdownPassphrase") { + lockdownCoordinator.submitPassphrase(passphrase, boots, hours, maxSessionSeconds, disable) + } + } + val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed fun toggleAnalyticsAllowed() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt index 887c349e2e..a7b4856055 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt @@ -46,7 +46,6 @@ import org.meshtastic.core.resources.config_security_public_key import org.meshtastic.core.resources.config_security_serial_enabled import org.meshtastic.core.resources.debug_log_api_enabled import org.meshtastic.core.resources.direct_message_key -import org.meshtastic.core.resources.lockdown_lock_now import org.meshtastic.core.resources.logs import org.meshtastic.core.resources.managed_mode import org.meshtastic.core.resources.private_key @@ -63,7 +62,7 @@ import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Warning -import org.meshtastic.feature.settings.lockdown.LockdownSessionStatus +import org.meshtastic.feature.settings.lockdown.LockdownModeSetting import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.proto.Config import kotlin.random.Random @@ -206,16 +205,25 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() + val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle() val tokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle() - val authorized by viewModel.sessionAuthorized.collectAsStateWithLifecycle() - if (authorized) { - LockdownSessionStatus(tokenInfo = tokenInfo) - } - NodeActionButton( - modifier = Modifier.padding(horizontal = 8.dp), - title = stringResource(Res.string.lockdown_lock_now), - enabled = state.connected && authorized, - onClick = { viewModel.sendLockNow() }, + LockdownModeSetting( + lockdownState = lockdownState, + tokenInfo = tokenInfo, + connected = state.connected, + containerColor = CardDefaults.cardColors().containerColor, + onEnable = { passphrase, boots, hours, sessionMinutes -> + viewModel.submitLockdownPassphrase( + passphrase = passphrase, + boots = boots, + hours = hours, + maxSessionSeconds = sessionMinutes * SECONDS_PER_MINUTE, + ) + }, + onDisable = { passphrase -> + viewModel.submitLockdownPassphrase(passphrase = passphrase, disable = true) + }, + onLockNow = { viewModel.sendLockNow() }, ) } } @@ -249,3 +257,5 @@ fun PrivateKeyRegenerateDialog( ) } } + +private const val SECONDS_PER_MINUTE = 60