Skip to content

Commit bc6b033

Browse files
jamesarichCopilot
andcommitted
feat: firmware lockdown mode — provision / unlock / lock-now (#5439)
Squash merge of PR #5439 into release/2.8.0. Adds end-to-end UI and service support for the firmware's hardened LOCKDOWN build. Includes LockdownCoordinator, passphrase store, lock/unlock admin messages, and settings UI. Service-layer interface additions from the original PR require adaptation to the post-AIDL architecture (wiring pending). Co-authored-by: niccellular Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ad75e0f commit bc6b033

45 files changed

Lines changed: 5226 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.skills/compose-ui/strings-index.txt

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,5 @@ You are an expert Android/KMP engineer. Maintain architectural boundaries, use M
4949
<!-- SPECKIT START -->
5050
For additional context about technologies to be used, project structure,
5151
shell commands, and other important information, read the current plan
52+
at `specs/20260513-075218-lockdown-mode/plan.md`
5253
<!-- SPECKIT END -->

androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import co.touchlab.kermit.Logger
3333
import org.koin.compose.viewmodel.koinViewModel
3434
import org.meshtastic.app.BuildConfig
3535
import org.meshtastic.core.model.ConnectionState
36+
import org.meshtastic.core.model.service.LockdownState
3637
import org.meshtastic.core.navigation.NodesRoute
3738
import org.meshtastic.core.navigation.TopLevelDestination
3839
import org.meshtastic.core.navigation.rememberMultiBackstack
@@ -49,6 +50,7 @@ import org.meshtastic.feature.firmware.navigation.firmwareGraph
4950
import org.meshtastic.feature.map.navigation.mapGraph
5051
import org.meshtastic.feature.messaging.navigation.contactsGraph
5152
import org.meshtastic.feature.node.navigation.nodesGraph
53+
import org.meshtastic.feature.settings.lockdown.LockdownDialog
5254
import org.meshtastic.feature.settings.navigation.settingsGraph
5355
import org.meshtastic.feature.settings.radio.channel.channelsGraph
5456
import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph
@@ -70,6 +72,21 @@ fun MainScreen() {
7072

7173
AndroidAppVersionCheck(viewModel)
7274

75+
val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle()
76+
LockdownDialog(
77+
lockdownState = lockdownState,
78+
onSubmit = { passphrase, boots, hours, sessionMinutes ->
79+
viewModel.sendLockdownUnlock(passphrase, boots, hours, sessionMinutes * SECONDS_PER_MINUTE)
80+
},
81+
onDisconnect = { viewModel.setDeviceAddress("n") },
82+
)
83+
// Auto-disconnect when firmware acknowledges Lock Now
84+
LaunchedEffect(lockdownState) {
85+
if (lockdownState is LockdownState.LockNowAcknowledged) {
86+
viewModel.setDeviceAddress("n")
87+
}
88+
}
89+
7390
MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) {
7491
MeshtasticNavigationSuite(
7592
multiBackstack = multiBackstack,
@@ -134,3 +151,5 @@ private fun AndroidAppVersionCheck(viewModel: UIViewModel) {
134151
}
135152
}
136153
}
154+
155+
private const val SECONDS_PER_MINUTE = 60
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.core.data.manager
18+
19+
import co.touchlab.kermit.Logger
20+
import org.koin.core.annotation.Single
21+
import org.meshtastic.core.model.service.LockdownState
22+
import org.meshtastic.core.model.service.LockdownTokenInfo
23+
import org.meshtastic.core.repository.CommandSender
24+
import org.meshtastic.core.repository.LockdownCoordinator
25+
import org.meshtastic.core.repository.LockdownPassphraseStore
26+
import org.meshtastic.core.repository.MeshConnectionManager
27+
import org.meshtastic.core.repository.RadioInterfaceService
28+
import org.meshtastic.core.repository.ServiceRepository
29+
import org.meshtastic.proto.LockdownStatus
30+
import kotlin.concurrent.Volatile
31+
32+
/**
33+
* Lockdown authentication state machine. Processes `LockdownStatus` messages from the firmware, drives the
34+
* `LockdownState` exposed to the UI, and manages auto-replay of cached passphrases.
35+
*
36+
* **Threading**: All public methods are called from the BLE/radio dispatcher (single-threaded). `@Volatile` fields
37+
* ensure visibility if a coroutine resumes on a different thread, but compound read-modify sequences assume no
38+
* concurrent callers.
39+
*/
40+
@Single(binds = [LockdownCoordinator::class])
41+
@Suppress("TooManyFunctions")
42+
class LockdownCoordinatorImpl(
43+
private val serviceRepository: ServiceRepository,
44+
private val commandSender: CommandSender,
45+
private val passphraseStore: LockdownPassphraseStore,
46+
private val radioInterfaceService: RadioInterfaceService,
47+
private val connectionManager: Lazy<MeshConnectionManager>,
48+
) : LockdownCoordinator {
49+
@Volatile private var wasAutoAttempt = false
50+
51+
@Volatile private var wasLockNow = false
52+
53+
@Volatile private var pendingPassphrase: String? = null
54+
55+
@Volatile private var pendingBoots: Int = LockdownPassphraseStore.DEFAULT_BOOTS
56+
57+
@Volatile private var pendingHours: Int = 0
58+
59+
@Volatile private var pendingMaxSessionSeconds: Int = 0
60+
61+
override fun onConnect() {
62+
serviceRepository.setSessionAuthorized(false)
63+
resetTransientState()
64+
}
65+
66+
override fun onDisconnect() {
67+
serviceRepository.setSessionAuthorized(false)
68+
serviceRepository.setLockdownTokenInfo(null)
69+
serviceRepository.setLockdownState(LockdownState.None)
70+
resetTransientState()
71+
}
72+
73+
override fun onConfigComplete() {
74+
// No-op once authorized; retained for lifecycle symmetry.
75+
}
76+
77+
override fun handleLockdownStatus(status: LockdownStatus) {
78+
when (status.state) {
79+
LockdownStatus.State.NEEDS_PROVISION -> handleNeedsProvision()
80+
LockdownStatus.State.LOCKED -> handleLocked(status.lock_reason)
81+
LockdownStatus.State.UNLOCKED -> handleUnlocked(status)
82+
LockdownStatus.State.UNLOCK_FAILED -> handleUnlockFailed(status.backoff_seconds)
83+
LockdownStatus.State.DISABLED -> handleDisabled()
84+
LockdownStatus.State.STATE_UNSPECIFIED -> Logger.w { "Lockdown: Received STATE_UNSPECIFIED from firmware" }
85+
}
86+
}
87+
88+
@Suppress("TooGenericExceptionCaught")
89+
private fun handleDisabled() {
90+
// Lockdown-capable but currently OFF. Drop any stale stored passphrase so we don't try to auto-unlock later.
91+
val deviceAddress = radioInterfaceService.getDeviceAddress()
92+
if (deviceAddress != null) {
93+
try {
94+
passphraseStore.clearPassphrase(deviceAddress)
95+
} catch (e: Exception) {
96+
Logger.e(e) { "Lockdown: Failed to clear stored passphrase on DISABLED" }
97+
}
98+
}
99+
resetTransientState()
100+
serviceRepository.setSessionAuthorized(false)
101+
serviceRepository.setLockdownTokenInfo(null)
102+
serviceRepository.setLockdownState(LockdownState.Disabled)
103+
}
104+
105+
private fun handleLockNowAcknowledged() {
106+
Logger.i { "Lockdown: Lock Now acknowledged — resetting session authorization" }
107+
serviceRepository.setSessionAuthorized(false)
108+
resetTransientState()
109+
connectionManager.value.clearRadioConfig()
110+
serviceRepository.setLockdownState(LockdownState.LockNowAcknowledged)
111+
}
112+
113+
@Suppress("TooGenericExceptionCaught")
114+
private fun handleLocked(lockReason: String) {
115+
if (wasLockNow) {
116+
handleLockNowAcknowledged()
117+
return
118+
}
119+
val deviceAddress = radioInterfaceService.getDeviceAddress()
120+
if (deviceAddress != null) {
121+
val stored =
122+
try {
123+
passphraseStore.getPassphrase(deviceAddress)
124+
} catch (e: Exception) {
125+
Logger.e(e) { "Lockdown: Failed to read stored passphrase" }
126+
null
127+
}
128+
if (stored != null) {
129+
Logger.i { "Lockdown: Auto-unlocking with stored passphrase" }
130+
wasAutoAttempt = true
131+
commandSender.sendLockdownPassphrase(
132+
stored.passphrase,
133+
stored.boots,
134+
stored.hours,
135+
stored.maxSessionSeconds,
136+
)
137+
return
138+
}
139+
}
140+
serviceRepository.setLockdownState(LockdownState.Locked(lockReason))
141+
}
142+
143+
private fun handleNeedsProvision() {
144+
serviceRepository.setLockdownState(LockdownState.NeedsProvision)
145+
}
146+
147+
@Suppress("TooGenericExceptionCaught")
148+
private fun handleUnlocked(status: LockdownStatus) {
149+
val deviceAddress = radioInterfaceService.getDeviceAddress()
150+
val passphrase = pendingPassphrase
151+
// Only save on manual submit — auto-unlock already has a stored passphrase.
152+
if (deviceAddress != null && passphrase != null) {
153+
try {
154+
passphraseStore.savePassphrase(
155+
deviceAddress,
156+
passphrase,
157+
pendingBoots,
158+
pendingHours,
159+
pendingMaxSessionSeconds,
160+
)
161+
Logger.i { "Lockdown: Saved passphrase for device" }
162+
} catch (e: Exception) {
163+
Logger.e(e) { "Lockdown: Failed to persist passphrase (session still unlocked)" }
164+
}
165+
}
166+
pendingPassphrase = null
167+
serviceRepository.setLockdownTokenInfo(
168+
LockdownTokenInfo(
169+
bootsRemaining = status.boots_remaining,
170+
expiryEpoch = status.valid_until_epoch.toUInt().toLong(),
171+
),
172+
)
173+
serviceRepository.setLockdownState(LockdownState.Unlocked)
174+
serviceRepository.setSessionAuthorized(true)
175+
connectionManager.value.startConfigOnly()
176+
}
177+
178+
@Suppress("TooGenericExceptionCaught")
179+
private fun handleUnlockFailed(backoffSeconds: Int) {
180+
pendingPassphrase = null
181+
if (wasAutoAttempt) {
182+
wasAutoAttempt = false
183+
if (backoffSeconds > 0) {
184+
Logger.i { "Lockdown: Auto-unlock rate-limited (backoff=${backoffSeconds}s)" }
185+
serviceRepository.setLockdownState(LockdownState.UnlockBackoff(backoffSeconds))
186+
} else {
187+
val deviceAddress = radioInterfaceService.getDeviceAddress()
188+
if (deviceAddress != null) {
189+
try {
190+
passphraseStore.clearPassphrase(deviceAddress)
191+
Logger.i { "Lockdown: Auto-unlock failed, cleared stored passphrase" }
192+
} catch (e: Exception) {
193+
Logger.e(e) { "Lockdown: Auto-unlock failed AND could not clear stored passphrase" }
194+
}
195+
}
196+
serviceRepository.setLockdownState(LockdownState.Locked())
197+
}
198+
return
199+
}
200+
if (backoffSeconds > 0) {
201+
Logger.i { "Lockdown: Unlock failed with backoff of ${backoffSeconds}s" }
202+
serviceRepository.setLockdownState(LockdownState.UnlockBackoff(backoffSeconds))
203+
} else {
204+
serviceRepository.setLockdownState(LockdownState.UnlockFailed)
205+
}
206+
}
207+
208+
@Suppress("TooGenericExceptionCaught")
209+
override fun submitPassphrase(
210+
passphrase: String,
211+
boots: Int,
212+
hours: Int,
213+
maxSessionSeconds: Int,
214+
disable: Boolean,
215+
) {
216+
wasAutoAttempt = false
217+
wasLockNow = false
218+
if (disable) {
219+
// Turning lockdown OFF: the device will reboot to DISABLED, so there is nothing to re-save. Drop any
220+
// stored passphrase now so a later reconnect doesn't auto-unlock a device the user just disabled.
221+
pendingPassphrase = null
222+
val deviceAddress = radioInterfaceService.getDeviceAddress()
223+
if (deviceAddress != null) {
224+
try {
225+
passphraseStore.clearPassphrase(deviceAddress)
226+
} catch (e: Exception) {
227+
Logger.e(e) { "Lockdown: Failed to clear stored passphrase while disabling" }
228+
}
229+
}
230+
} else {
231+
pendingPassphrase = passphrase
232+
pendingBoots = boots
233+
pendingHours = hours
234+
pendingMaxSessionSeconds = maxSessionSeconds
235+
}
236+
serviceRepository.setLockdownState(LockdownState.None)
237+
commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds, disable)
238+
}
239+
240+
override fun lockNow() {
241+
wasLockNow = true
242+
commandSender.sendLockNow()
243+
}
244+
245+
private fun resetTransientState() {
246+
wasAutoAttempt = false
247+
wasLockNow = false
248+
pendingPassphrase = null
249+
pendingBoots = LockdownPassphraseStore.DEFAULT_BOOTS
250+
pendingHours = 0
251+
pendingMaxSessionSeconds = 0
252+
}
253+
}

0 commit comments

Comments
 (0)