From 407dff34ed6b04fbf5bb8b1bb549b4d481dd2a3b Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 25 Aug 2026 08:03:06 +0530 Subject: [PATCH 1/2] fix: reject a future-dated verification QR Freshness only subtracted now - ts, so a timestamp in the future made the age negative and kept the QR valid too long. Match iOS abs() skew so a backdated clock cannot extend the 5-minute window. --- .../android/services/VerificationService.kt | 10 ++- .../services/VerificationServiceTest.kt | 68 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/services/VerificationServiceTest.kt diff --git a/app/src/main/java/com/bitchat/android/services/VerificationService.kt b/app/src/main/java/com/bitchat/android/services/VerificationService.kt index 06dcd6f3..45127634 100644 --- a/app/src/main/java/com/bitchat/android/services/VerificationService.kt +++ b/app/src/main/java/com/bitchat/android/services/VerificationService.kt @@ -146,7 +146,10 @@ object VerificationService { val service = encryptionServiceRef?.get() ?: return null val qr = VerificationQR.fromUrlString(urlString) ?: return null val now = System.currentTimeMillis() / 1000L - if (now - qr.ts > maxAgeSeconds) return null + // Freshness in both directions: a future-dated timestamp must not + // buy a QR a longer validity window than a fresh one gets. iOS uses + // the same abs() check in VerificationService.verifyScannedQR. + if (verificationTimestampSkewSeconds(now, qr.ts) > maxAgeSeconds) return null val sig = qr.sigHex.dataFromHexString() ?: return null val signKey = qr.signKeyHex.dataFromHexString() ?: return null @@ -292,3 +295,8 @@ object VerificationService { var last: CacheEntry? = null } } + +/** Absolute age of a verification QR timestamp, in seconds. */ +internal fun verificationTimestampSkewSeconds(nowSeconds: Long, qrTimestampSeconds: Long): Long { + return kotlin.math.abs(nowSeconds - qrTimestampSeconds) +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/VerificationServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/services/VerificationServiceTest.kt new file mode 100644 index 00000000..27436d0b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/VerificationServiceTest.kt @@ -0,0 +1,68 @@ +package com.bitchat.android.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.util.hexEncodedString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VerificationServiceTest { + private lateinit var encryptionService: EncryptionService + + @Before + fun setup() { + val context: Context = ApplicationProvider.getApplicationContext() + encryptionService = EncryptionService(context) + VerificationService.configure(encryptionService) + } + + @Test + fun `freshness check rejects both stale and future-dated timestamps`() { + assertEquals(0L, verificationTimestampSkewSeconds(1_700_000_000L, 1_700_000_000L)) + assertEquals(60L, verificationTimestampSkewSeconds(1_700_000_060L, 1_700_000_000L)) + assertEquals(3_600L, verificationTimestampSkewSeconds(1_700_000_000L, 1_700_003_600L)) + } + + @Test + fun `verifyScannedQR accepts a freshly signed payload`() { + val qr = VerificationService.buildMyQRString(nickname = "alice", npub = null) + assertNotNull(qr) + assertNotNull(VerificationService.verifyScannedQR(qr!!, maxAgeSeconds = 60)) + } + + @Test + fun `verifyScannedQR rejects a future-dated payload`() { + val qr = signedQr(ts = (System.currentTimeMillis() / 1000L) + 3_600L) + assertNull(VerificationService.verifyScannedQR(qr, maxAgeSeconds = 60)) + } + + @Test + fun `verifyScannedQR rejects an expired payload`() { + val qr = signedQr(ts = (System.currentTimeMillis() / 1000L) - 3_600L) + assertNull(VerificationService.verifyScannedQR(qr, maxAgeSeconds = 60)) + } + + private fun signedQr(ts: Long): String { + val noise = encryptionService.getStaticPublicKey()!!.joinToString("") { "%02x".format(it) } + val sign = encryptionService.getSigningPublicKey()!!.joinToString("") { "%02x".format(it) } + val payload = VerificationService.VerificationQR( + v = 1, + noiseKeyHex = noise, + signKeyHex = sign, + npub = null, + nickname = "future-alice", + ts = ts, + nonceB64 = "AAAAAAAAAAAAAAAAAAAAAA", + sigHex = "" + ) + val signature = encryptionService.signData(payload.canonicalBytes())!! + return payload.copy(sigHex = signature.hexEncodedString()).toUrlString() + } +}