From 4bbeda2215fdee1a81280d283ca38cebdbb0af8a Mon Sep 17 00:00:00 2001 From: Pauljd Date: Fri, 6 Nov 2026 09:45:23 +0000 Subject: [PATCH] Phone pairing: clearer Grown-ups PIN step, per-phone rate limit, address-only Host, outcome logs (from release 0.108.48) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/tv/orbit/classic/CreateChannel.kt | 2 + .../main/java/tv/orbit/classic/PhoneLink.kt | 60 +++++++++++++++---- .../main/java/tv/orbit/classic/PhonePages.kt | 3 +- .../java/tv/orbit/classic/PhoneLinkTest.kt | 41 +++++++++++-- 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt b/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt index 82b868a..3e53bca 100644 --- a/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt +++ b/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt @@ -199,6 +199,8 @@ private fun PhoneCard(modifier: Modifier) { } } Text(PhoneLink.active?.let { "Connected: $it" } ?: "Same Wi-Fi as this TV. The code works once, for two minutes.", color = if (PhoneLink.active != null) Mint else Muted, fontSize = 11.sp) + // The phone asks for the After Dark PIN next, which is also four digits: say so here, so it isn't mistaken for this code. + if (PhoneLink.active == null && AfterDarkLock.pinSet) Text("Then your phone asks for your Grown-ups PIN, the one for After Dark.", color = Muted, fontSize = 11.sp) } } diff --git a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt index 7a30f30..a63e511 100644 --- a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt +++ b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt @@ -28,12 +28,16 @@ import java.util.concurrent.Executors * on the home Wi-Fi, which a phone opens by scanning a QR code on the TV. No app, no account, and the Emby sign-in never * leaves the TV. The rules: * - Only addresses on the home network are answered; anything else is closed unread. - * - A phone joins with a one-time code from the TV, good for two minutes and five tries, and the Grown-ups PIN if one is set. + * - A phone joins with a one-time code from the TV, good for two minutes and five tries, and the Grown-ups PIN if one is set + * (three tries). Each phone address also has its own limit: five wrong answers in ten minutes and it's refused for ten. + * - Requests must name the TV by its address (Host is an IP), so a web page elsewhere can't reach it through a borrowed name. + * - A phone's token works only on the TV that issued it, and the phone page carries catalogue metadata only: titles, years, + * reasons and channel settings. No Emby or Jellyfin credentials, server addresses or playback tokens ever go to a phone. * - A joined phone holds a long random token in a cookie (HttpOnly, SameSite=Strict); the TV keeps only its hash, and * Settings lists phones and removes them. Changes also need a header a cross-site page can't send. * - The server runs only while Orbit is open, and only once a phone has joined or one is joining. */ -internal class Pairing(val code: String, val secret: String, val untilMs: Long) { var tries = 0 } +internal class Pairing(val code: String, val secret: String, val untilMs: Long) { var tries = 0; var pinTries = 0 } /** What the phone may ask of the screen that holds the library: Create your own's [ChannelMaker], what's on, and tuning. */ internal class PhoneHandler(val maker: ChannelMaker, val isOn: (Int) -> Boolean, val watch: (Int) -> Unit) @@ -50,6 +54,8 @@ private class PrefsPhoneStore(context: Context) : PhoneStore { internal object PhoneLink { const val PORT = 8099 + private const val STRIKE_LIMIT = 5 + private const val STRIKE_WINDOW_MS = 10 * 60_000L private const val COOKIE = "orbit_phone" private val random = SecureRandom() @Volatile private var server: ServerSocket? = null @@ -71,6 +77,10 @@ internal object PhoneLink { /** Where the server listens: every interface on the TV (tests keep it to this computer). */ internal var bindTo: InetAddress? = null internal var port = PORT + /** Wrong answers per phone address: how many, since when, and until when it's refused. */ + private class Strikes(var count: Int, var sinceMs: Long, var refusedUntilMs: Long = 0) + private val strikes = java.util.concurrent.ConcurrentHashMap() + internal var clock: () -> Long = System::currentTimeMillis /** Changes the phone asks for run on the screen's own thread. */ internal var mainDispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Main @@ -89,13 +99,13 @@ internal object PhoneLink { fun init(context: Context) { appContext = context.applicationContext; store = PrefsPhoneStore(context.applicationContext); loadPhones(); if (phones.isNotEmpty()) start() } /** For tests: a store of its own, no Android. */ - internal fun initForTest(testStore: PhoneStore) { store = testStore; loadPhones() } + internal fun initForTest(testStore: PhoneStore) { store = testStore; strikes.clear(); loadPhones() } /** A new one-time code, and the server running to receive it. */ fun beginPairing(): Pairing { val code = "%04d".format(random.nextInt(10_000)) val secret = ByteArray(16).also(random::nextBytes).joinToString("") { "%02x".format(it) } - return Pairing(code, secret, System.currentTimeMillis() + 2 * 60_000L).also { pairing = it; start() } + return Pairing(code, secret, clock() + 2 * 60_000L).also { pairing = it; start() } } fun pairingUrl(p: Pairing): String? = homeAddress()?.let { "http://$it:$PORT/join?k=${p.secret}" } @@ -153,14 +163,21 @@ internal object PhoneLink { return prefs().get(id)?.let { id } } + /** The Host a request names: an address, never a name a page elsewhere could point at this TV. */ + private fun hostIsAnAddress(host: String?): Boolean { + val name = host?.trim()?.let { if (it.startsWith("[")) it.substringBefore(']') + "]" else it.substringBefore(':') } ?: return false + return Regex("^\\d{1,3}(\\.\\d{1,3}){3}$").matches(name) || Regex("^\\[[0-9a-fA-F:.]+]$").matches(name) + } + private fun serve(socket: Socket) { socket.soTimeout = 15_000 if (!allowed(socket.inetAddress)) return val input = BufferedInputStream(socket.getInputStream()); val out = socket.getOutputStream() val r = readRequest(input) ?: return + if (!hostIsAnAddress(r.headers["host"])) return respond(out, 400, "text/plain", "Use the TV's address.".toByteArray()) val phone = phoneOf(r) when { - r.path == "/join" -> join(r, out) + r.path == "/join" -> join(r, out, socket.inetAddress.hostAddress.orEmpty()) phone == null -> page(out, PhonePages.join(null)) r.path == "/" -> page(out, PhonePages.app()) r.method == "POST" && r.headers["x-orbit"] != "1" -> json(out, 403, JSONObject().put("error", "Refused")) @@ -168,22 +185,43 @@ internal object PhoneLink { } } - private fun join(r: Request, out: OutputStream) { + /** A wrong answer from [who]; true when that phone address has now had too many. */ + private fun strike(who: String): Boolean { + val now = clock() + val s = strikes.compute(who) { _, old -> if (old == null || now - old.sinceMs > STRIKE_WINDOW_MS) Strikes(1, now) else old.apply { count++ } }!! + if (s.count >= STRIKE_LIMIT) s.refusedUntilMs = now + STRIKE_WINDOW_MS + return s.count >= STRIKE_LIMIT + } + private fun refused(who: String) = (strikes[who]?.refusedUntilMs ?: 0) > clock() + /** Joining's outcomes, counted only: never a code, PIN or address. */ + private fun note(outcome: String) = android.util.Log.i("OrbitPhone", "join outcome=$outcome") + + private fun join(r: Request, out: OutputStream, who: String) { + if (refused(who)) { note("refused"); return respond(out, 429, "text/html; charset=utf-8", PhonePages.join("Too many wrong tries from this phone. Wait ten minutes, then ask the TV for a new code.").toByteArray()) } val p = pairing // The code and PIN come in a posted form, so they never sit in an address or history. val form = if (r.method == "POST") r.body.split('&').filter { '=' in it }.associate { URLDecoder.decode(it.substringBefore('='), "UTF-8") to URLDecoder.decode(it.substringAfter('='), "UTF-8") } else emptyMap() val given = form["k"] ?: r.query["k"]; val code = form["code"]?.filter(Char::isDigit); val pin = form["pin"]?.filter(Char::isDigit) - if (p == null || System.currentTimeMillis() > p.untilMs) return page(out, PhonePages.join("That code has run out. Ask the TV for a new one.")) + if (p == null || clock() > p.untilMs) return page(out, PhonePages.join("That code has run out. Ask the TV for a new one.")) if (given == null && code == null) return page(out, PhonePages.join(null)) - if (p.tries++ >= 5) { pairing = null; return page(out, PhonePages.join("Too many tries. Ask the TV for a new code.")) } + if (p.tries++ >= 5) { pairing = null; note("used_up"); return page(out, PhonePages.join("Too many tries. Ask the TV for a new code.")) } val ok = (given != null && MessageDigest.isEqual(given.toByteArray(), p.secret.toByteArray())) || (code != null && code == p.code) - if (!ok) return page(out, PhonePages.join("That code didn't match. Check the TV.")) - if (pinNeeded() && (pin == null || !pinCheck(pin))) return page(out, PhonePages.pin(given, code, pin != null)) + if (!ok) { note("wrong_code"); strike(who); return page(out, PhonePages.join("That code didn't match. Check the TV.")) } + if (pinNeeded()) { + // The code is right; a PIN page doesn't spend the code's tries, but wrong PINs have their own three. + p.tries-- + if (pin == null) return page(out, PhonePages.pin(given, code, false)) + if (!pinCheck(pin)) { + note("wrong_pin"); strike(who) + if (++p.pinTries >= 3) { pairing = null; return page(out, PhonePages.join("That PIN didn't match three times. Ask the TV for a new code.")) } + return page(out, PhonePages.pin(given, code, true)) + } + } // Joined: a long random token the phone keeps; the TV keeps only its hash. val token = ByteArray(32).also(random::nextBytes).joinToString("") { "%02x".format(it) } val name = phoneName(r.headers["user-agent"].orEmpty()) prefs().put(hash(token), "$name|${System.currentTimeMillis()}") - pairing = null; loadPhones(); active = name + pairing = null; loadPhones(); active = name; strikes.remove(who); note("joined") respond(out, 302, "text/plain", ByteArray(0), listOf("Location: /", "Set-Cookie: $COOKIE=$token; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000")) } diff --git a/android/app/src/main/java/tv/orbit/classic/PhonePages.kt b/android/app/src/main/java/tv/orbit/classic/PhonePages.kt index 8e61492..df18cef 100644 --- a/android/app/src/main/java/tv/orbit/classic/PhonePages.kt +++ b/android/app/src/main/java/tv/orbit/classic/PhonePages.kt @@ -12,7 +12,8 @@ background:#ffde59;color:#071632;font-size:18px;font-weight:700}.note{color:#ffd

Enter the code shown on the TV in Settings › Channels › Create a channel › On your phone.

${note?.let { "

${esc(it)}

" }.orEmpty()}
""" fun pin(k: String?, code: String?, wrong: Boolean) = """Orbit$STYLE

Grown-ups PIN

-

Making channels is for Grown-ups. Enter the PIN you use on the TV.

${if (wrong) "

That PIN didn't match.

" else ""} +

The code worked. Making channels is for Grown-ups, so now enter your Grown-ups PIN: the four digits that unlock After Dark on the TV.

+

It isn't the code on the TV screen.

${if (wrong) "

That PIN didn't match. It's the After Dark PIN, not the TV's code.

" else ""}
${k?.let { "" }.orEmpty()}${code?.let { "" }.orEmpty()}
""" fun app(): String = runCatching { PhoneLink.context()?.assets?.open("phone/app.html")?.use { it.readBytes().toString(Charsets.UTF_8) } }.getOrNull() ?: join("The page couldn't be loaded.") diff --git a/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt index 760a394..0e97d08 100644 --- a/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt @@ -33,7 +33,7 @@ class PhoneLinkTest { { emptyList() }, { c, s -> made = c to s; "${c.name} is ready" }, { emptyList() }), { false }, { watched += it }) PhoneLink.start() } - @After fun down() { PhoneLink.stop(); PhoneLink.handler = null; PhoneLink.allowed = EmbyServer::isHomeAddress; PhoneLink.bindTo = null; PhoneLink.port = PhoneLink.PORT; CustomChannels.clear() } + @After fun down() { PhoneLink.stop(); PhoneLink.handler = null; PhoneLink.allowed = EmbyServer::isHomeAddress; PhoneLink.bindTo = null; PhoneLink.port = PhoneLink.PORT; PhoneLink.clock = System::currentTimeMillis; CustomChannels.clear() } private class Reply(val status: Int, val body: String, val cookie: String?) private fun call(path: String, method: String = "GET", body: String? = null, cookie: String? = null, form: Boolean = false, orbit: Boolean = true): Reply { @@ -79,16 +79,49 @@ class PhoneLinkTest { } @Test fun guessingRunsOutAndThePinIsAskedWhenThereIsOne() { + var now = System.currentTimeMillis(); PhoneLink.clock = { now } val p = PhoneLink.beginPairing() val wrong = if (p.code == "1111") "2222" else "1111" repeat(5) { call("/join", "POST", "code=$wrong", form = true) } - assertTrue(call("/join", "POST", "code=${p.code}", form = true).body.contains("Too many tries")) + val refused = call("/join", "POST", "code=${p.code}", form = true) + assertEquals(429, refused.status); assertTrue(refused.body.contains("Too many wrong tries from this phone")) // this phone, for ten minutes assertTrue(phones.isEmpty()) + now += 11 * 60_000L // ten minutes on: heard again PhoneLink.pinNeeded = { true }; PhoneLink.pinCheck = { it == "4321" } val q = PhoneLink.beginPairing() - assertTrue(call("/join", "POST", "code=${q.code}", form = true).body.contains("Grown-ups PIN")) - assertTrue(call("/join", "POST", "code=${q.code}&pin=1234", form = true).body.contains("didn't match")) + val asked = call("/join", "POST", "code=${q.code}", form = true).body + assertTrue(asked.contains("Grown-ups PIN")); assertTrue(asked.contains("isn't the code on the TV screen")) + assertTrue(call("/join", "POST", "code=${q.code}&pin=${q.code}", form = true).body.contains("not the TV's code")) + repeat(3) { call("/join", "POST", "code=${q.code}", form = true) } // asking again spends no tries assertEquals(302, call("/join", "POST", "code=${q.code}&pin=4321", form = true).status) PhoneLink.pinNeeded = { false } } + + @Test fun threeWrongPinsEndTheCode() { + PhoneLink.pinNeeded = { true }; PhoneLink.pinCheck = { it == "4321" } + val p = PhoneLink.beginPairing() + repeat(2) { assertTrue(call("/join", "POST", "code=${p.code}&pin=0000", form = true).body.contains("didn't match")) } + assertTrue(call("/join", "POST", "code=${p.code}&pin=0000", form = true).body.contains("three times")) + assertNull(PhoneLink.pairing) + assertTrue(call("/join", "POST", "code=${p.code}&pin=4321", form = true).body.contains("run out")) + PhoneLink.pinNeeded = { false } + } + + @Test fun onlyTheTvsAddressIsAnsweredNotABorrowedName() { + // A raw request: Java's own client won't let a Host be made up. + fun statusFor(host: String) = java.net.Socket(InetAddress.getLoopbackAddress(), port).use { s -> + s.getOutputStream().write("GET / HTTP/1.1\r\nHost: $host\r\n\r\n".toByteArray()) + s.getInputStream().bufferedReader().readLine() } + assertTrue(statusFor("evil.example:$port").contains(" 400 ")) + assertTrue(statusFor("127.0.0.1:$port").contains(" 200 ")) + } + + @Test fun thePhoneIsGivenCatalogueMetadataOnly() { + val p = PhoneLink.beginPairing() + val cookie = call("/join", "POST", "code=${p.code}", form = true).cookie!! + val suggest = JSONArray(call("/api/suggest?d=sci-fi", cookie = cookie).body).getJSONObject(0) + assertEquals(setOf("id", "title", "year", "type", "reason"), suggest.keys().asSequence().toSet()) + val everything = call("/api/channels", cookie = cookie).body + suggest + listOf("token", "api_key", "password", "X-Emby", "MediaBrowser", "http").forEach { assertFalse(it, everything.contains(it, ignoreCase = true)) } + } }