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) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 09:45:23 +00:00
co-authored by Claude Opus 5.5
parent 7ea43f84f8
commit 4bbeda2215
4 changed files with 90 additions and 16 deletions
@@ -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)
}
}
@@ -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<String, Strikes>()
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"))
}
@@ -12,7 +12,8 @@ background:#ffde59;color:#071632;font-size:18px;font-weight:700}.note{color:#ffd
<p>Enter the code shown on the TV in Settings › Channels › Create a channel › On your phone.</p>${note?.let { "<p class='note'>${esc(it)}</p>" }.orEmpty()}
<form method="post" action="/join"><input name="code" inputmode="numeric" maxlength="4" autocomplete="one-time-code" autofocus><button>Join</button></form></main></body></html>"""
fun pin(k: String?, code: String?, wrong: Boolean) = """<!doctype html><html><head><title>Orbit</title>$STYLE</head><body><main><h1>Grown-ups PIN</h1>
<p>Making channels is for Grown-ups. Enter the PIN you use on the TV.</p>${if (wrong) "<p class='note'>That PIN didn't match.</p>" else ""}
<p>The code worked. Making channels is for Grown-ups, so now enter your <b>Grown-ups PIN</b>: the four digits that unlock After Dark on the TV.</p>
<p>It isn't the code on the TV screen.</p>${if (wrong) "<p class='note'>That PIN didn't match. It's the After Dark PIN, not the TV's code.</p>" else ""}
<form method="post" action="/join">${k?.let { "<input type='hidden' name='k' value='${esc(it)}'>" }.orEmpty()}${code?.let { "<input type='hidden' name='code' value='${esc(it)}'>" }.orEmpty()}
<input name="pin" type="password" inputmode="numeric" maxlength="8" autofocus><button>Continue</button></form></main></body></html>"""
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.")
@@ -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)) }
}
}