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:
@@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user