mirror of
https://github.com/KDE/kdeconnect-android
synced 2025-08-22 18:07:55 +00:00
Make the verification key change every time in protocol v8
This commit is contained in:
parent
b4ee6e30b1
commit
7a4fb8b584
@ -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>
|
||||
|
@ -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))
|
||||
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
47
tests/org/kde/kdeconnect/PairingHandlerTest.kt
Normal file
47
tests/org/kde/kdeconnect/PairingHandlerTest.kt
Normal 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))
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user