feature: Add TAK passphrase lock/unlock support

Implement the client-side TAK passphrase authentication flow for
  devices running TAK-locked firmware.

  Key components:
  - TakPassphraseStore: per-device passphrase persistence using
    EncryptedSharedPreferences (Android Keystore AES-256-GCM), with
    boot and hour TTL fields stored alongside the passphrase
  - TakLockHandler: orchestrates the full lock/unlock lifecycle —
    auto-unlock on reconnect using stored credentials, passphrase
    submission, token info parsing, and backoff/failure handling
  - MeshCommandSender: sendTakPassphrase() and sendTakLockNow() build
    plain local packets that bypass PKC signing and session_passkey;
    hour TTL is encoded as an absolute Unix epoch as required by firmware
  - ServiceRepository: TakLockState sealed class (None, Locked,
    NeedsProvision, Unlocked, LockNowAcknowledged, UnlockFailed,
    UnlockBackoff), TakTokenInfo (boots remaining + expiry epoch), and
    sessionAuthorized flag
  - TakUnlockDialog: Compose dialog for passphrase entry, shown on
    Locked and NeedsProvision states; onDismissRequest is a no-op to
    prevent race conditions with firmware response timing; cancel
    disconnects the user and navigates to the Connections tab
  - Lock Now (Security settings): immediately disconnects the client
    after informing firmware, purges cached config, navigates away
    without showing a passphrase dialog
  - ConnectionsScreen: suppress "region unset" prompt while the device
    is TAK-locked, since pre-auth config is zeroed/redacted and would
    lead the user to a blank LoRa settings screen
  - AIDL: sendTakUnlock() and sendTakLockNow() wired through
    MeshService → MeshActionHandler → TakLockHandler
  - Security settings: "Lock Now (TAK)" button and token info display
    showing boots remaining and expiry date
This commit is contained in:
niccellular 2026-02-27 08:31:05 -05:00
parent 986c60ce88
commit e7ba8e8497
26 changed files with 753 additions and 8 deletions

View file

@ -189,4 +189,10 @@ interface IMeshService {
* hash is the 32-byte firmware SHA256 hash (optional, can be null)
*/
void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash);
/// Send TAK unlock passphrase to the device
void sendTakUnlock(in String passphrase, in int bootTtl, in int hourTtl);
/// Lock the device with TAK lock immediately
void sendTakLockNow();
}

@ -1 +1 @@
Subproject commit e2d1873a6fef7b4b63525cdd014790298d80bef8
Subproject commit bc63a57f9e5dba8a7c90ee0bd4a9840862d61f6d

View file

@ -28,9 +28,34 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
sealed class TakLockState {
data object None : TakLockState()
data object Locked : TakLockState()
data object NeedsProvision : TakLockState()
data object Unlocked : TakLockState()
/** Lock Now ACK received — client should disconnect immediately, no dialog. */
data object LockNowAcknowledged : TakLockState()
/** Wrong passphrase — retry immediately. */
data object UnlockFailed : TakLockState()
/** Too many attempts — must wait [backoffSeconds] before retrying. */
data class UnlockBackoff(val backoffSeconds: Int) : TakLockState()
}
/**
* TAK session token metadata parsed from the TAK_UNLOCKED:boots=N:until=EPOCH: notification.
*
* @param bootsRemaining Number of reboots before the token expires.
* @param expiryEpoch Unix epoch seconds; 0 means no time-based expiry.
*/
data class TakTokenInfo(
val bootsRemaining: Int,
val expiryEpoch: Long,
)
sealed class RetryEvent {
abstract val packetId: Int
abstract val attemptNumber: Int
@ -159,6 +184,38 @@ class ServiceRepository @Inject constructor() {
_serviceAction.send(action)
}
// TAK lock state
private val _takLockState: MutableStateFlow<TakLockState> = MutableStateFlow(TakLockState.None)
val takLockState: StateFlow<TakLockState>
get() = _takLockState
fun setTakLockState(state: TakLockState) {
_takLockState.value = state
}
fun clearTakLockState() {
_takLockState.value = TakLockState.None
}
// TAK token info (boots remaining + expiry) from the most recent TAK_UNLOCKED notification
private val _takTokenInfo: MutableStateFlow<TakTokenInfo?> = MutableStateFlow(null)
val takTokenInfo: StateFlow<TakTokenInfo?>
get() = _takTokenInfo
fun setTakTokenInfo(info: TakTokenInfo?) {
_takTokenInfo.value = info
}
// True once TAK passphrase is accepted for this BLE connection; false on disconnect.
private val _sessionAuthorized: MutableStateFlow<Boolean> = MutableStateFlow(false)
val sessionAuthorized: StateFlow<Boolean>
get() = _sessionAuthorized
fun setSessionAuthorized(authorized: Boolean) {
_sessionAuthorized.value = authorized
}
// Retry management
private val _retryEvents = MutableStateFlow<RetryEvent?>(null)
val retryEvents: StateFlow<RetryEvent?>

View file

@ -120,4 +120,8 @@ open class FakeIMeshService : IMeshService.Stub() {
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {}
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
override fun sendTakUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) {}
override fun sendTakLockNow() {}
}

View file

@ -117,4 +117,8 @@ open class FakeIMeshService : IMeshService.Stub() {
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {}
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
override fun sendTakUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) {}
override fun sendTakLockNow() {}
}