8ee5d862e957

re-pin golden vector to the browser's 4-byte token, enforce exact token size on encode, note VPNs on the pairing screen

time
2026-07-24 23:45 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
pairing code sync
parent
792a4039d36c
3 files app/src/main/java/com/peachpasswords/sync/PairingCode.kt+12 −2app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+15 −0app/src/test/java/com/peachpasswords/sync/PairingCodeTest.kt+25 −7
app/src/main/java/com/peachpasswords/sync/PairingCode.kt+12 −2
@@ -26,7 +26,11 @@2626     private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"2727     private const val IP_BYTES = 42828     private const val PORT_BYTES = 229+    // Decoding stays lenient about token length so a peer on an older build30+    // (8-byte tokens, 23 characters) still pairs instead of hard-failing.31+    // Encoding is strict — see TOKEN_BYTES.2932     private const val MIN_TOKEN_BYTES = 433+    private const val MAX_TOKEN_BYTES = 163034 3135     /**3236      * 4 bytes, which is what a four-group code actually costs.@@ -61,7 +65,12 @@6165         val octets = ip.split(".")6266         require(octets.size == IP_BYTES) { "ip must be dotted-quad IPv4" }6367         require(port in 1..65535) { "invalid port: $port" }64-        require(token.size >= MIN_TOKEN_BYTES) { "token must be at least $MIN_TOKEN_BYTES bytes" }68+        // Strict, mirroring the browser: a longer token silently produces a69+        // longer code, which is exactly the bug this guard exists to prevent.70+        require(token.size == TOKEN_BYTES) {71+            "token must be exactly $TOKEN_BYTES bytes (got ${token.size}); " +72+                "every extra byte adds 1.6 characters to the code"73+        }6574 6675         val bytes = ByteArray(IP_BYTES + PORT_BYTES + token.size)6776         for (i in 0 until IP_BYTES) {@@ -78,7 +87,8 @@7887     /** Null for anything that is not a well-formed code, never an exception. */7988     fun parse(input: String): Parsed? {8089         val bytes = decodeCrockford(input) ?: return null81-        if (bytes.size < IP_BYTES + PORT_BYTES + MIN_TOKEN_BYTES) return null90+        val tokenBytes = bytes.size - IP_BYTES - PORT_BYTES91+        if (tokenBytes < MIN_TOKEN_BYTES || tokenBytes > MAX_TOKEN_BYTES) return null8292         val port = ((bytes[4].toInt() and 0xff) shl 8) or (bytes[5].toInt() and 0xff)8393         if (port == 0) return null8494         return Parsed(

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+15 −0
@@ -94,6 +94,21 @@9494                         textAlign = TextAlign.Center,9595                         modifier = Modifier.fillMaxWidth(),9696                     )97+                    // The code carries this phone's address, so a VPN that98+                    // routes the computer elsewhere makes it undialable. The99+                    // failure is a silent timeout with nothing to look at, so100+                    // say it here rather than let it be debugged from scratch.101+                    Spacer(Modifier.height(PeachSpacing.md))102+                    Text(103+                        text = "The code contains this phone's address on your network (${s.ip}). " +104+                            "If either device is on a VPN, turn it off for a moment — the computer " +105+                            "won't be able to reach this address through it.",106+                        color = peachColors.T2,107+                        fontFamily = PeachType.Body,108+                        fontSize = 11.sp,109+                        textAlign = TextAlign.Center,110+                        modifier = Modifier.fillMaxWidth(),111+                    )97112                 }98113 99114                 is CodePairUiState.ConfirmSas -> {

view file

app/src/test/java/com/peachpasswords/sync/PairingCodeTest.kt+25 −7
@@ -13,12 +13,14 @@1313  */1414 class PairingCodeTest {1515 16-    // encodePairingCode("192.168.7.115", 49142, deadbeef01234567) in peach-browser.17-    private val vectorCode = "R2M0-EWXZ-YVFA-VFQF-04HM-ASR"18-    private val vectorToken = byteArrayOf(19-        0xde.toByte(), 0xad.toByte(), 0xbe.toByte(), 0xef.toByte(), 0x01, 0x23, 0x45, 0x67,20-    )16+    // encodePairingCode("192.168.7.115", 49142, deadbeef) in peach-browser,17+    // re-generated after both sides moved to a 4-byte token.18+    private val vectorCode = "R2M0-EWXZ-YVFA-VFQF"19+    private val vectorToken = byteArrayOf(0xde.toByte(), 0xad.toByte(), 0xbe.toByte(), 0xef.toByte())2120 21+    /** The 8-byte code an older build would have shown. */22+    private val legacyCode = "R2M0-EWXZ-YVFA-VFQF-04HM-ASR"23+2224     @Test fun `encodes the browser's golden vector`() {2325         assertEquals(vectorCode, PairingCode.encode("192.168.7.115", 49142, vectorToken))2426     }@@ -27,6 +29,15 @@2729         val parsed = PairingCode.parse(vectorCode)!!2830         assertEquals("192.168.7.115", parsed.ip)2931         assertEquals(49142, parsed.port)32+        assertEquals("deadbeef", parsed.tokenHex)33+    }34+35+    @Test fun `still decodes a code from an older eight-byte build`() {36+        // The browser kept decoding lenient on purpose so a peer that hasn't37+        // updated still pairs rather than hard-failing; match that.38+        val parsed = PairingCode.parse(legacyCode)!!39+        assertEquals("192.168.7.115", parsed.ip)40+        assertEquals(49142, parsed.port)3041         assertEquals("deadbeef01234567", parsed.tokenHex)3142     }3243 @@ -41,7 +52,7 @@4152     @Test fun `folds the ambiguous glyphs a user might read off a screen`() {4253         // I/L -> 1 and O -> 0, so the doc's own example resolves rather than4354         // failing on characters Crockford's alphabet excludes.44-        val typed = PairingCode.parse("r2mO-ewxz-yvfa-vfqf-O4hm-asr")55+        val typed = PairingCode.parse("r2mO-ewxz-yvfa-vfqf")4556         assertEquals(PairingCode.parse(vectorCode), typed)4657     }4758 @@ -66,7 +77,14 @@6677     }6778 6879     @Test(expected = IllegalArgumentException::class)69-    fun `refuses a token below the browser's four-byte floor`() {80+    fun `refuses a token shorter than the canonical size`() {7081         PairingCode.encode("10.0.0.1", 8765, byteArrayOf(1, 2, 3))82+    }83+84+    @Test(expected = IllegalArgumentException::class)85+    fun `refuses a token longer than the canonical size`() {86+        // The browser throws here too. A longer token encodes fine and quietly87+        // produces a six-group code, which is the regression to prevent.88+        PairingCode.encode("10.0.0.1", 8765, ByteArray(8))7189     }7290 }

view file