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

@ -36,6 +36,7 @@ import org.meshtastic.core.model.util.isWithinSizeLimit
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.Constants
import org.meshtastic.proto.Data
import org.meshtastic.proto.LocalConfig
@ -44,6 +45,7 @@ import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicLong
@ -467,6 +469,68 @@ constructor(
),
)
fun sendTakPassphrase(passphrase: String, boots: Int = 0, hours: Int = 0) {
val myNum = nodeManager?.myNodeNum ?: return
// The firmware expects slot 2 as an absolute Unix epoch (seconds), not a duration.
// Convert hours duration → absolute epoch; 0 hours means no time-based expiry (until=0).
val adminKeyList = if (boots > 0 || hours > 0) {
val untilEpoch = if (hours > 0) System.currentTimeMillis() / 1000L + hours.toLong() * 3600L else 0L
val untilBytes = ByteArray(INT_BYTE_SIZE)
untilBytes[0] = (untilEpoch and BYTE_MASK.toLong()).toByte()
untilBytes[1] = ((untilEpoch shr BYTE_BITS) and BYTE_MASK.toLong()).toByte()
untilBytes[2] = ((untilEpoch shr (BYTE_BITS * 2)) and BYTE_MASK.toLong()).toByte()
untilBytes[3] = ((untilEpoch shr (BYTE_BITS * 3)) and BYTE_MASK.toLong()).toByte()
listOf(
ByteString.EMPTY, // slot 0 unused
ByteString.of(boots.coerceIn(1, MAX_BYTE_VALUE).toByte()), // slot 1: boots u8
untilBytes.toByteString(), // slot 2: until epoch LE u32
)
} else {
emptyList()
}
val securityConfig = Config.SecurityConfig(
private_key = passphrase.encodeToByteArray().toByteString(),
admin_key = adminKeyList,
)
val adminMessage = AdminMessage(set_config = Config(security = securityConfig))
val packet = MeshPacket(
to = myNum,
id = generatePacketId(),
channel = 0,
want_ack = true,
hop_limit = DEFAULT_HOP_LIMIT,
hop_start = DEFAULT_HOP_LIMIT,
priority = MeshPacket.Priority.RELIABLE,
decoded = Data(
portnum = PortNum.ADMIN_APP,
payload = adminMessage.encode().toByteString(),
),
)
packetHandler?.sendToRadio(ToRadio(packet = packet))
}
fun sendTakLockNow() {
val myNum = nodeManager?.myNodeNum ?: return
val securityConfig = Config.SecurityConfig(
private_key = ByteString.of(TAK_LOCK_BYTE),
)
val adminMessage = AdminMessage(set_config = Config(security = securityConfig))
val packet = MeshPacket(
to = myNum,
id = generatePacketId(),
channel = 0,
want_ack = true,
hop_limit = DEFAULT_HOP_LIMIT,
hop_start = DEFAULT_HOP_LIMIT,
priority = MeshPacket.Priority.RELIABLE,
decoded = Data(
portnum = PortNum.ADMIN_APP,
payload = adminMessage.encode().toByteString(),
),
)
packetHandler?.sendToRadio(ToRadio(packet = packet))
}
companion object {
private const val PACKET_ID_MASK = 0xffffffffL
private const val PACKET_ID_SHIFT_BITS = 32
@ -478,5 +542,12 @@ constructor(
private const val HEX_RADIX = 16
private const val DEFAULT_HOP_LIMIT = 3
private const val MAX_BYTE_VALUE = 255
private const val INT_BYTE_SIZE = 4
private const val BYTE_MASK = 0xFF
private const val BYTE_BITS = 8
@Suppress("MagicNumber")
private val TAK_LOCK_BYTE = 0xFF.toByte()
}
}