536cb610f6f6

widen the pairing SAS to 8 digits over 64 PRF bits and route key comparisons through CryptoCompare (v2.4.2)

time
2026-07-26 07:50 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
SAS and rate-limit hardening
parent
b4bbb429a17e
14 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+2 −5app/src/main/java/com/peachpasswords/sync/CryptoCompare.kt+18 −0app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+1 −1app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+1 −1app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+1 −1app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+2 −2app/src/main/java/com/peachpasswords/sync/SASDerivation.kt+9 −6app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+6 −2app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+1 −1app/src/test/java/com/peachpasswords/CrossPlatformKATTest.kt+2 −2app/src/test/java/com/peachpasswords/sync/ChirpSyncSuiteTest.kt+25 −12app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+1 −1
VERSION+1 −1
@@ -1 +1 @@1-2.4.11+2.4.2

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24000120-        versionName "2.4.1"19+        versionCode 24000220+        versionName "2.4.2"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+2 −5
@@ -38,7 +38,7 @@3838  * claims like this against `pairing-dial.ts`, not against this comment.3939  *   5. Noise NK, phone as RESPONDER, over binary frames4040  *   6. one encrypted PAIR_IDENTITY frame each way, BROWSER FIRST41- *   7. both derive the same 6-digit SAS from the handshake hash, and BOTH gate41+ *   7. both derive the same 8-digit SAS from the handshake hash, and BOTH gate4242  *      persistence on the user confirming it4343  *4444  * Step 7 is why [PairingSession] hands the SAS out and waits: the older@@ -357,10 +357,7 @@357357 358358     /** The token is low-value, but comparing it in constant time costs nothing. */359359     private fun constantTimeEquals(a: String, b: String): Boolean {360-        if (a.length != b.length) return false361-        var diff = 0362-        for (i in a.indices) diff = diff or (a[i].code xor b[i].code)363-        return diff == 0360+        return CryptoCompare.utf8(a, b)364361     }365362 366363     /**

view file

app/src/main/java/com/peachpasswords/sync/CryptoCompare.kt+18 −0
@@ -0,0 +1,18 @@1+package com.peachpasswords.sync2+3+import java.security.MessageDigest4+5+/**6+ * Fixed-format protocol comparisons. Wire-format length is public, but the7+ * contents are compared without a data-dependent early exit.8+ */9+internal object CryptoCompare {10+    fun utf8(left: String, right: String): Boolean =11+        MessageDigest.isEqual(12+            left.toByteArray(Charsets.UTF_8),13+            right.toByteArray(Charsets.UTF_8),14+        )15+16+    fun hex(left: String, right: String): Boolean =17+        utf8(left.lowercase(), right.lowercase())18+}

view file

app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+1 −1
@@ -12,7 +12,7 @@1212     fun get(id: String): PairedDevice?1313     fun getByPubkey(pubkeyHex: String): PairedDevice? =1414         getAll().firstOrNull {15-            it.longTermPublicKeyHex?.equals(pubkeyHex, ignoreCase = true) == true15+            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true1616         }1717     fun upsert(device: PairedDevice)1818 }

view file

app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+1 −1
@@ -141,7 +141,7 @@141141             val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))142142             require(theirIdentity.deviceId == known.deviceId) { "peer device id changed after KK" }143143             require(144-                theirIdentity.longTermPublicKeyHex.equals(initiatorKeyHex, ignoreCase = true),144+                CryptoCompare.hex(theirIdentity.longTermPublicKeyHex, initiatorKeyHex),145145             ) { "peer identity does not match authenticated KK key" }146146             transport.send(session.encrypt(encodePairIdentityFrame(localIdentity)))147147 

view file

app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+1 −1
@@ -61,7 +61,7 @@6161 6262     override fun getByPubkey(pubkeyHex: String): PairedDevice? =6363         getAll().firstOrNull {64-            it.longTermPublicKeyHex?.equals(pubkeyHex, ignoreCase = true) == true64+            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true6565         }6666 6767     /** Insert or replace by [PairedDevice.deviceId]. */

view file

app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+2 −2
@@ -145,9 +145,9 @@145145             val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))146146             require(theirIdentity.deviceId == peer.deviceId) { "peer device id changed after KK" }147147             require(148-                theirIdentity.longTermPublicKeyHex.equals(148+                CryptoCompare.hex(149+                    theirIdentity.longTermPublicKeyHex,149150                     pubkeyToHex(peer.remoteStaticPublicKey),150-                    ignoreCase = true,151151                 ),152152             ) { "peer identity does not match authenticated KK key" }153153 

view file

app/src/main/java/com/peachpasswords/sync/SASDerivation.kt+9 −6
@@ -1,12 +1,15 @@11 package com.peachpasswords.sync22 3+import java.math.BigInteger4+35 object SASDerivation {46     fun derive(handshakeHash: ByteArray): String {5-        require(handshakeHash.size >= 4) { "Handshake hash too short" }6-        val n = (((handshakeHash[0].toInt() and 0xFF) shl 24) or7-                ((handshakeHash[1].toInt() and 0xFF) shl 16) or8-                ((handshakeHash[2].toInt() and 0xFF) shl 8) or9-                (handshakeHash[3].toInt() and 0xFF)).toLong() and 0xFFFFFFFFL10-        return (n % 1_000_000L).toString().padStart(6, '0')7+        require(handshakeHash.size >= 8) { "Handshake hash too short for SAS" }8+        // Use 64 PRF-output bits so modulo bias at the 1e8 boundary is9+        // negligible. Browser pins the identical big-endian rule.10+        val value = BigInteger(1, handshakeHash.copyOfRange(0, 8))11+        return value.mod(BigInteger.valueOf(100_000_000L))12+            .toString()13+            .padStart(8, '0')1114     }1215 }

view file

app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+6 −2
@@ -77,8 +77,12 @@7777 ): SyncAlert? {7878     val nextLongTerm = incoming.longTermPublicKeyHex.lowercase()7979     val nextSigning = incoming.signingPublicKeyHex.lowercase()80-    val longTermChanged = existing.longTermPublicKeyHex?.lowercase()?.let { it != nextLongTerm } == true81-    val signingChanged = existing.signingPublicKeyHex?.lowercase()?.let { it != nextSigning } == true80+    val longTermChanged = existing.longTermPublicKeyHex?.let {81+        !CryptoCompare.hex(it, nextLongTerm)82+    } == true83+    val signingChanged = existing.signingPublicKeyHex?.let {84+        !CryptoCompare.hex(it, nextSigning)85+    } == true8286     if (!longTermChanged && !signingChanged) return null8387     return SyncAlert(8488         type = SyncAlertType.DEVICE_KEY_ROTATED,

view file

app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+1 −1
@@ -221,7 +221,7 @@221221 ): Boolean {222222     val expected = known.longTermPublicKeyHex ?: return false223223     return known.deviceId == presented.deviceId &&224-        expected.equals(presented.longTermPublicKeyHex, ignoreCase = true) &&224+        CryptoCompare.hex(expected, presented.longTermPublicKeyHex) &&225225         MessageDigest.isEqual(authenticatedKey, hexToPubkey(presented.longTermPublicKeyHex))226226 }227227 

view file

app/src/test/java/com/peachpasswords/CrossPlatformKATTest.kt+2 −2
@@ -59,9 +59,9 @@5959     }6060 6161     @Test fun sas_derivation_vector() {62-        // First 4 bytes 0x12345678 = 305419896; 305419896 mod 1_000_000 = 419896.62+        // First 8 bytes are unsigned big-endian, modulo 100_000_000.6363         val handshakeHash = byteArrayOf(0x12, 0x34, 0x56, 0x78) + ByteArray(28)64-        assertEquals("419896", SASDerivation.derive(handshakeHash))64+        assertEquals("67721216", SASDerivation.derive(handshakeHash))6565     }6666 6767     // PCH-01-002: the Noise protocol-name init hash and the MixKey/Split HKDF now

view file

app/src/test/java/com/peachpasswords/sync/ChirpSyncSuiteTest.kt+25 −12
@@ -6,7 +6,9 @@66 import com.peachpasswords.vault.VaultRepository77 import com.peachpasswords.vault.VaultSettings88 import com.peachpasswords.vault.VaultStorage9+import kotlinx.coroutines.async910 import kotlinx.coroutines.runBlocking11+import kotlinx.coroutines.yield1012 import org.junit.Assert.assertArrayEquals1113 import org.junit.Assert.assertEquals1214 import org.junit.Assert.assertFalse@@ -89,28 +91,39 @@8991 9092     // ── SAS derivation ────────────────────────────────────────────────────────9193 92-    @Test fun sas_is_six_digits_zero_padded() {93-        assertEquals("000000", SASDerivation.derive(byteArrayOf(0, 0, 0, 0)))94-        assertEquals("000005", SASDerivation.derive(byteArrayOf(0, 0, 0, 5)))94+    @Test fun sas_is_eight_digits_zero_padded() {95+        assertEquals("00000000", SASDerivation.derive(ByteArray(8)))96+        assertEquals("00000005", SASDerivation.derive(byteArrayOf(0, 0, 0, 0, 0, 0, 0, 5)))9597     }9698 9799     @Test fun sas_kat_known_vectors() {98-        // First 4 bytes as big-endian uint32, mod 1_000_000.99-        assertEquals("999999", SASDerivation.derive(byteArrayOf(0x00, 0x0F, 0x42, 0x3F)))100-        assertEquals("000000", SASDerivation.derive(byteArrayOf(0x00, 0x0F, 0x42, 0x40))) // 1_000_000101-        assertEquals("967295", SASDerivation.derive(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())))100+        // First 8 bytes as an unsigned big-endian integer, mod 100_000_000.101+        assertEquals(102+            "52306183",103+            SASDerivation.derive(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7)),104+        )105+        assertEquals("09551615", SASDerivation.derive(ByteArray(8) { 0xFF.toByte() }))102106     }103107 104-    @Test fun sas_ignores_bytes_past_the_first_four() {105-        val a = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 9, 9, 9))106-        val b = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 0, 0, 0))108+    @Test fun sas_ignores_bytes_past_the_first_eight() {109+        val a = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9))110+        val b = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0))107111         assertEquals(a, b)108112     }109113 110-    @Test fun sas_requires_at_least_four_bytes() {114+    @Test fun sas_requires_at_least_eight_bytes() {111115         var threw = false112-        try { SASDerivation.derive(byteArrayOf(1, 2, 3)) } catch (_: IllegalArgumentException) { threw = true }116+        try { SASDerivation.derive(ByteArray(7)) } catch (_: IllegalArgumentException) { threw = true }113117         assertTrue(threw)118+    }119+120+    @Test fun sas_gate_never_completes_without_explicit_user_confirmation() = runBlocking {121+        val session = PairingSession()122+        val waiter = async { session.await() }123+        yield()124+        assertFalse("SAS session must not auto-confirm", waiter.isCompleted)125+        session.confirm(true)126+        assertTrue(waiter.await())114127     }115128 116129     // ── Cross-device entry sync (shared root key via Codex) ───────────────────

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+1 −1
@@ -75,7 +75,7 @@7575                 },7676                 confirm = { sas, request ->7777                     assertEquals("receiver", request.deviceName)78-                    assertEquals(6, sas.length)78+                    assertEquals(8, sas.length)7979                     true8080                 },8181             )

view file