2
0
mirror of https://github.com/KDE/kdeconnect-android synced 2025-08-22 01:51:47 +00:00

Make the verification key change every time in protocol v8

This commit is contained in:
Albert Vaca Cintora 2025-01-19 00:37:24 +01:00
parent b4ee6e30b1
commit 7a4fb8b584
7 changed files with 159 additions and 76 deletions

View File

@ -176,6 +176,7 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<string name="error_not_reachable">Device not reachable</string>
<string name="error_already_paired">Device already paired</string>
<string name="error_timed_out">Timed out</string>
<string name="error_clocks_not_match">Device clocks are out of sync</string>
<string name="error_canceled_by_user">Canceled by user</string>
<string name="error_canceled_by_other_peer">Canceled by other peer</string>
<string name="encryption_info_title">Encryption Info</string>

View File

@ -159,19 +159,18 @@ class Device : PacketReceiver {
val certificate: Certificate
get() = deviceInfo.certificate
val verificationKey: String?
get() = pairingHandler.verificationKey()
// Returns 0 if the version matches, < 0 if it is older or > 0 if it is newer
fun compareProtocolVersion(): Int =
deviceInfo.protocolVersion - DeviceHelper.ProtocolVersion
val isPaired: Boolean
get() = pairingHandler.state == PairingHandler.PairState.Paired
val isPairRequested: Boolean
get() = pairingHandler.state == PairingHandler.PairState.Requested
val isPairRequestedByPeer: Boolean
get() = pairingHandler.state == PairingHandler.PairState.RequestedByPeer
val pairStatus : PairingHandler.PairState
get() = pairingHandler.state
fun addPairingCallback(callback: PairingCallback) = pairingCallbacks.add(callback)
@ -289,8 +288,6 @@ class Device : PacketReceiver {
val notificationManager = ContextCompat.getSystemService(context, NotificationManager::class.java)!!
val verificationKey = SslHelper.getVerificationKey(SslHelper.certificate, deviceInfo.certificate)
val noti = NotificationCompat.Builder(context, NotificationHelper.Channels.DEFAULT)
.setContentTitle(res.getString(R.string.pairing_request_from, name))
.setContentText(res.getString(R.string.pairing_verification_code, verificationKey))

View File

@ -282,31 +282,4 @@ public class SslHelper {
return IETFUtils.valueToString(rdn.getFirst().getValue());
}
public static String getVerificationKey(Certificate certificateA, Certificate certificateB) {
try {
byte[] a = certificateA.getPublicKey().getEncoded();
byte[] b = certificateB.getPublicKey().getEncoded();
if (Arrays.compareUnsigned(a, b) < 0) {
// Swap them so on both devices they are in the same order
byte[] aux = a;
a = b;
b = aux;
}
byte[] concat = new byte[a.length + b.length];
System.arraycopy(a, 0, concat, 0, a.length);
System.arraycopy(b, 0, concat, a.length, b.length);
byte[] hash = MessageDigest.getInstance("SHA-256").digest(concat);
Formatter formatter = new Formatter();
for (byte value : hash) {
formatter.format("%02x", value);
}
return formatter.toString().substring(0,8).toUpperCase(Locale.ROOT);
} catch(Exception e) {
e.printStackTrace();
return "error";
}
}
}

View File

@ -8,7 +8,13 @@ package org.kde.kdeconnect
import android.util.Log
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.*
import org.bouncycastle.util.Arrays
import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper
import org.kde.kdeconnect_tp.R
import java.security.MessageDigest
import java.security.cert.Certificate
import java.util.Formatter
import kotlin.math.abs
import kotlin.time.Duration.Companion.seconds
class PairingHandler(private val device: Device, private val callback: PairingCallback, var state: PairState) {
@ -31,6 +37,7 @@ class PairingHandler(private val device: Device, private val callback: PairingCa
private val pairingJob = SupervisorJob()
private val pairingScope = CoroutineScope(Dispatchers.IO + pairingJob)
private var pairingTimestamp = 0L
fun packetReceived(np: NetworkPacket) {
cancelTimer()
@ -50,10 +57,26 @@ class PairingHandler(private val device: Device, private val callback: PairingCa
Log.w("PairingHandler", "Received pairing request from a device we already trusted.")
// It would be nice to auto-accept the pairing request here, but since the pairing accept and pairing request
// messages are identical, this could create an infinite loop if both devices are "accepting" each other pairs.
// Instead, unpair and handle as if "NotPaired".
// Instead, unpair and handle as if "NotPaired". TODO: No longer true in protocol version 8
state = PairState.NotPaired
callback.unpaired()
}
if (device.protocolVersion >= 8) {
pairingTimestamp = np.getLong("timestamp", -1L)
if (pairingTimestamp == -1L) {
state = PairState.NotPaired
callback.unpaired()
return
}
val currentTimestamp = System.currentTimeMillis() / 1000L
if (abs(pairingTimestamp - currentTimestamp) > allowedTimestampDifferenceSeconds) {
state = PairState.NotPaired
callback.pairingFailed(device.context.getString(R.string.error_clocks_not_match))
return
}
}
state = PairState.RequestedByPeer
pairingScope.launch {
@ -85,6 +108,18 @@ class PairingHandler(private val device: Device, private val callback: PairingCa
}
}
fun verificationKey(): String? {
return if (device.protocolVersion >= 8) {
if (state != PairState.Requested && state != PairState.RequestedByPeer) {
return null
} else {
getVerificationKey(SslHelper.certificate, device.certificate, pairingTimestamp)
}
} else {
getVerificationKeyV7(SslHelper.certificate, device.certificate)
}
}
fun requestPairing() {
cancelTimer()
@ -126,6 +161,8 @@ class PairingHandler(private val device: Device, private val callback: PairingCa
}
val np = NetworkPacket(NetworkPacket.PACKET_TYPE_PAIR)
np["pair"] = true
pairingTimestamp = System.currentTimeMillis() / 1000L
np["timestamp"] = pairingTimestamp
device.sendPacket(np, statusCallback)
}
@ -181,4 +218,36 @@ class PairingHandler(private val device: Device, private val callback: PairingCa
private fun cancelTimer() {
pairingJob.cancelChildren()
}
companion object {
private const val allowedTimestampDifferenceSeconds = 1_800 // 30 minutes
// Concatenate in a deterministic order so on both devices the result is the same
private fun sortedConcat(a: ByteArray, b: ByteArray): ByteArray {
return if (Arrays.compareUnsigned(a, b) < 0) {
b + a
} else {
a + b
}
}
private fun humanReadableHash(bytes: ByteArray): String {
val hash = MessageDigest.getInstance("SHA-256").digest(bytes)
val formatter = Formatter()
for (value in hash) {
formatter.format("%02x", value)
}
return formatter.toString().substring(0, 8).uppercase()
}
fun getVerificationKey(certificateA: Certificate, certificateB: Certificate, timestamp: Long): String {
val certsConcat = sortedConcat(certificateA.publicKey.encoded, certificateB.publicKey.encoded)
return humanReadableHash(certsConcat + timestamp.toString().toByteArray())
}
fun getVerificationKeyV7(certificateA: Certificate, certificateB: Certificate): String {
val certsConcat = sortedConcat(certificateA.publicKey.encoded, certificateB.publicKey.encoded)
return humanReadableHash(certsConcat)
}
}
}

View File

@ -115,16 +115,9 @@ class DeviceFragment : Fragment() {
this.refreshDevicesAction()
}
requirePairingBinding().pairVerification.text = SslHelper.getVerificationKey(SslHelper.certificate, device?.certificate)
requirePairingBinding().pairButton.setOnClickListener {
with(requirePairingBinding()) {
pairButton.visibility = View.GONE
pairMessage.text = getString(R.string.pair_requested)
pairProgress.visibility = View.VISIBLE
}
device?.requestPairing()
mActivity?.invalidateOptionsMenu()
refreshUI()
}
requirePairingBinding().acceptButton.setOnClickListener {
device?.apply {
@ -242,7 +235,7 @@ class DeviceFragment : Fragment() {
true
}
}
if (device.isPairRequested) {
if (device.pairStatus == PairingHandler.PairState.Requested) {
menu.add(R.string.cancel_pairing).setOnMenuItemClickListener {
device.cancelPairing()
true
@ -275,19 +268,36 @@ class DeviceFragment : Fragment() {
//Once in-app, there is no point in keep displaying the notification if any
device.hidePairingNotification()
if (device.isPairRequestedByPeer) {
with (requirePairingBinding()) {
pairMessage.setText(R.string.pair_requested)
pairVerification.visibility = View.VISIBLE
pairVerification.text = SslHelper.getVerificationKey(SslHelper.certificate, device.certificate)
pairingButtons.visibility = View.VISIBLE
pairProgress.visibility = View.GONE
pairButton.visibility = View.GONE
pairRequestButtons.visibility = View.VISIBLE
when (device.pairStatus) {
PairingHandler.PairState.NotPaired -> {
requireErrorBinding().errorMessageContainer.visibility = View.GONE
requireDeviceBinding().deviceView.visibility = View.GONE
requirePairingBinding().pairingButtons.visibility = View.VISIBLE
requirePairingBinding().pairVerification.visibility = View.GONE
}
requireDeviceBinding().deviceView.visibility = View.GONE
} else {
if (device.isPaired) {
PairingHandler.PairState.Requested -> {
with(requirePairingBinding()) {
pairButton.visibility = View.GONE
pairMessage.text = getString(R.string.pair_requested)
pairProgress.visibility = View.VISIBLE
pairVerification.text = device.verificationKey
pairVerification.visibility = View.VISIBLE
}
}
PairingHandler.PairState.RequestedByPeer -> {
with (requirePairingBinding()) {
pairMessage.setText(R.string.pair_requested)
pairVerification.visibility = View.VISIBLE
pairingButtons.visibility = View.VISIBLE
pairProgress.visibility = View.GONE
pairButton.visibility = View.GONE
pairRequestButtons.visibility = View.VISIBLE
pairVerification.text = device.verificationKey
pairVerification.visibility = View.VISIBLE
}
requireDeviceBinding().deviceView.visibility = View.GONE
}
PairingHandler.PairState.Paired -> {
requirePairingBinding().pairingButtons.visibility = View.GONE
if (device.isReachable) {
val context = requireContext()
@ -305,13 +315,9 @@ class DeviceFragment : Fragment() {
requireErrorBinding().errorMessageContainer.visibility = View.VISIBLE
requireDeviceBinding().deviceView.visibility = View.GONE
}
} else {
requireErrorBinding().errorMessageContainer.visibility = View.GONE
requireDeviceBinding().deviceView.visibility = View.GONE
requirePairingBinding().pairingButtons.visibility = View.VISIBLE
}
mActivity?.invalidateOptionsMenu()
}
mActivity?.invalidateOptionsMenu()
}
private val pairingCallback: PairingHandler.PairingCallback = object : PairingHandler.PairingCallback {

View File

@ -12,6 +12,7 @@ import org.junit.Before
import org.junit.Test
import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper
import org.kde.kdeconnect.MockSharedPreference
import org.kde.kdeconnect.PairingHandler
import org.mockito.ArgumentMatchers
import org.mockito.MockedStatic
import org.mockito.Mockito
@ -22,7 +23,6 @@ import java.util.Base64
class SSLHelperTest {
private lateinit var context: Context
private lateinit var sharedPreferences: MockSharedPreference
/** Certificate captured from debug session */
private val certificateBase64 = "MIIBkzCCATmgAwIBAgIBATAKBggqhkjOPQQDBDBTMS0wKwYDVQQDDCRlZTA2MWE3NV9lNDAzXzRlY2NfOTI2MV81ZmZlMjcyMmY2OTgxFDASBgNVBAsMC0tERSBDb25uZWN0MQwwCgYDVQQKDANLREUwHhcNMjMwOTE1MjIwMDAwWhcNMzQwOTE1MjIwMDAwWjBTMS0wKwYDVQQDDCRlZTA2MWE3NV9lNDAzXzRlY2NfOTI2MV81ZmZlMjcyMmY2OTgxFDASBgNVBAsMC0tERSBDb25uZWN0MQwwCgYDVQQKDANLREUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASqOIKTm5j6x8DKgYSkItLmjCgIXP0gkOW6bmVvloDGsYnvqYLMFGe7YW8g8lT/qPBTEfDOM4UpQ8X6jidE+XrnMAoGCCqGSM49BAMEA0gAMEUCIEpk6VNpbt3tfbWDf0TmoJftRq3wAs3Dke7d5vMZlivyAiEA/ZXtSRqPjs/2RN9SynKhSUA9/z0PNq6LYoAaC6TdomM="
private val certificateHash = "fc:1f:b3:d3:d3:3b:23:42:e4:5c:74:b1:a6:13:dc:df:e5:e1:f0:29:d6:68:24:9f:50:49:52:a9:a8:04:1e:31:"
private val deviceId = "testDevice"
@ -40,13 +40,13 @@ class SSLHelperTest {
mockBase64.`when`<Any> {
android.util.Base64.encodeToString(ArgumentMatchers.any(ByteArray::class.java), ArgumentMatchers.anyInt())
}.thenAnswer { invocation: InvocationOnMock ->
java.util.Base64.getMimeEncoder().encodeToString(invocation.arguments[0] as ByteArray)
Base64.getMimeEncoder().encodeToString(invocation.arguments[0] as ByteArray)
}
mockBase64.`when`<Any> {
android.util.Base64.decode(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())
}.thenAnswer { invocation: InvocationOnMock ->
java.util.Base64.getMimeDecoder().decode(invocation.arguments[0] as String)
Base64.getMimeDecoder().decode(invocation.arguments[0] as String)
}
this.mockBase64 = mockBase64
@ -112,14 +112,4 @@ class SSLHelperTest {
Assert.assertEquals(expected, commonName)
}
@Test
fun getVerificationKey() {
sharedPreferences.edit().putString(certificateKey, certificateBase64).apply()
val cert = SslHelper.getDeviceCertificate(context, deviceId)
// Normally not used with same certificate, but it's fine for testing
val verificationKey = SslHelper.getVerificationKey(cert, cert)
val expected = "97A75917"
Assert.assertEquals(expected, verificationKey)
}
}
}

View File

@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2025 Albert Vaca Cintora <albertvaka@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect
import org.junit.Assert
import org.junit.Test
import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper
import java.util.Base64
class PairingHandlerTest {
private val certA = SslHelper.parseCertificate(Base64.getMimeDecoder().decode(
"MIIBkzCCATmgAwIBAgIBATAKBggqhkjOPQQDBDBTMS0wKwYDVQQDDCRlZTA2MWE3NV9lNDAzXzRlY2NfOTI2" +
"MV81ZmZlMjcyMmY2OTgxFDASBgNVBAsMC0tERSBDb25uZWN0MQwwCgYDVQQKDANLREUwHhcNMjMwOTE1MjIw" +
"MDAwWhcNMzQwOTE1MjIwMDAwWjBTMS0wKwYDVQQDDCRlZTA2MWE3NV9lNDAzXzRlY2NfOTI2MV81ZmZlMjcy" +
"MmY2OTgxFDASBgNVBAsMC0tERSBDb25uZWN0MQwwCgYDVQQKDANLREUwWTATBgcqhkjOPQIBBggqhkjOPQMB" +
"BwNCAASqOIKTm5j6x8DKgYSkItLmjCgIXP0gkOW6bmVvloDGsYnvqYLMFGe7YW8g8lT/qPBTEfDOM4UpQ8X6" +
"jidE+XrnMAoGCCqGSM49BAMEA0gAMEUCIEpk6VNpbt3tfbWDf0TmoJftRq3wAs3Dke7d5vMZlivyAiEA/ZXt" +
"SRqPjs/2RN9SynKhSUA9/z0PNq6LYoAaC6TdomM="
))
private val certB = SslHelper.parseCertificate(Base64.getMimeDecoder().decode(
"MIIBkzCCATmgAwIBAgIBATAKBggqhkjOPQQDBDBTMS0wKwYDVQQDDCQxNTdiYmMyOF82ZjJiXzRiMTZfYmQw" +
"Ml8xMzM0NWMwMjU0M2MxFDASBgNVBAsMC0tERSBDb25uZWN0MQwwCgYDVQQKDANLREUwHhcNMjQwMTE3MjMw" +
"MDAwWhcNMzUwMTE3MjMwMDAwWjBTMS0wKwYDVQQDDCQxNTdiYmMyOF82ZjJiXzRiMTZfYmQwMl8xMzM0NWMw" +
"MjU0M2MxFDASBgNVBAsMC0tERSBDb25uZWN0MQwwCgYDVQQKDANLREUwWTATBgcqhkjOPQIBBggqhkjOPQMB" +
"BwNCAAQ5W53rrDJps9v/sszQf0eLtvoGiRbfsY+snO6IJJfi1pFeHDQj2nAE+aTyUYelrcx1eIuqxFHnJTFt" +
"/HqXwuAvMAoGCCqGSM49BAMEA0gAMEUCIBIk3zKPz/M0c82nvCGFDXGGmfdojHsx3G5DbYNNKqFVAiEAzhBG" +
"e960/4NDiaVcOplBaeg5xNJKs3Kq+22J6JOii4Y="))
@Test
fun getVerificationKey() {
val timestampA = 1737228658L
val timestampB = 2737228658L
Assert.assertEquals("54DC916E", PairingHandler.getVerificationKey(certA, certB, timestampA))
Assert.assertEquals("54DC916E", PairingHandler.getVerificationKey(certB, certA, timestampA))
Assert.assertEquals("8C07153A", PairingHandler.getVerificationKey(certA, certB, timestampB))
}
@Test
fun getVerificationKeyV7() {
Assert.assertEquals("F3900DB5", PairingHandler.getVerificationKeyV7(certA, certB))
Assert.assertEquals("F3900DB5", PairingHandler.getVerificationKeyV7(certB, certA))
Assert.assertEquals("97A75917", PairingHandler.getVerificationKeyV7(certA, certA))
}
}