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
↓ peach-android-8ee5d862.tar.gzatlas at this save
files changed3 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 −7app/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(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 -> {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 }