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
↓ peach-android-536cb610.tar.gzatlas at this save
files changed14 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 −1app/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 {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 /**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+}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 }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 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]. */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 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 }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,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 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 nowapp/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) ───────────────────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 )