feat: firmware lockdown mode (provision / unlock / lock-now)#5439
feat: firmware lockdown mode (provision / unlock / lock-now)#5439niccellular wants to merge 20 commits into
Conversation
Introduce the TAK passphrase lockdown abstractions: - LockdownState sealed class + LockdownTokenInfo for UI to observe. - LockdownCoordinator interface for the authentication lifecycle (onConnect/onDisconnect/onConfigComplete/handleLockdownStatus, plus submitPassphrase/lockNow). - Add sendLockdownPassphrase/sendLockNow to CommandSender, RadioController. - Add handleSendLockdownUnlock/handleSendLockNow to MeshActionHandler. - Add clearRadioConfig to MeshConnectionManager (used during lock-now). - Add lockdownState/lockdownTokenInfo/sessionAuthorized flows to ServiceRepository. handleLockdownStatus consumes the typed firmware LockdownStatus message from FromRadio (protobufs#911) instead of parsing string-prefixed ClientNotification messages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CommandSenderImpl: build AdminMessage.lockdown_auth = LockdownAuth(...) for provision/unlock and lock_now=true for the lock command. - FromRadioPacketHandlerImpl: route the new FromRadio.lockdown_status variant to the coordinator; also notify the coordinator on config_complete_id. - MeshActionHandlerImpl: forward handleSendLockdownUnlock/handleSendLockNow to the coordinator. - MeshConnectionManagerImpl: call coordinator.onConnect/onDisconnect; add clearRadioConfig to purge cached config after a lock-now ACK. - ServiceRepositoryImpl: back the lockdownState/lockdownTokenInfo/ sessionAuthorized flows. - LockdownHandlerImpl: orchestration. Switches on LockdownStatus.State (NEEDS_PROVISION / LOCKED / UNLOCKED / UNLOCK_FAILED), auto-replays stored passphrase on LOCKED, clears stored passphrase on a fresh UNLOCK_FAILED, surfaces backoff_seconds on rate-limit. Tracks a wasLockNow flag locally so the next LOCKED status after a lock-now command is translated to LockdownState.LockNowAcknowledged for an immediate UI disconnect (the new schema has no explicit ACK type). - LockdownPassphraseStore: per-device EncryptedSharedPreferences store for auto-unlock. Not biometric-gated by design. - Add androidx.security:security-crypto dependency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- IMeshService: sendLockdownUnlock(passphrase, bootTtl, hourTtl) and sendLockNow() AIDL methods. - MeshService: AIDL stubs forwarding to MeshActionHandler. - AndroidRadioControllerImpl: forward to meshService over AIDL. - DirectRadioControllerImpl: forward directly to actionHandler (in-process non-Android targets). - FakeIMeshService: test stubs. - UIViewModel: lockdownState/lockdownTokenInfo flows, sendLockdownUnlock, sendLockNow, clearLockdownState. Routed through radioController so the commonMain code does not depend on the AIDL service directly. - ConnectionsViewModel: expose lockdownState. - RadioConfigViewModel: lockdownTokenInfo + sendLockNow for the Lock Now button in security settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- LockdownUnlockDialog: passphrase entry with boots / hours TTL inputs. Shows lock_reason on LOCKED, a backoff countdown on UNLOCK_FAILED with backoff_seconds > 0 (Submit disabled while in backoff), and switches the title to "Set Passphrase" on NEEDS_PROVISION. - Main: collect lockdownState/lockdownTokenInfo, show the dialog, auto-clear on LockNowAcknowledged so the connection drops without a dialog flash. - ConnectionsScreen: gate the "must set region" banner on isLockdownAuthorized so an unauthorized client isn't told to fix a region it can't see. - SecurityConfigItemList: "Lock Now" button under Administration, labelled with the active session token's boots remaining and (if set) the wall-clock expiry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Meshtastic-Android into feat/lockdown-mode # Conflicts: # app/src/main/kotlin/org/meshtastic/app/ui/Main.kt # core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt # core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt # core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt # core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt # core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt # feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt # feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
- Replace java.text.DateFormat/java.util.Date usage in SecurityConfigScreen (constitution violation: no java.* in commonMain) with simplified Lock Now button - Replace material.icons imports with MeshtasticIcons in LockdownUnlockDialog - Proper token info display to be re-implemented in Phase 5 (T025-T026)
…Main Phase 2 foundational refactor: - T006: Extract LockdownPassphraseStore interface to core/repository - T007: Make concrete Android impl implement the interface (renamed to Impl) - T008: Add JVM/Desktop no-op passphrase store stub - T010: Move coordinator state machine from core/service/androidMain to core/data/commonMain as LockdownCoordinatorImpl (pure KMP, no Android deps) - Remove old LockdownHandlerImpl (superseded) - Convert KoinComponent lazy inject to constructor injection
…gration
Phase 3 (User Story 1 - Unlock a Locked Node):
- T014: Create LockdownDialog composable in feature/settings/lockdown/
- Non-dismissable AlertDialog (onDismissRequest = {})
- Passphrase field with visibility toggle
- Provision mode with boots/hours TTL fields
- Error display for UnlockFailed and UnlockBackoff states
- Disconnect button instead of Cancel
- T017: Integrate dialog in Main.kt app shell
- Observe lockdownState from UIViewModel
- Submit triggers sendLockdownUnlock
- Disconnect triggers setDeviceAddress("n") to drop connection
…onfirm - T027/T028: Auto-disconnect on LockNowAcknowledged state in app shell - T020/T021: Confirm passphrase field in provision mode with mismatch validation - T035/T036: LockdownSessionStatus composable showing boots remaining and expiry - Wire session status and Lock Now button enabled state based on sessionAuthorized - Expose lockdownTokenInfo and sessionAuthorized from RadioConfigViewModel
- Add LockdownCoordinator state machine with auto-replay, lock-now, and error-resilient passphrase store calls - Add EncryptedSharedPreferences-backed Android passphrase store with nullable fallback on crypto init failure - Add LockdownDialog (provision/unlock/backoff) with byte-length passphrase validation and string resources - Add LockdownSessionStatus composable for token info display - Gate region-unset banner on sessionAuthorized in ConnectionsScreen - Wire Lock Now button in SecurityConfigScreen - Add LockdownCoordinatorImplTest covering all state transitions, auto-replay, lock-now, error paths, and uint32 overflow - Add FakeLockdownCoordinator and update test fakes - Delete unused LockdownUnlockDialog.kt
Replace no-op stub with PKCS12 KeyStore + AES-256-GCM file-backed store at ~/.meshtastic/lockdown/. Passphrases now persist across Desktop sessions with same error resilience as Android impl.
Sync contracts, plan, and tasks with actual interface signatures, module paths, and JVM encrypted store implementation.
Address remaining review items, add integration and JVM store tests, and sync the lockdown spec docs with the implemented API and UI.
|
On The cycle is unresolved:
Koin resolves each singleton by trying to build the other first, recurses forever. Crash (head of the stack — repeats indefinitely): Fix options:
Happy to push (1) if it's useful. |
MeshConnectionManagerImpl and LockdownCoordinatorImpl constructor-inject each other, causing a StackOverflowError at Koin resolution time. The coordinator only needs MeshConnectionManager in two rare paths (lock-now-ack and post-unlock config reload), so defer its resolution with Lazy<T> — matching the existing Lazy<MeshRouter> pattern in FromRadioPacketHandlerImpl.
|
Good catch — went with option 1. The coordinator only touches |
Compose Multiplatform stringResource requires positional specifiers (%1$s, %1$d) — plain %s/%d renders literal format tokens. Boot TTL and Hour TTL fields are now shown for both provision and unlock, matching the original implementation. Confirm passphrase field remains provisioning-only.
Spec docs: - lockdown-ui.md: TTL fields now shown in unlock mode, not just provision - data-model.md: note Lazy<MeshConnectionManager> in relationships - plan.md: correct module :core:datastore -> :core:service Tests (2 new): - NEEDS_PROVISION after lockNow does not trigger LockNowAcknowledged - UNLOCKED with no deviceAddress skips save but still authorizes
- spotlessApply across core:repository, core:service, core:ui - Android LockdownPassphraseStoreImpl: inline requirePrefs() body (FunctionSignature) - JVM LockdownPassphraseStoreImpl: suppress ReturnCount on deserialize()
Pulls in protobufs PR #916 which adds LockdownAuth.max_session_seconds (uint32, field 5) — per-boot uptime cap on the unlocked session. Wire-compatible (proto3 default 0 = unchanged behaviour). Note: this pointer is on the upstream `develop` branch — `master` has not absorbed it yet. Will need updating if the maintainer prefers a master pointer at merge time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
|
Bumped the proto submodule and threaded a new field through ( Proto bump:
What it does: when non-zero, the firmware arms an uptime timer at unlock; on expiry it revokes per-connection admin auth, re-engages screen redaction, and reboots — without deleting the token, so the next boot auto-unlocks via the boot-count TTL (decrementing Client wiring:
UI: a single optional "Session cap (minutes)" field added to Test coverage: existing unit tests still pass. I have not added new tests for the round-trip of 🤖 Generated with Claude Code |
# Conflicts: # .specify/feature.json # AGENTS.md # core/proto/src/main/proto
📄 Docs staleness check — advisoryThis PR modifies user-facing UI source files but does not update any page under
Changed source files: What to check:
New page checklist (if adding a new doc page):
If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the
|
🖼️ Preview staleness check — advisoryThis PR modifies UI composables but does not update any
Changed UI files: What to check:
Adding previews checklist:
If this PR does not require preview updates (e.g., logic-only change, non-visual refactor), add the |
Summary
Adds end-to-end UI and service support for the firmware's hardened LOCKDOWN build (see meshtastic/firmware#10349). On a locked device the client prompts for a passphrase, optionally auto-replays a stored one, surfaces backoff/lock-reason from firmware, and offers a Lock Now control.
Built against the typed schema in meshtastic/protobufs#911 —
AdminMessage.lockdown_auth = LockdownAuth(...)for provision / unlock / lock-now, andFromRadio.lockdown_status = LockdownStatus(...)for state reports.Dependency on protobufs#911
This PR will not compile in CI until the proto submodule is bumped to a revision containing
LockdownAuth(admin.proto tag 104) andLockdownStatus(mesh.proto tag 18). Opening as draft pending that bump. Local development against1c62540of protobufs works.The proto bump also introduces four new
ModemPresetenum entries (LITE_FAST/LITE_SLOW/NARROW_FAST/NARROW_SLOW) that surface aswhenexhaustiveness errors inChannel.kt,ChannelOption.kt, andModelExtensions.kt. Those fixes are intentionally not in this PR — they belong with the proto bump.Architecture
LockdownCoordinator(commonMain interface +LockdownCoordinatorImplincore/data) is the single owner of lockdown lifecycle. ProcessesLockdownStatusmessages from firmware, drivesLockdownStateexposed to UI viaServiceRepository, and manages auto-replay of cached passphrases.LockdownPassphraseStore(commonMain interface) with platform implementations:LockdownPassphraseStoreImplincore/service/androidMain):EncryptedSharedPreferencesbacked by AES-256-GCM MasterKey. Not biometric-gated — auto-replay runs without user interaction. Init failures are logged; subsequent operations fail fast for explicit error handling by callers.LockdownPassphraseStoreImplincore/service/jvmMain): PKCS12 KeyStore + AES-256-GCM file-backed store at$MESHTASTIC_DATA_DIR/lockdown/(default~/.meshtastic/lockdown/). Generates a random AES-256 master key on first use, stores per-device passphrase entries as individual.encfiles. Read failures return null; write failures throw so the coordinator can log and keep the session unlocked.LockdownStatesealed class:None,NeedsProvision,Locked(lockReason),Unlocked,UnlockFailed,UnlockBackoff(backoffSeconds),LockNowAcknowledged. Session TTL metadata is separate asLockdownTokenInfo(bootsRemaining, expiryEpoch).LockdownDialog(provision/unlock/backoff) andLockdownSessionStatus(token info display), both infeature/settings/lockdown/usingstringResource(Res.string.lockdown_*).SecurityConfigScreen→RadioConfigViewModel→LockdownCoordinator.lockNow()→CommandSender.sendLockNow().sessionAuthorizedso unauthorized clients aren't prompted to fix config they can't access.Lock Now ACK handling
The typed schema has only one LOCKED variant carrying
lock_reason. To preserve the UX where Lock Now drops the connection without flashing the passphrase dialog, the coordinator tracks a client-sidewasLockNowflag set when the user issues a lock-now command; the nextLOCKEDstatus routes toLockdownState.LockNowAcknowledged, clears the flag, resets session authorization, and clears cached radio config. This is local state, not on the wire.What changed (latest commits)
The original 4 commits established the core lockdown plumbing. The subsequent commits refactored and hardened the implementation:
Refactoring
LockdownCoordinatorandLockdownPassphraseStoreinterfaces intocore/repository(commonMain)core/datacommonMain (LockdownCoordinatorImpl)LockdownUnlockDialog.kt(replaced byLockdownDialogin feature/settings)resetTransientState()helper to dedup field resets acrossonConnect/onDisconnect/handleLockNowAcknowledgedError resilience
LockdownPassphraseStoreImpl(Android):prefsis nowSharedPreferences?with try/catch onEncryptedSharedPreferencesinit — subsequent operations fail fast viarequirePrefs()LockdownCoordinatorImpl:passphraseStore.getPassphrase(),savePassphrase(), andclearPassphrase()wrapped in try/catch — store failures don't crash sessionsclearPassphrasemoved inside try blockUI polish
stringResource(Res.string.lockdown_*)encodeToByteArray().size(UTF-8 byte length) instead of character count to match proto wire limitLockdownDialogreferencesLockdownPassphraseStore.DEFAULT_BOOTSinstead of duplicated local constantLockdownSessionStatusformats token expiry withDateFormatter.formatDateTime()Code quality
UINT32_MASKconstant with.toUInt().toLong()idiom for proto uint32 fieldsLockdownCoordinatorImpl@Volatilefields for cross-thread visibility with documented single-threaded assumptiononConfigComplete()documented as a lifecycle hook retained for future useTests & review follow-ups
LockdownCoordinatorImplTest— 17 test cases covering:wasLockNowflag behavioronConnect/onDisconnectreset0xFFFF_FFFF)FromRadioPacketHandlerImplTest— addedlockdown_statusrouting andconfig_complete_id→onConfigComplete()assertionMeshActionHandlerImplTest— addedhandleSendLockdownUnlockandhandleSendLockNowdelegation testsMeshConnectionManagerImplTest— addedonConnect()/onDisconnect()hook assertionsLockdownPassphraseStoreImplTest(jvmTest) — save/get/clear round-trip with tempuser.homeoverrideFakeLockdownCoordinatorto trackbootsandhoursparameters./gradlew spotlessApply detekt assembleDebug test allTestsSpec docs
spec.md,plan.md,tasks.md,data-model.md,quickstart.md,research.md, and contract docs) to match the shipped API, state model, module layout, and UI behaviorCommits
feat(lockdown): add LockdownState model and coordinator interfaces— pure commonMain plumbing.feat(lockdown): implement coordinator and typed status dispatch— coordinator, passphrase store, wire changes inCommandSenderImpl/FromRadioPacketHandlerImpl/MeshConnectionManagerImpl.feat(lockdown): wire AIDL, RadioController, and ViewModels— IPC + ViewModel exposure.feat(lockdown): add unlock dialog, Lock Now button, region gating— UI.fix: resolve compile errors from PR merge— merge cleanup.refactor(lockdown): extract interfaces and move coordinator to commonMain— KMP architecture.feat(lockdown): add non-dismissable LockdownDialog and app shell integration— dialog + Main.kt.feat(lockdown): Lock Now auto-disconnect, session status, provision confirm— polish.feat: implement lockdown mode authentication— error resilience, review fixes, tests.feat(lockdown): implement encrypted JVM passphrase store— replace no-op stub with PKCS12 + AES-256-GCM file-backed store.fix: finish lockdown review follow-ups— integration tests, JVM store round-trip test, edge-case coordinator tests, KDoc accuracy, spec doc sync.fix: break Koin circular dependency with Lazy<MeshConnectionManager>—MeshConnectionManagerImpl↔LockdownCoordinatorImplcycle causedStackOverflowError; defer coordinator's reference withLazy<T>.fix: use positional format specifiers and show TTL fields in unlock mode—%s/%d→%1$s/%1$dfor CMPstringResource; Boot TTL / Hour TTL shown in both provision and unlock dialogs.fix: sync spec docs and add edge-case coordinator tests— TTL fields in UI contract,Lazy<>in data-model relationships,:core:servicemodule ref; 2 new tests (NEEDS_PROVISION after lockNow, UNLOCKED with null deviceAddress).Test plan
Requires a device flashed with firmware from meshtastic/firmware#10349 (a LOCKDOWN build).
LockdownStatus(NEEDS_PROVISION)→ "Set Passphrase" dialog. Enter passphrase + optional boots/hours. VerifyUNLOCKEDwith non-zeroboots_remaining/valid_until_epoch. Settings → Security shows session info and Lock Now.LOCKEDthen auto-replay; dialog never appears. Second status isUNLOCKED.UNLOCK_FAILEDwithbackoff_seconds=0→ "Incorrect passphrase" with retry allowed.backoff_seconds>0→ countdown shown, Submit disabled.UNLOCK_FAILEDwithbackoff_seconds=0; client purges stored credential and prompts.hours=1. Wait past expiry, reboot. ExpectLOCKED, lock_reason="token_expired".boots_remaining=2. Reboot twice. ExpectLOCKED, lock_reason="token_boots_zero".LockNowAcknowledged(no dialog flash) and the BLE connection drops.AdminMessage.lockdown_auth(tag 104) is set andset_config.security.private_keyis not../gradlew :core:data:allTests :core:service:jvmTestpasses all lockdown test cases.